valtech-components 2.0.858 → 2.0.860

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/esm2022/lib/components/molecules/feedback-form/feedback-form.component.mjs +153 -268
  2. package/esm2022/lib/components/molecules/feedback-form/types.mjs +1 -1
  3. package/esm2022/lib/components/molecules/textarea-input/textarea-input.component.mjs +3 -3
  4. package/esm2022/lib/components/organisms/attachment-uploader/attachment-uploader.component.mjs +77 -0
  5. package/esm2022/lib/components/organisms/attachment-uploader/types.mjs +2 -0
  6. package/esm2022/lib/components/organisms/mfa-modal/mfa-modal.component.mjs +21 -3
  7. package/esm2022/lib/components/organisms/toolbar/toolbar.component.mjs +5 -5
  8. package/esm2022/lib/components/organisms/wizard/types.mjs +1 -1
  9. package/esm2022/lib/components/organisms/wizard/wizard.component.mjs +13 -37
  10. package/esm2022/lib/components/types.mjs +1 -1
  11. package/esm2022/lib/services/auth/auth.service.mjs +6 -1
  12. package/esm2022/lib/services/i18n/default-content.mjs +19 -1
  13. package/esm2022/lib/services/icons.service.mjs +3 -2
  14. package/esm2022/lib/version.mjs +2 -2
  15. package/esm2022/public-api.mjs +3 -1
  16. package/fesm2022/valtech-components.mjs +911 -933
  17. package/fesm2022/valtech-components.mjs.map +1 -1
  18. package/lib/components/molecules/feedback-form/feedback-form.component.d.ts +8 -39
  19. package/lib/components/molecules/feedback-form/types.d.ts +1 -0
  20. package/lib/components/organisms/attachment-uploader/attachment-uploader.component.d.ts +23 -0
  21. package/lib/components/organisms/attachment-uploader/types.d.ts +12 -0
  22. package/lib/components/organisms/mfa-modal/mfa-modal.component.d.ts +2 -0
  23. package/lib/components/organisms/wizard/types.d.ts +3 -3
  24. package/lib/components/organisms/wizard/wizard.component.d.ts +2 -1
  25. package/lib/components/types.d.ts +2 -0
  26. package/lib/services/auth/auth.service.d.ts +3 -0
  27. package/lib/version.d.ts +1 -1
  28. package/package.json +1 -1
  29. package/public-api.d.ts +2 -0
@@ -5,7 +5,7 @@ import { IonAvatar, IonCard, IonIcon, IonButton, IonSpinner, IonText, IonModal,
5
5
  import * as i1 from '@angular/common';
6
6
  import { CommonModule, NgStyle, NgFor, isPlatformBrowser, DOCUMENT, NgClass } from '@angular/common';
7
7
  import { addIcons } from 'ionicons';
8
- import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, warningOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, cameraOutline, bugOutline, bulbOutline, closeCircleOutline, menuOutline } from 'ionicons/icons';
8
+ import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, peopleOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, warningOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, attachOutline, cameraOutline, closeCircleOutline, imageOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, menuOutline } from 'ionicons/icons';
9
9
  import * as i1$1 from '@angular/router';
10
10
  import { RouterLink, Router, NavigationEnd, RouterOutlet, RouterModule } from '@angular/router';
11
11
  import { Browser } from '@capacitor/browser';
@@ -13,7 +13,7 @@ import * as i1$2 from '@angular/platform-browser';
13
13
  import { DomSanitizer, Meta, Title } from '@angular/platform-browser';
14
14
  import QRCodeStyling from 'qr-code-styling';
15
15
  import * as i1$3 from '@angular/forms';
16
- import { ReactiveFormsModule, FormsModule, FormControl, Validators, FormBuilder } from '@angular/forms';
16
+ import { ReactiveFormsModule, FormsModule, FormControl, Validators } from '@angular/forms';
17
17
  import { BehaviorSubject, map, filter as filter$1, interval, throwError, Subject, distinctUntilChanged, take as take$1, firstValueFrom, of, from, EMPTY, Observable, debounceTime, switchMap as switchMap$1, catchError as catchError$1, takeUntil, isObservable, shareReplay, race, timer } from 'rxjs';
18
18
  import * as i1$4 from 'ng-otp-input';
19
19
  import { NgOtpInputComponent, NgOtpInputModule } from 'ng-otp-input';
@@ -41,7 +41,7 @@ import * as i1$7 from '@angular/fire/storage';
41
41
  import { provideStorage, getStorage, connectStorageEmulator, ref, uploadBytesResumable, getDownloadURL, getMetadata, deleteObject, listAll } from '@angular/fire/storage';
42
42
  import { filter, catchError, switchMap, finalize, take, map as map$1, tap, retry, timeout, debounceTime as debounceTime$1, takeUntil as takeUntil$1 } from 'rxjs/operators';
43
43
  import * as i1$8 from '@angular/common/http';
44
- import { provideHttpClient, withInterceptors, HttpErrorResponse, HttpClient } from '@angular/common/http';
44
+ import { provideHttpClient, withInterceptors, HttpClient, HttpErrorResponse } from '@angular/common/http';
45
45
  import { isSupported, getMessaging as getMessaging$1 } from 'firebase/messaging';
46
46
  import { getApps, getApp, initializeApp as initializeApp$1 } from 'firebase/app';
47
47
  import { ImageCropperComponent } from 'ngx-image-cropper';
@@ -53,7 +53,7 @@ import 'prismjs/components/prism-json';
53
53
  * Current version of valtech-components.
54
54
  * This is automatically updated during the publish process.
55
55
  */
56
- const VERSION = '2.0.858';
56
+ const VERSION = '2.0.860';
57
57
 
58
58
  /**
59
59
  * Servicio para gestionar presets de componentes.
@@ -347,6 +347,7 @@ class IconService {
347
347
  createOutline,
348
348
  trashOutline,
349
349
  playOutline,
350
+ peopleOutline,
350
351
  phonePortraitOutline,
351
352
  refreshOutline,
352
353
  documentTextOutline,
@@ -4043,6 +4044,11 @@ const VALTECH_DEFAULT_CONTENT = {
4043
4044
  feedbackType: 'Tipo de feedback',
4044
4045
  feedbackSuccess: 'Feedback enviado exitosamente',
4045
4046
  feedbackError: 'Error al enviar el feedback',
4047
+ // Componentes - AttachmentUploader
4048
+ attachAdd: 'Adjuntar archivo',
4049
+ attachCamera: 'Usar cámara',
4050
+ attachMaxCount: 'Límite de {count} archivos alcanzado',
4051
+ attachUploadFailed: 'Error al subir el archivo',
4046
4052
  titlePlaceholder: 'Describe brevemente...',
4047
4053
  titleValidation: 'El título debe tener entre 5 y 200 caracteres',
4048
4054
  descriptionPlaceholder: 'Proporciona más detalles...',
@@ -4190,6 +4196,11 @@ const VALTECH_DEFAULT_CONTENT = {
4190
4196
  feedbackType: 'Feedback type',
4191
4197
  feedbackSuccess: 'Feedback sent successfully',
4192
4198
  feedbackError: 'Error sending feedback',
4199
+ // Components - AttachmentUploader
4200
+ attachAdd: 'Attach file',
4201
+ attachCamera: 'Use camera',
4202
+ attachMaxCount: 'Limit of {count} files reached',
4203
+ attachUploadFailed: 'Error uploading file',
4193
4204
  titlePlaceholder: 'Describe briefly...',
4194
4205
  titleValidation: 'Title must be between 5 and 200 characters',
4195
4206
  descriptionPlaceholder: 'Provide more details...',
@@ -4403,6 +4414,10 @@ const VALTECH_DEFAULT_CONTENT = {
4403
4414
  mfaCodesCopied: 'Códigos copiados al portapapeles.',
4404
4415
  mfaSecretCopied: 'Secreto copiado al portapapeles.',
4405
4416
  mfaDisableTotpPrompt: 'Ingresá el código de tu app de autenticación para deshabilitar MFA.',
4417
+ mfaDisableCodePrompt: 'Ingresá el código que enviamos a tu correo/teléfono para deshabilitar MFA.',
4418
+ mfaDisableSendCode: 'Enviar código',
4419
+ mfaDisableCodeSent: 'Código enviado. Revisá tu correo o teléfono.',
4420
+ mfaDisableResendCode: 'Reenviar código',
4406
4421
  mfaDisableNeedsPassword: 'Esta cuenta no tiene contraseña. Primero seteá una desde "Olvidé mi contraseña" y luego volvé a deshabilitar MFA.',
4407
4422
  mfaErrorInvalidCode: 'Código incorrecto.',
4408
4423
  mfaErrorExpiredCode: 'El código ha expirado. Solicita uno nuevo.',
@@ -4562,6 +4577,10 @@ const VALTECH_DEFAULT_CONTENT = {
4562
4577
  mfaCodesCopied: 'Codes copied to clipboard.',
4563
4578
  mfaSecretCopied: 'Secret copied to clipboard.',
4564
4579
  mfaDisableTotpPrompt: 'Enter the code from your authenticator app to disable MFA.',
4580
+ mfaDisableCodePrompt: 'Enter the code we sent to your email/phone to disable MFA.',
4581
+ mfaDisableSendCode: 'Send code',
4582
+ mfaDisableCodeSent: 'Code sent. Check your email or phone.',
4583
+ mfaDisableResendCode: 'Resend code',
4565
4584
  mfaDisableNeedsPassword: 'This account has no password. Set one via "Forgot password" first, then come back to disable MFA.',
4566
4585
  mfaErrorInvalidCode: 'Incorrect code.',
4567
4586
  mfaErrorExpiredCode: 'The code has expired. Request a new one.',
@@ -14206,7 +14225,7 @@ class TextareaInputComponent {
14206
14225
  </ion-note>
14207
14226
  }
14208
14227
  </div>
14209
- `, isInline: true, styles: [":host{display:block;width:100%}.textarea-container{position:relative}.textarea-container ion-textarea{--background: var(--ion-background-color, #fff);--padding-start: 12px;--padding-end: 12px;--padding-top: 12px;--padding-bottom: 12px}.textarea-container ion-textarea.no-resize textarea{resize:none!important}.textarea-container.has-error ion-textarea{--border-color: var(--ion-color-danger);--highlight-color: var(--ion-color-danger)}.char-counter{font-size:12px;color:var(--ion-color-medium);text-align:right;margin-top:4px;padding-right:4px}.char-counter.remaining{color:var(--ion-color-medium-shade)}.hint{display:block;font-size:12px;color:var(--ion-color-medium);margin-top:4px;padding-left:4px}.error-message{display:block;font-size:12px;margin-top:4px;padding-left:4px}ion-textarea::part(counter){font-size:12px;color:var(--ion-color-medium)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.MinLengthValidator, selector: "[minlength][formControlName],[minlength][formControl],[minlength][ngModel]", inputs: ["minlength"] }, { kind: "directive", type: i1$3.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: IonTextarea, selector: "ion-textarea", inputs: ["autoGrow", "autocapitalize", "autofocus", "clearOnEdit", "color", "cols", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "maxlength", "minlength", "mode", "name", "placeholder", "readonly", "required", "rows", "shape", "spellcheck", "value", "wrap"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }] }); }
14228
+ `, isInline: true, styles: [":host{display:block;width:100%}.textarea-container{position:relative}.textarea-container ion-textarea{--background: var(--ion-background-color, #fff);--padding-start: 12px;--padding-end: 12px;--padding-top: 12px;--padding-bottom: 12px}.textarea-container ion-textarea.no-resize textarea{resize:none!important}.textarea-container.has-error ion-textarea{--border-color: var(--ion-color-danger);--highlight-color: var(--ion-color-danger)}.char-counter{font-size:12px;color:var(--ion-color-medium);text-align:right;margin-top:4px;padding-right:4px}.char-counter.remaining{color:var(--ion-color-medium-shade)}.hint{display:block;font-size:12px;color:var(--ion-color-dark);margin-top:4px;padding-left:4px}.error-message{display:block;font-size:12px;margin-top:4px;padding-left:4px}ion-textarea::part(counter){font-size:12px;color:var(--ion-color-medium)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.MinLengthValidator, selector: "[minlength][formControlName],[minlength][formControl],[minlength][ngModel]", inputs: ["minlength"] }, { kind: "directive", type: i1$3.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: IonTextarea, selector: "ion-textarea", inputs: ["autoGrow", "autocapitalize", "autofocus", "clearOnEdit", "color", "cols", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "maxlength", "minlength", "mode", "name", "placeholder", "readonly", "required", "rows", "shape", "spellcheck", "value", "wrap"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }] }); }
14210
14229
  }
14211
14230
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TextareaInputComponent, decorators: [{
14212
14231
  type: Component,
@@ -14250,7 +14269,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
14250
14269
  </ion-note>
14251
14270
  }
14252
14271
  </div>
14253
- `, styles: [":host{display:block;width:100%}.textarea-container{position:relative}.textarea-container ion-textarea{--background: var(--ion-background-color, #fff);--padding-start: 12px;--padding-end: 12px;--padding-top: 12px;--padding-bottom: 12px}.textarea-container ion-textarea.no-resize textarea{resize:none!important}.textarea-container.has-error ion-textarea{--border-color: var(--ion-color-danger);--highlight-color: var(--ion-color-danger)}.char-counter{font-size:12px;color:var(--ion-color-medium);text-align:right;margin-top:4px;padding-right:4px}.char-counter.remaining{color:var(--ion-color-medium-shade)}.hint{display:block;font-size:12px;color:var(--ion-color-medium);margin-top:4px;padding-left:4px}.error-message{display:block;font-size:12px;margin-top:4px;padding-left:4px}ion-textarea::part(counter){font-size:12px;color:var(--ion-color-medium)}\n"] }]
14272
+ `, styles: [":host{display:block;width:100%}.textarea-container{position:relative}.textarea-container ion-textarea{--background: var(--ion-background-color, #fff);--padding-start: 12px;--padding-end: 12px;--padding-top: 12px;--padding-bottom: 12px}.textarea-container ion-textarea.no-resize textarea{resize:none!important}.textarea-container.has-error ion-textarea{--border-color: var(--ion-color-danger);--highlight-color: var(--ion-color-danger)}.char-counter{font-size:12px;color:var(--ion-color-medium);text-align:right;margin-top:4px;padding-right:4px}.char-counter.remaining{color:var(--ion-color-medium-shade)}.hint{display:block;font-size:12px;color:var(--ion-color-dark);margin-top:4px;padding-left:4px}.error-message{display:block;font-size:12px;margin-top:4px;padding-left:4px}ion-textarea::part(counter){font-size:12px;color:var(--ion-color-medium)}\n"] }]
14254
14273
  }], propDecorators: { preset: [{
14255
14274
  type: Input
14256
14275
  }], props: [{
@@ -25867,6 +25886,11 @@ class AuthService {
25867
25886
  .post(`${this.baseUrl}/mfa/disable`, input)
25868
25887
  .pipe(catchError(error => this.handleAuthError(error)));
25869
25888
  }
25889
+ sendMFADisableCode() {
25890
+ return this.http
25891
+ .post(`${this.baseUrl}/mfa/disable-code`, {})
25892
+ .pipe(catchError(error => this.handleAuthError(error)));
25893
+ }
25870
25894
  // =============================================
25871
25895
  // TOTP MFA (Google Authenticator)
25872
25896
  // =============================================
@@ -27653,196 +27677,758 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
27653
27677
  }] });
27654
27678
 
27655
27679
  /**
27656
- * Configuración de espaciado predefinida
27680
+ * Token de inyección para la configuración de Feedback.
27657
27681
  */
27658
- const ARTICLE_SPACING = {
27659
- NONE: { top: 'none', bottom: 'none' },
27660
- SMALL: { top: 'small', bottom: 'small' },
27661
- MEDIUM: { top: 'medium', bottom: 'medium' },
27662
- LARGE: { top: 'large', bottom: 'large' },
27663
- XLARGE: { top: 'xlarge', bottom: 'xlarge' },
27664
- // Espaciados específicos para elementos
27665
- TITLE: { top: 'large', bottom: 'medium' },
27666
- SUBTITLE: { top: 'medium', bottom: 'small' },
27667
- PARAGRAPH: { top: 'small', bottom: 'medium' },
27668
- QUOTE: { top: 'medium', bottom: 'medium' },
27669
- CODE: { top: 'medium', bottom: 'medium' },
27670
- NOTE: { top: 'medium', bottom: 'medium' },
27671
- COMMAND: { top: 'medium', bottom: 'medium' },
27672
- LIST: { top: 'small', bottom: 'medium' },
27673
- BUTTON: { top: 'medium', bottom: 'medium' },
27674
- IMAGE: { top: 'large', bottom: 'large' },
27675
- SEPARATOR: { top: 'large', bottom: 'large' },
27682
+ const VALTECH_FEEDBACK_CONFIG = new InjectionToken('ValtechFeedbackConfig');
27683
+ /**
27684
+ * Configuración por defecto.
27685
+ */
27686
+ const DEFAULT_FEEDBACK_CONFIG = {
27687
+ feedbackPrefix: '/v1/feedback',
27688
+ maxAttachments: 5,
27689
+ // Estándar acordado para adjuntos de feedback: solo imágenes (JPEG/PNG/WebP)
27690
+ // y PDF, máx 5 MB. Reflejado en `storage.rules` (path `users/{uid}/feedback/`).
27691
+ maxFileSize: 5 * 1024 * 1024,
27692
+ allowedFileTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
27693
+ storagePath: 'feedback',
27676
27694
  };
27677
27695
  /**
27678
- * Función helper para crear elementos de artículo de forma fácil
27696
+ * Provee el servicio de feedback a la aplicación Angular.
27697
+ *
27698
+ * @param config - Configuración de feedback
27699
+ * @returns EnvironmentProviders para usar en bootstrapApplication
27700
+ *
27701
+ * @example
27702
+ * ```typescript
27703
+ * // main.ts
27704
+ * import { bootstrapApplication } from '@angular/platform-browser';
27705
+ * import { provideValtechFeedback } from 'valtech-components';
27706
+ * import { environment } from './environments/environment';
27707
+ *
27708
+ * bootstrapApplication(AppComponent, {
27709
+ * providers: [
27710
+ * provideValtechAuth({ apiUrl: environment.apiUrl }),
27711
+ * provideValtechFeedback({
27712
+ * apiUrl: environment.apiUrl,
27713
+ * appId: 'my-app-name',
27714
+ * }),
27715
+ * ],
27716
+ * });
27717
+ * ```
27679
27718
  */
27680
- class ArticleBuilder {
27719
+ function provideValtechFeedback(config) {
27720
+ const mergedConfig = {
27721
+ ...DEFAULT_FEEDBACK_CONFIG,
27722
+ ...config,
27723
+ };
27724
+ return makeEnvironmentProviders([{ provide: VALTECH_FEEDBACK_CONFIG, useValue: mergedConfig }]);
27725
+ }
27726
+
27727
+ /**
27728
+ * Servicio para gestionar feedback de usuarios.
27729
+ *
27730
+ * @example
27731
+ * ```typescript
27732
+ * @Component({...})
27733
+ * export class MyComponent {
27734
+ * private feedbackService = inject(FeedbackService);
27735
+ *
27736
+ * async submitFeedback() {
27737
+ * const response = await this.feedbackService.createAsync(
27738
+ * 'feedback',
27739
+ * 'Mi comentario',
27740
+ * 'Descripción detallada...'
27741
+ * );
27742
+ * console.log('Feedback enviado:', response.feedbackId);
27743
+ * }
27744
+ * }
27745
+ * ```
27746
+ */
27747
+ class FeedbackService {
27681
27748
  constructor() {
27682
- this.elements = [];
27749
+ this.config = inject(VALTECH_FEEDBACK_CONFIG);
27750
+ this.http = inject(HttpClient);
27751
+ this.firestore = inject(FirestoreService, { optional: true });
27752
+ this.storage = inject(StorageService, { optional: true });
27753
+ this.auth = inject(AuthService, { optional: true });
27683
27754
  }
27684
27755
  /**
27685
- * Añade un título al artículo
27756
+ * URL base para endpoints de feedback.
27686
27757
  */
27687
- title(props, options) {
27688
- this.elements.push({
27689
- type: 'title',
27690
- props,
27691
- ...options,
27692
- });
27693
- return this;
27758
+ get baseUrl() {
27759
+ return `${this.config.apiUrl}${this.config.feedbackPrefix}`;
27694
27760
  }
27695
27761
  /**
27696
- * Añade un subtítulo al artículo
27762
+ * Captura el contexto del dispositivo automáticamente.
27697
27763
  */
27698
- subtitle(props, options) {
27699
- this.elements.push({
27700
- type: 'subtitle',
27701
- props,
27702
- ...options,
27703
- });
27704
- return this;
27764
+ captureDeviceContext() {
27765
+ const ua = navigator.userAgent;
27766
+ return {
27767
+ browser: this.detectBrowser(ua),
27768
+ os: this.detectOS(ua),
27769
+ viewport: `${window.innerWidth}x${window.innerHeight}`,
27770
+ language: navigator.language,
27771
+ userAgent: ua,
27772
+ pageUrl: window.location.href,
27773
+ };
27705
27774
  }
27706
27775
  /**
27707
- * Añade un párrafo al artículo
27776
+ * Crea un nuevo feedback.
27777
+ *
27778
+ * @param type - Tipo de feedback
27779
+ * @param title - Título del feedback
27780
+ * @param description - Descripción detallada
27781
+ * @param attachments - URLs de archivos adjuntos (opcional)
27782
+ * @param contentRef - Referencia a contenido específico (opcional)
27783
+ * @returns Observable con la respuesta
27708
27784
  */
27709
- paragraph(props, options) {
27710
- this.elements.push({
27711
- type: 'paragraph',
27712
- props,
27713
- ...options,
27714
- });
27715
- return this;
27785
+ create(type, title, description, attachments = [], contentRef) {
27786
+ const request = {
27787
+ type,
27788
+ title,
27789
+ description,
27790
+ attachments,
27791
+ contentRef,
27792
+ deviceContext: this.captureDeviceContext(),
27793
+ appId: this.config.appId,
27794
+ };
27795
+ return this.http.post(this.baseUrl, request);
27716
27796
  }
27717
27797
  /**
27718
- * Añade una cita al artículo
27798
+ * Crea un nuevo feedback (versión async/await).
27719
27799
  */
27720
- quote(props, options) {
27721
- this.elements.push({
27722
- type: 'quote',
27723
- props,
27724
- ...options,
27725
- });
27726
- return this;
27800
+ async createAsync(type, title, description, attachments = [], contentRef) {
27801
+ return firstValueFrom(this.create(type, title, description, attachments, contentRef));
27727
27802
  }
27728
27803
  /**
27729
- * Añade código al artículo
27804
+ * Obtiene un feedback por ID (solo el propietario).
27805
+ *
27806
+ * @param feedbackId - ID del feedback
27807
+ * @returns Observable con la respuesta
27730
27808
  */
27731
- code(code, language, options) {
27732
- this.elements.push({
27733
- type: 'code',
27734
- props: { code, language },
27735
- ...options,
27736
- });
27737
- return this;
27809
+ getById(feedbackId) {
27810
+ return this.http.get(`${this.baseUrl}/${feedbackId}`);
27738
27811
  }
27739
27812
  /**
27740
- * Añade una lista al artículo
27813
+ * Obtiene un feedback por ID (versión async/await).
27741
27814
  */
27742
- list(items, listType, options) {
27743
- this.elements.push({
27744
- type: 'list',
27745
- props: { items, listType },
27746
- ...options,
27747
- });
27748
- return this;
27815
+ async getByIdAsync(feedbackId) {
27816
+ return firstValueFrom(this.getById(feedbackId));
27749
27817
  }
27750
27818
  /**
27751
- * Añade un botón al artículo
27819
+ * Valida si un archivo cumple con las restricciones.
27752
27820
  */
27753
- button(props, alignment, options) {
27754
- this.elements.push({
27755
- type: 'button',
27756
- props: { ...props, alignment },
27757
- ...options,
27821
+ validateFile(file) {
27822
+ // Verificar tamaño
27823
+ if (file.size > this.config.maxFileSize) {
27824
+ const maxSizeMB = Math.round(this.config.maxFileSize / (1024 * 1024));
27825
+ return {
27826
+ valid: false,
27827
+ error: `El archivo excede el tamaño máximo de ${maxSizeMB}MB`,
27828
+ };
27829
+ }
27830
+ // Verificar tipo
27831
+ const allowedTypes = this.config.allowedFileTypes || [];
27832
+ const isAllowed = allowedTypes.some(pattern => {
27833
+ if (pattern.endsWith('/*')) {
27834
+ const baseType = pattern.replace('/*', '');
27835
+ return file.type.startsWith(baseType);
27836
+ }
27837
+ return file.type === pattern;
27758
27838
  });
27759
- return this;
27839
+ if (!isAllowed) {
27840
+ return {
27841
+ valid: false,
27842
+ error: 'Tipo de archivo no permitido',
27843
+ };
27844
+ }
27845
+ return { valid: true };
27760
27846
  }
27761
27847
  /**
27762
- * Añade un separador al artículo
27848
+ * Valida el CONTENIDO del archivo por magic-bytes (firma binaria) —
27849
+ * defensa contra spoofing del content-type declarado. Lee los primeros 12
27850
+ * bytes y los compara con las firmas de JPEG/PNG/WebP/PDF (whitelist
27851
+ * estricta). Es client-side, ergo bypasseable; la defensa real vive en las
27852
+ * Storage rules. Esto corta el 99% de los casos accidentales o no-targeted.
27763
27853
  */
27764
- separator(style, options) {
27765
- this.elements.push({
27766
- type: 'separator',
27767
- props: { style },
27768
- ...options,
27769
- });
27770
- return this;
27854
+ async validateFileContent(file) {
27855
+ const buf = await file.slice(0, 12).arrayBuffer();
27856
+ const b = new Uint8Array(buf);
27857
+ // JPEG: FF D8 FF
27858
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
27859
+ return { valid: true };
27860
+ }
27861
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
27862
+ if (b[0] === 0x89 &&
27863
+ b[1] === 0x50 &&
27864
+ b[2] === 0x4e &&
27865
+ b[3] === 0x47 &&
27866
+ b[4] === 0x0d &&
27867
+ b[5] === 0x0a &&
27868
+ b[6] === 0x1a &&
27869
+ b[7] === 0x0a) {
27870
+ return { valid: true };
27871
+ }
27872
+ // WebP: "RIFF" (0..3) + "WEBP" (8..11)
27873
+ if (b[0] === 0x52 &&
27874
+ b[1] === 0x49 &&
27875
+ b[2] === 0x46 &&
27876
+ b[3] === 0x46 &&
27877
+ b[8] === 0x57 &&
27878
+ b[9] === 0x45 &&
27879
+ b[10] === 0x42 &&
27880
+ b[11] === 0x50) {
27881
+ return { valid: true };
27882
+ }
27883
+ // PDF: "%PDF-"
27884
+ if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) {
27885
+ return { valid: true };
27886
+ }
27887
+ return {
27888
+ valid: false,
27889
+ error: 'El contenido del archivo no coincide con un tipo permitido (imagen o PDF).',
27890
+ };
27771
27891
  }
27772
27892
  /**
27773
- * Añade una imagen al artículo
27893
+ * Sube un adjunto a Firebase Storage en `users/{uid}/feedback/{uuid}/{name}`
27894
+ * y devuelve su download URL. Ejecuta tres validaciones en orden:
27895
+ * 1. tamaño + tipo declarado (`validateFile`)
27896
+ * 2. contenido por magic-bytes (`validateFileContent`)
27897
+ * 3. usuario autenticado (no soportamos adjuntos en feedback anónimo)
27898
+ *
27899
+ * Si cualquier validación falla → rechaza la promesa con un Error legible;
27900
+ * el componente que la consuma debe cancelar la operación de adjuntar.
27774
27901
  */
27775
- image(src, alt, caption, options) {
27776
- this.elements.push({
27777
- type: 'image',
27778
- props: { src, alt, caption },
27779
- ...options,
27902
+ async uploadAttachment(file) {
27903
+ const sizeTypeCheck = this.validateFile(file);
27904
+ if (!sizeTypeCheck.valid) {
27905
+ throw new Error(sizeTypeCheck.error);
27906
+ }
27907
+ const contentCheck = await this.validateFileContent(file);
27908
+ if (!contentCheck.valid) {
27909
+ throw new Error(contentCheck.error);
27910
+ }
27911
+ const userId = this.auth?.user()?.userId;
27912
+ if (!userId) {
27913
+ throw new Error('Debes iniciar sesión para adjuntar archivos.');
27914
+ }
27915
+ if (!this.storage) {
27916
+ throw new Error('StorageService no está configurado.');
27917
+ }
27918
+ // Path bajo `users/{uid}/feedback/` — sibling de `files/`, con su propia
27919
+ // regla en `storage.rules` (whitelist estricta JPEG/PNG/WebP/PDF + 5MB).
27920
+ // skipPrefix=true porque `users/{uid}/` es path GLOBAL cross-app.
27921
+ const uuid = typeof crypto !== 'undefined' && 'randomUUID' in crypto
27922
+ ? crypto.randomUUID()
27923
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
27924
+ const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
27925
+ const path = `users/${userId}/feedback/${uuid}/${safeName}`;
27926
+ const result = await this.storage.uploadAndGetUrl(path, file, {
27927
+ contentType: file.type,
27928
+ skipPrefix: true,
27780
27929
  });
27781
- return this;
27930
+ return result.downloadUrl;
27782
27931
  }
27783
27932
  /**
27784
- * Añade una nota destacada al artículo
27933
+ * Obtiene la configuración actual del servicio.
27785
27934
  */
27786
- note(text, prefix, color, options) {
27787
- this.elements.push({
27788
- type: 'note',
27789
- props: {
27790
- text,
27791
- prefix: prefix || 'Nota:',
27792
- color: color || 'warning',
27793
- textColor: 'dark',
27794
- size: 'medium',
27795
- rounded: true,
27796
- },
27797
- ...options,
27798
- });
27799
- return this;
27935
+ getConfig() {
27936
+ return this.config;
27800
27937
  }
27938
+ // =========================================================================
27939
+ // Reaction Methods (Content feedback with emojis)
27940
+ // =========================================================================
27801
27941
  /**
27802
- * Añade un comando de terminal al artículo
27803
- * Acepta un string simple o un array de comandos
27942
+ * Verifica si el usuario ya dio feedback para una entidad específica.
27943
+ *
27944
+ * Primero intenta leer de Firebase (rápido, sin latencia de red al backend).
27945
+ * Si Firebase no está disponible o falla, hace fallback a la API.
27946
+ *
27947
+ * @param entityType - Tipo de entidad (article, docs, feature, etc.)
27948
+ * @param entityId - ID de la entidad
27949
+ * @returns Promise con la respuesta de verificación
27950
+ *
27951
+ * @example
27952
+ * ```typescript
27953
+ * const check = await this.feedbackService.checkFeedback('article', 'art-123');
27954
+ * if (check.hasFeedback) {
27955
+ * console.log('Ya dio feedback:', check.reactionValue);
27956
+ * }
27957
+ * ```
27804
27958
  */
27805
- command(command, options) {
27806
- const commands = Array.isArray(command) ? command : [command];
27807
- this.elements.push({
27808
- type: 'command',
27809
- props: {
27810
- lines: commands.map(cmd => ({ text: cmd, type: 'command' })),
27811
- showCopyButton: true,
27812
- },
27813
- ...options,
27959
+ async checkFeedback(entityType, entityId) {
27960
+ // Si no hay usuario autenticado, no puede haber feedback previo
27961
+ // Retornar inmediatamente sin llamar al API (evita 401 y redirect a login)
27962
+ const userId = this.auth?.user()?.userId;
27963
+ if (!userId) {
27964
+ return { operationId: '', hasFeedback: false };
27965
+ }
27966
+ // 1. Intentar Firebase primero (si está disponible)
27967
+ if (this.firestore) {
27968
+ try {
27969
+ // Path: feedback/{entityType}/{entityId}/{userId}
27970
+ // FirestoreService agrega automáticamente el prefijo apps/{appId}/
27971
+ const collectionPath = `feedback/${entityType}/${entityId}`;
27972
+ const doc = await this.firestore.getDoc(collectionPath, userId);
27973
+ if (doc) {
27974
+ return {
27975
+ operationId: '',
27976
+ hasFeedback: true,
27977
+ feedbackId: doc.feedbackId,
27978
+ type: doc.type,
27979
+ reactionValue: doc.reactionValue,
27980
+ createdAt: doc.createdAt?.toISOString(),
27981
+ };
27982
+ }
27983
+ // Doc no existe = no hay feedback
27984
+ return { operationId: '', hasFeedback: false };
27985
+ }
27986
+ catch (error) {
27987
+ console.warn('[FeedbackService] Firebase check failed, falling back to API:', error);
27988
+ // Fallback a API
27989
+ }
27990
+ }
27991
+ // 2. Fallback: llamar API (solo si hay usuario autenticado)
27992
+ const params = new URLSearchParams({
27993
+ appId: this.config.appId,
27994
+ entityType,
27995
+ entityId,
27814
27996
  });
27815
- return this;
27997
+ return firstValueFrom(this.http.get(`${this.baseUrl}/check?${params}`));
27816
27998
  }
27817
27999
  /**
27818
- * Construye el artículo final
28000
+ * Crea o actualiza una reacción (feedback con emoji).
28001
+ *
28002
+ * @param entityRef - Referencia a la entidad
28003
+ * @param value - Valor de la reacción (negative, neutral, positive)
28004
+ * @param comment - Comentario opcional (máx 500 caracteres)
28005
+ * @returns Promise con la respuesta
28006
+ *
28007
+ * @example
28008
+ * ```typescript
28009
+ * const response = await this.feedbackService.createReaction(
28010
+ * { entityType: 'article', entityId: 'art-123' },
28011
+ * 'positive',
28012
+ * 'Muy útil!'
28013
+ * );
28014
+ * ```
27819
28015
  */
27820
- build(config) {
27821
- return {
27822
- elements: this.elements,
27823
- maxWidth: 'auto',
27824
- centered: true,
27825
- theme: 'auto',
27826
- ...config,
28016
+ async createReaction(entityRef, value, comment) {
28017
+ const request = {
28018
+ type: 'reaction',
28019
+ entityRef,
28020
+ reactionValue: value,
28021
+ description: comment || '',
28022
+ deviceContext: this.captureDeviceContext(),
28023
+ appId: this.config.appId,
27827
28024
  };
28025
+ return firstValueFrom(this.http.post(this.baseUrl, request));
27828
28026
  }
27829
28027
  /**
27830
- * Resetea el builder para crear un nuevo artículo
28028
+ * Crea feedback anónimo (sin autenticación requerida).
28029
+ * Usado para blogs, FAQs y contenido público.
28030
+ *
28031
+ * @param entityRef - Referencia a la entidad
28032
+ * @param value - Valor de la reacción (negative, neutral, positive)
28033
+ * @param comment - Comentario opcional (máx 500 caracteres)
28034
+ * @returns Promise con la respuesta
28035
+ *
28036
+ * @example
28037
+ * ```typescript
28038
+ * // En un blog público
28039
+ * const response = await this.feedbackService.createAnonymousReaction(
28040
+ * { entityType: 'blog', entityId: 'my-post-slug' },
28041
+ * 'positive'
28042
+ * );
28043
+ * ```
27831
28044
  */
27832
- clear() {
27833
- this.elements = [];
27834
- return this;
28045
+ async createAnonymousReaction(entityRef, value, comment) {
28046
+ const request = {
28047
+ type: 'reaction',
28048
+ entityRef,
28049
+ reactionValue: value,
28050
+ description: comment || '',
28051
+ deviceContext: this.captureDeviceContext(),
28052
+ appId: this.config.appId,
28053
+ };
28054
+ return firstValueFrom(this.http.post(`${this.baseUrl}/anonymous`, request));
28055
+ }
28056
+ // =========================================================================
28057
+ // Helpers privados para detección de browser/OS
28058
+ // =========================================================================
28059
+ detectBrowser(ua) {
28060
+ if (ua.includes('Edg/'))
28061
+ return 'Edge';
28062
+ if (ua.includes('Chrome/'))
28063
+ return 'Chrome';
28064
+ if (ua.includes('Firefox/'))
28065
+ return 'Firefox';
28066
+ if (ua.includes('Safari/') && !ua.includes('Chrome'))
28067
+ return 'Safari';
28068
+ if (ua.includes('Opera') || ua.includes('OPR/'))
28069
+ return 'Opera';
28070
+ return 'Unknown';
28071
+ }
28072
+ detectOS(ua) {
28073
+ if (ua.includes('Windows NT 10'))
28074
+ return 'Windows 10';
28075
+ if (ua.includes('Windows NT 11'))
28076
+ return 'Windows 11';
28077
+ if (ua.includes('Windows'))
28078
+ return 'Windows';
28079
+ if (ua.includes('Mac OS X')) {
28080
+ const match = ua.match(/Mac OS X (\d+[._]\d+)/);
28081
+ if (match) {
28082
+ return `macOS ${match[1].replace('_', '.')}`;
28083
+ }
28084
+ return 'macOS';
28085
+ }
28086
+ if (ua.includes('Android'))
28087
+ return 'Android';
28088
+ if (ua.includes('iPhone') || ua.includes('iPad'))
28089
+ return 'iOS';
28090
+ if (ua.includes('Linux'))
28091
+ return 'Linux';
28092
+ return 'Unknown';
27835
28093
  }
28094
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
28095
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, providedIn: 'root' }); }
27836
28096
  }
28097
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, decorators: [{
28098
+ type: Injectable,
28099
+ args: [{ providedIn: 'root' }]
28100
+ }] });
27837
28101
 
27838
28102
  /**
27839
- * val-article
27840
- *
27841
- * Componente para crear artículos, blogs y documentación de forma declarativa.
27842
- * Permite combinar múltiples elementos (títulos, texto, imágenes, código, etc.)
27843
- * con espaciado automático y soporte multi-idioma.
27844
- *
27845
- * @example Uso básico:
28103
+ * Configuración por defecto de tipos de feedback.
28104
+ */
28105
+ const DEFAULT_FEEDBACK_TYPE_OPTIONS = [
28106
+ {
28107
+ value: 'issue',
28108
+ label: 'Reportar problema',
28109
+ description: 'Algo no funciona correctamente',
28110
+ icon: 'bug-outline',
28111
+ },
28112
+ {
28113
+ value: 'poor-content',
28114
+ label: 'Contenido incorrecto',
28115
+ description: 'Información incorrecta o desactualizada',
28116
+ icon: 'document-text-outline',
28117
+ },
28118
+ {
28119
+ value: 'feedback',
28120
+ label: 'Comentario general',
28121
+ description: 'Tu opinión o experiencia',
28122
+ icon: 'chatbubble-outline',
28123
+ },
28124
+ {
28125
+ value: 'suggestion',
28126
+ label: 'Sugerencia',
28127
+ description: 'Propuesta de mejora o nueva funcionalidad',
28128
+ icon: 'bulb-outline',
28129
+ },
28130
+ ];
28131
+
28132
+ /**
28133
+ * Valtech Feedback Service
28134
+ *
28135
+ * Servicio para gestionar feedback de usuarios a nivel de plataforma.
28136
+ *
28137
+ * @example
28138
+ * ```typescript
28139
+ * // main.ts - Configuración
28140
+ * import { provideValtechFeedback } from 'valtech-components';
28141
+ *
28142
+ * bootstrapApplication(AppComponent, {
28143
+ * providers: [
28144
+ * provideValtechAuth({ apiUrl: environment.apiUrl }),
28145
+ * provideValtechFeedback({
28146
+ * apiUrl: environment.apiUrl,
28147
+ * appId: 'my-app-name',
28148
+ * }),
28149
+ * ],
28150
+ * });
28151
+ *
28152
+ * // component.ts - Uso
28153
+ * import { FeedbackService } from 'valtech-components';
28154
+ *
28155
+ * @Component({...})
28156
+ * export class MyComponent {
28157
+ * private feedbackService = inject(FeedbackService);
28158
+ *
28159
+ * async submitFeedback() {
28160
+ * const response = await this.feedbackService.createAsync(
28161
+ * 'feedback',
28162
+ * 'Título',
28163
+ * 'Descripción...'
28164
+ * );
28165
+ * }
28166
+ * }
28167
+ * ```
28168
+ */
28169
+ // Configuration
28170
+
28171
+ class AttachmentUploaderComponent {
28172
+ get readyUrls() {
28173
+ return this.attachments()
28174
+ .filter(a => a.status === 'ready')
28175
+ .map(a => a.url);
28176
+ }
28177
+ constructor() {
28178
+ this.props = input({});
28179
+ this.attachmentsChange = output();
28180
+ this.i18n = inject(I18nService);
28181
+ this.feedbackService = inject(FeedbackService);
28182
+ this.attachments = signal([]);
28183
+ this.maxFiles = computed(() => this.props().maxFiles ?? 5);
28184
+ this.accept = computed(() => this.props().accept ?? 'image/jpeg,image/png,image/webp,application/pdf');
28185
+ this.maxReached = computed(() => this.attachments().length >= this.maxFiles());
28186
+ this.isDisabled = computed(() => this.props().disabled === true || this.maxReached());
28187
+ this.isUploading = computed(() => this.attachments().some(a => a.status === 'uploading'));
28188
+ addIcons({
28189
+ attachOutline,
28190
+ cameraOutline,
28191
+ checkmarkCircleOutline,
28192
+ closeCircleOutline,
28193
+ documentOutline,
28194
+ imageOutline,
28195
+ trashOutline,
28196
+ });
28197
+ }
28198
+ async onFilesSelected(event) {
28199
+ const input = event.target;
28200
+ const files = Array.from(input.files ?? []);
28201
+ input.value = '';
28202
+ const available = this.maxFiles() - this.attachments().length;
28203
+ const toProcess = files.slice(0, available);
28204
+ for (const file of toProcess) {
28205
+ const id = crypto.randomUUID();
28206
+ this.attachments.update(list => [...list, { id, file, status: 'uploading' }]);
28207
+ this.attachmentsChange.emit(this.attachments());
28208
+ this.uploadFile(id, file);
28209
+ }
28210
+ }
28211
+ async uploadFile(id, file) {
28212
+ try {
28213
+ const url = await this.feedbackService.uploadAttachment(file);
28214
+ this.attachments.update(list => list.map(a => (a.id === id ? { ...a, status: 'ready', url } : a)));
28215
+ }
28216
+ catch {
28217
+ const error = this.i18n.t('attachUploadFailed');
28218
+ this.attachments.update(list => list.map(a => (a.id === id ? { ...a, status: 'error', error } : a)));
28219
+ }
28220
+ this.attachmentsChange.emit(this.attachments());
28221
+ }
28222
+ remove(id) {
28223
+ this.attachments.update(list => list.filter(a => a.id !== id));
28224
+ this.attachmentsChange.emit(this.attachments());
28225
+ }
28226
+ formatSize(bytes) {
28227
+ if (bytes < 1024)
28228
+ return `${bytes} B`;
28229
+ if (bytes < 1024 * 1024)
28230
+ return `${Math.round(bytes / 1024)} KB`;
28231
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
28232
+ }
28233
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AttachmentUploaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
28234
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: AttachmentUploaderComponent, isStandalone: true, selector: "val-attachment-uploader", inputs: { props: { classPropertyName: "props", publicName: "props", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { attachmentsChange: "attachmentsChange" }, ngImport: i0, template: "<div class=\"attachment-uploader\">\n <input #filePicker type=\"file\" [accept]=\"accept()\" multiple class=\"hidden-input\" (change)=\"onFilesSelected($event)\" />\n <input\n #cameraPicker\n type=\"file\"\n accept=\"image/*\"\n capture=\"environment\"\n class=\"hidden-input\"\n (change)=\"onFilesSelected($event)\"\n />\n\n <div class=\"attachment-actions\">\n <ion-button fill=\"outline\" size=\"small\" [disabled]=\"isDisabled()\" (click)=\"filePicker.click()\">\n <ion-icon slot=\"start\" name=\"attach-outline\"></ion-icon>\n {{ i18n.t('attachAdd') }}\n </ion-button>\n <ion-button fill=\"outline\" size=\"small\" [disabled]=\"isDisabled()\" (click)=\"cameraPicker.click()\">\n <ion-icon slot=\"start\" name=\"camera-outline\"></ion-icon>\n {{ i18n.t('attachCamera') }}\n </ion-button>\n </div>\n\n @if (maxReached()) {\n <ion-note color=\"warning\" class=\"max-note\">\n {{ i18n.t('attachMaxCount', '_global', { count: maxFiles().toString() }) }}\n </ion-note>\n } @if (attachments().length > 0) {\n <div class=\"attachment-list\">\n @for (item of attachments(); track item.id) {\n <div class=\"attachment-item\" [class]=\"'status-' + item.status\">\n <ion-icon\n class=\"file-icon\"\n [name]=\"item.file.type.startsWith('image/') ? 'image-outline' : 'document-outline'\"\n ></ion-icon>\n <div class=\"file-info\">\n <span class=\"file-name\">{{ item.file.name }}</span>\n <span class=\"file-size\">{{ formatSize(item.file.size) }}</span>\n </div>\n\n @if (item.status === 'uploading') {\n <ion-spinner class=\"status-icon\" name=\"circular\"></ion-spinner>\n } @else if (item.status === 'ready') {\n <ion-icon class=\"status-icon\" name=\"checkmark-circle-outline\" color=\"success\"></ion-icon>\n } @else if (item.status === 'error') {\n <ion-icon class=\"status-icon\" name=\"close-circle-outline\" color=\"danger\"></ion-icon>\n } @if (item.status !== 'uploading') {\n <ion-button fill=\"clear\" size=\"small\" color=\"medium\" (click)=\"remove(item.id)\">\n <ion-icon slot=\"icon-only\" name=\"trash-outline\"></ion-icon>\n </ion-button>\n } @if (item.status === 'error' && item.error) {\n <ion-note color=\"danger\" class=\"error-note\">{{ item.error }}</ion-note>\n }\n </div>\n }\n </div>\n }\n</div>\n", styles: [".hidden-input{display:none}.attachment-uploader{display:flex;flex-direction:column;gap:8px}.attachment-actions{display:flex;gap:8px;flex-wrap:wrap}.attachment-list{display:flex;flex-direction:column;gap:4px}.attachment-item{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;background:var(--ion-color-light);flex-wrap:wrap}.attachment-item.status-error{background:var(--ion-color-danger-tint)}.attachment-item.status-ready{background:var(--ion-color-success-tint)}.file-icon{font-size:20px;flex-shrink:0}.file-info{flex:1;min-width:0;display:flex;flex-direction:column}.file-name{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file-size{font-size:12px;color:var(--ion-color-medium)}.status-icon{font-size:20px;flex-shrink:0}.error-note{font-size:11px;width:100%}.max-note{font-size:12px}\n"], dependencies: [{ kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }] }); }
28235
+ }
28236
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AttachmentUploaderComponent, decorators: [{
28237
+ type: Component,
28238
+ args: [{ selector: 'val-attachment-uploader', standalone: true, imports: [IonButton, IonIcon, IonNote, IonSpinner], template: "<div class=\"attachment-uploader\">\n <input #filePicker type=\"file\" [accept]=\"accept()\" multiple class=\"hidden-input\" (change)=\"onFilesSelected($event)\" />\n <input\n #cameraPicker\n type=\"file\"\n accept=\"image/*\"\n capture=\"environment\"\n class=\"hidden-input\"\n (change)=\"onFilesSelected($event)\"\n />\n\n <div class=\"attachment-actions\">\n <ion-button fill=\"outline\" size=\"small\" [disabled]=\"isDisabled()\" (click)=\"filePicker.click()\">\n <ion-icon slot=\"start\" name=\"attach-outline\"></ion-icon>\n {{ i18n.t('attachAdd') }}\n </ion-button>\n <ion-button fill=\"outline\" size=\"small\" [disabled]=\"isDisabled()\" (click)=\"cameraPicker.click()\">\n <ion-icon slot=\"start\" name=\"camera-outline\"></ion-icon>\n {{ i18n.t('attachCamera') }}\n </ion-button>\n </div>\n\n @if (maxReached()) {\n <ion-note color=\"warning\" class=\"max-note\">\n {{ i18n.t('attachMaxCount', '_global', { count: maxFiles().toString() }) }}\n </ion-note>\n } @if (attachments().length > 0) {\n <div class=\"attachment-list\">\n @for (item of attachments(); track item.id) {\n <div class=\"attachment-item\" [class]=\"'status-' + item.status\">\n <ion-icon\n class=\"file-icon\"\n [name]=\"item.file.type.startsWith('image/') ? 'image-outline' : 'document-outline'\"\n ></ion-icon>\n <div class=\"file-info\">\n <span class=\"file-name\">{{ item.file.name }}</span>\n <span class=\"file-size\">{{ formatSize(item.file.size) }}</span>\n </div>\n\n @if (item.status === 'uploading') {\n <ion-spinner class=\"status-icon\" name=\"circular\"></ion-spinner>\n } @else if (item.status === 'ready') {\n <ion-icon class=\"status-icon\" name=\"checkmark-circle-outline\" color=\"success\"></ion-icon>\n } @else if (item.status === 'error') {\n <ion-icon class=\"status-icon\" name=\"close-circle-outline\" color=\"danger\"></ion-icon>\n } @if (item.status !== 'uploading') {\n <ion-button fill=\"clear\" size=\"small\" color=\"medium\" (click)=\"remove(item.id)\">\n <ion-icon slot=\"icon-only\" name=\"trash-outline\"></ion-icon>\n </ion-button>\n } @if (item.status === 'error' && item.error) {\n <ion-note color=\"danger\" class=\"error-note\">{{ item.error }}</ion-note>\n }\n </div>\n }\n </div>\n }\n</div>\n", styles: [".hidden-input{display:none}.attachment-uploader{display:flex;flex-direction:column;gap:8px}.attachment-actions{display:flex;gap:8px;flex-wrap:wrap}.attachment-list{display:flex;flex-direction:column;gap:4px}.attachment-item{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;background:var(--ion-color-light);flex-wrap:wrap}.attachment-item.status-error{background:var(--ion-color-danger-tint)}.attachment-item.status-ready{background:var(--ion-color-success-tint)}.file-icon{font-size:20px;flex-shrink:0}.file-info{flex:1;min-width:0;display:flex;flex-direction:column}.file-name{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file-size{font-size:12px;color:var(--ion-color-medium)}.status-icon{font-size:20px;flex-shrink:0}.error-note{font-size:11px;width:100%}.max-note{font-size:12px}\n"] }]
28239
+ }], ctorParameters: () => [] });
28240
+
28241
+ /**
28242
+ * Configuración de espaciado predefinida
28243
+ */
28244
+ const ARTICLE_SPACING = {
28245
+ NONE: { top: 'none', bottom: 'none' },
28246
+ SMALL: { top: 'small', bottom: 'small' },
28247
+ MEDIUM: { top: 'medium', bottom: 'medium' },
28248
+ LARGE: { top: 'large', bottom: 'large' },
28249
+ XLARGE: { top: 'xlarge', bottom: 'xlarge' },
28250
+ // Espaciados específicos para elementos
28251
+ TITLE: { top: 'large', bottom: 'medium' },
28252
+ SUBTITLE: { top: 'medium', bottom: 'small' },
28253
+ PARAGRAPH: { top: 'small', bottom: 'medium' },
28254
+ QUOTE: { top: 'medium', bottom: 'medium' },
28255
+ CODE: { top: 'medium', bottom: 'medium' },
28256
+ NOTE: { top: 'medium', bottom: 'medium' },
28257
+ COMMAND: { top: 'medium', bottom: 'medium' },
28258
+ LIST: { top: 'small', bottom: 'medium' },
28259
+ BUTTON: { top: 'medium', bottom: 'medium' },
28260
+ IMAGE: { top: 'large', bottom: 'large' },
28261
+ SEPARATOR: { top: 'large', bottom: 'large' },
28262
+ };
28263
+ /**
28264
+ * Función helper para crear elementos de artículo de forma fácil
28265
+ */
28266
+ class ArticleBuilder {
28267
+ constructor() {
28268
+ this.elements = [];
28269
+ }
28270
+ /**
28271
+ * Añade un título al artículo
28272
+ */
28273
+ title(props, options) {
28274
+ this.elements.push({
28275
+ type: 'title',
28276
+ props,
28277
+ ...options,
28278
+ });
28279
+ return this;
28280
+ }
28281
+ /**
28282
+ * Añade un subtítulo al artículo
28283
+ */
28284
+ subtitle(props, options) {
28285
+ this.elements.push({
28286
+ type: 'subtitle',
28287
+ props,
28288
+ ...options,
28289
+ });
28290
+ return this;
28291
+ }
28292
+ /**
28293
+ * Añade un párrafo al artículo
28294
+ */
28295
+ paragraph(props, options) {
28296
+ this.elements.push({
28297
+ type: 'paragraph',
28298
+ props,
28299
+ ...options,
28300
+ });
28301
+ return this;
28302
+ }
28303
+ /**
28304
+ * Añade una cita al artículo
28305
+ */
28306
+ quote(props, options) {
28307
+ this.elements.push({
28308
+ type: 'quote',
28309
+ props,
28310
+ ...options,
28311
+ });
28312
+ return this;
28313
+ }
28314
+ /**
28315
+ * Añade código al artículo
28316
+ */
28317
+ code(code, language, options) {
28318
+ this.elements.push({
28319
+ type: 'code',
28320
+ props: { code, language },
28321
+ ...options,
28322
+ });
28323
+ return this;
28324
+ }
28325
+ /**
28326
+ * Añade una lista al artículo
28327
+ */
28328
+ list(items, listType, options) {
28329
+ this.elements.push({
28330
+ type: 'list',
28331
+ props: { items, listType },
28332
+ ...options,
28333
+ });
28334
+ return this;
28335
+ }
28336
+ /**
28337
+ * Añade un botón al artículo
28338
+ */
28339
+ button(props, alignment, options) {
28340
+ this.elements.push({
28341
+ type: 'button',
28342
+ props: { ...props, alignment },
28343
+ ...options,
28344
+ });
28345
+ return this;
28346
+ }
28347
+ /**
28348
+ * Añade un separador al artículo
28349
+ */
28350
+ separator(style, options) {
28351
+ this.elements.push({
28352
+ type: 'separator',
28353
+ props: { style },
28354
+ ...options,
28355
+ });
28356
+ return this;
28357
+ }
28358
+ /**
28359
+ * Añade una imagen al artículo
28360
+ */
28361
+ image(src, alt, caption, options) {
28362
+ this.elements.push({
28363
+ type: 'image',
28364
+ props: { src, alt, caption },
28365
+ ...options,
28366
+ });
28367
+ return this;
28368
+ }
28369
+ /**
28370
+ * Añade una nota destacada al artículo
28371
+ */
28372
+ note(text, prefix, color, options) {
28373
+ this.elements.push({
28374
+ type: 'note',
28375
+ props: {
28376
+ text,
28377
+ prefix: prefix || 'Nota:',
28378
+ color: color || 'warning',
28379
+ textColor: 'dark',
28380
+ size: 'medium',
28381
+ rounded: true,
28382
+ },
28383
+ ...options,
28384
+ });
28385
+ return this;
28386
+ }
28387
+ /**
28388
+ * Añade un comando de terminal al artículo
28389
+ * Acepta un string simple o un array de comandos
28390
+ */
28391
+ command(command, options) {
28392
+ const commands = Array.isArray(command) ? command : [command];
28393
+ this.elements.push({
28394
+ type: 'command',
28395
+ props: {
28396
+ lines: commands.map(cmd => ({ text: cmd, type: 'command' })),
28397
+ showCopyButton: true,
28398
+ },
28399
+ ...options,
28400
+ });
28401
+ return this;
28402
+ }
28403
+ /**
28404
+ * Construye el artículo final
28405
+ */
28406
+ build(config) {
28407
+ return {
28408
+ elements: this.elements,
28409
+ maxWidth: 'auto',
28410
+ centered: true,
28411
+ theme: 'auto',
28412
+ ...config,
28413
+ };
28414
+ }
28415
+ /**
28416
+ * Resetea el builder para crear un nuevo artículo
28417
+ */
28418
+ clear() {
28419
+ this.elements = [];
28420
+ return this;
28421
+ }
28422
+ }
28423
+
28424
+ /**
28425
+ * val-article
28426
+ *
28427
+ * Componente para crear artículos, blogs y documentación de forma declarativa.
28428
+ * Permite combinar múltiples elementos (títulos, texto, imágenes, código, etc.)
28429
+ * con espaciado automático y soporte multi-idioma.
28430
+ *
28431
+ * @example Uso básico:
27846
28432
  * ```html
27847
28433
  * <val-article [props]="articleConfig"></val-article>
27848
28434
  * ```
@@ -30857,7 +31443,7 @@ class ToolbarComponent {
30857
31443
  [props]="{
30858
31444
  user: action.user,
30859
31445
  avatarUrl: action.description,
30860
- size: 'small',
31446
+ size: action.avatarSize ?? 'small',
30861
31447
  }"
30862
31448
  (onClick)="clickHandler(action.token)"
30863
31449
  ></val-user-avatar>
@@ -30890,7 +31476,7 @@ class ToolbarComponent {
30890
31476
  [props]="{
30891
31477
  user: action.user,
30892
31478
  avatarUrl: action.description,
30893
- size: 'small',
31479
+ size: action.avatarSize ?? 'small',
30894
31480
  }"
30895
31481
  (onClick)="clickHandler(action.token)"
30896
31482
  ></val-user-avatar>
@@ -30947,7 +31533,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
30947
31533
  [props]="{
30948
31534
  user: action.user,
30949
31535
  avatarUrl: action.description,
30950
- size: 'small',
31536
+ size: action.avatarSize ?? 'small',
30951
31537
  }"
30952
31538
  (onClick)="clickHandler(action.token)"
30953
31539
  ></val-user-avatar>
@@ -30980,7 +31566,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
30980
31566
  [props]="{
30981
31567
  user: action.user,
30982
31568
  avatarUrl: action.description,
30983
- size: 'small',
31569
+ size: action.avatarSize ?? 'small',
30984
31570
  }"
30985
31571
  (onClick)="clickHandler(action.token)"
30986
31572
  ></val-user-avatar>
@@ -31310,6 +31896,7 @@ class MfaModalComponent {
31310
31896
  /** Marca momentánea cuando el secreto TOTP se acaba de copiar (feedback visual). */
31311
31897
  this.copiedSecret = signal(false);
31312
31898
  this.resendCooldown = signal(0);
31899
+ this.disableCodeSent = signal(false);
31313
31900
  this.pinControl = new FormControl('', [Validators.required, Validators.minLength(6), Validators.maxLength(6)]);
31314
31901
  this.phoneControl = new FormControl('', [Validators.required, Validators.pattern(/^\+[1-9]\d{6,14}$/)]);
31315
31902
  this.pinInputProps = {
@@ -31425,6 +32012,7 @@ class MfaModalComponent {
31425
32012
  }
31426
32013
  backToStatus() {
31427
32014
  this.stopCooldown();
32015
+ this.disableCodeSent.set(false);
31428
32016
  this.resolveStatus();
31429
32017
  }
31430
32018
  // ===========================================================================
@@ -31579,6 +32167,20 @@ class MfaModalComponent {
31579
32167
  }
31580
32168
  this.disable({ mfaCode: code });
31581
32169
  }
32170
+ sendDisableCode() {
32171
+ this.working.set(true);
32172
+ this.auth.sendMFADisableCode().subscribe({
32173
+ next: () => {
32174
+ this.working.set(false);
32175
+ this.disableCodeSent.set(true);
32176
+ this.showToast(this.t('mfaDisableCodeSent'));
32177
+ },
32178
+ error: err => {
32179
+ this.working.set(false);
32180
+ this.showToast(this.resolveError(err));
32181
+ },
32182
+ });
32183
+ }
31582
32184
  disable(input) {
31583
32185
  this.working.set(true);
31584
32186
  this.auth.disableMFA(input).subscribe({
@@ -31690,7 +32292,7 @@ class MfaModalComponent {
31690
32292
  this.toast.show({ message, duration: 3500 });
31691
32293
  }
31692
32294
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MfaModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
31693
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: MfaModalComponent, isStandalone: true, selector: "val-mfa-modal", inputs: { isOpen: "isOpen", prefillCode: "prefillCode" }, outputs: { changed: "changed", enabledViaDeeplink: "enabledViaDeeplink", dismissed: "dismissed" }, ngImport: i0, template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n\n <ion-content class=\"ion-padding\">\n <section class=\"mfa-modal\">\n <val-display [props]=\"{ content: t('mfaManageTitle'), size: 'small', color: 'dark' }\" />\n\n @switch (step()) { @case ('loading') {\n <div class=\"mfa-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @case ('status') { @if (mfaEnabled()) {\n <p class=\"mfa-status mfa-status--on\">{{ t('mfaEnabledLabel') }} \u00B7 {{ methodLabel(mfaMethod()) }}</p>\n\n @if (mfaMethod() === 'TOTP') {\n <div class=\"mfa-block\">\n <h3>{{ t('mfaBackupCodesTitle') }}</h3>\n <p class=\"mfa-text\">{{ t('mfaBackupCodesAvailable') }}: {{ backupCodesCount() }}</p>\n\n @if (regeneratedCodes(); as codes) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of codes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(codes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n } @else { @if (backupCodesCount() < 3) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesLow') }}</p>\n </div>\n }\n <ion-button\n expand=\"block\"\n color=\"dark\"\n fill=\"outline\"\n [disabled]=\"working()\"\n (click)=\"regenerateBackupCodes()\"\n >\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaRegenerateCodes') }} }\n </ion-button>\n }\n </div>\n }\n\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"goToDisable()\">\n {{ t('mfaDisableButton') }}\n </ion-button>\n } @else {\n <p class=\"mfa-status mfa-status--off\">{{ t('mfaDisabledLabel') }}</p>\n <p class=\"mfa-text\">{{ t('mfaDisabledHint') }}</p>\n <ion-button expand=\"block\" (click)=\"goToMethodSelect()\"> {{ t('mfaEnableButton') }} </ion-button>\n } } @case ('method-select') {\n <val-title [props]=\"{ content: t('mfaEnableTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaMethodPrompt') }}</p>\n\n <ion-radio-group [value]=\"selectedMethod()\" (ionChange)=\"selectedMethod.set($event.detail.value)\">\n <ion-item>\n <ion-radio slot=\"start\" value=\"TOTP\"></ion-radio>\n <ion-label>\n <strong>{{ t('mfaMethodTotp') }}</strong>\n <p>{{ t('mfaMethodTotpHint') }}</p>\n </ion-label>\n </ion-item>\n <ion-item>\n <ion-radio slot=\"start\" value=\"EMAIL\"></ion-radio>\n <ion-label>\n <strong>{{ t('mfaMethodEmail') }}</strong>\n <p>{{ t('mfaMethodEmailHint') }}</p>\n </ion-label>\n </ion-item>\n <ion-item>\n <ion-radio slot=\"start\" value=\"SMS\"></ion-radio>\n <ion-label>\n <strong>{{ t('mfaMethodSms') }}</strong>\n <p>{{ t('mfaMethodSmsHint') }}</p>\n </ion-label>\n </ion-item>\n </ion-radio-group>\n\n @if (selectedMethod() === 'SMS' && !userPhone()) {\n <ion-input\n label=\"{{ t('mfaPhoneLabel') }}\"\n labelPlacement=\"floating\"\n fill=\"outline\"\n type=\"tel\"\n placeholder=\"+56912345678\"\n [formControl]=\"phoneControl\"\n ></ion-input>\n @if (phoneControl.invalid && phoneControl.touched) {\n <p class=\"mfa-error\">{{ t('mfaPhoneInvalid') }}</p>\n } } @if (selectedMethod() === 'SMS' && userPhone(); as phone) {\n <p class=\"mfa-text\">{{ t('mfaPhoneRegistered') }}: {{ phone }}</p>\n }\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"proceedWithMethod()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaContinue') }} }\n </ion-button>\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('totp-setup') {\n <val-title [props]=\"{ content: t('mfaTotpSetupTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaTotpStep1') }}</p>\n\n @if (totpQr(); as qr) {\n <div class=\"mfa-qr\">\n <val-qr-code [props]=\"{ qr: qr }\" />\n </div>\n } @if (totpSetup(); as setup) {\n <p class=\"mfa-text\">{{ t('mfaTotpManualEntry') }}</p>\n <div class=\"mfa-secret-row\">\n <code class=\"mfa-secret\">{{ setup.secret }}</code>\n <ion-button fill=\"clear\" size=\"small\" (click)=\"copySecret(setup.secret)\" [attr.aria-label]=\"t('copy')\">\n @if (copiedSecret()) {\n <ion-icon name=\"checkmark-outline\"></ion-icon>\n } @else {\n <ion-icon name=\"copy-outline\"></ion-icon>\n }\n </ion-button>\n </div>\n\n <p class=\"mfa-text\">{{ t('mfaTotpStep2') }}</p>\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"verifyTotp()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaTotpVerify') }} }\n </ion-button>\n\n <div class=\"mfa-backup\">\n <h3>{{ t('mfaBackupCodesTitle') }}</h3>\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of setup.backupCodes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(setup.backupCodes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n </div>\n }\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('code-confirm') {\n <val-title [props]=\"{ content: t('mfaConfirmTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">\n {{ selectedMethod() === 'EMAIL' ? t('mfaConfirmPromptEmail') : t('mfaConfirmPromptSms') }}\n </p>\n\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"confirmCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaConfirmButton') }} }\n </ion-button>\n\n <p class=\"mfa-resend\">\n {{ t('mfaNoCode') }} @if (resendCooldown() > 0) {\n <span class=\"mfa-text\">{{ t('mfaResendIn') }} {{ resendCooldown() }}s</span>\n } @else {\n <a (click)=\"resendCode()\">{{ t('mfaResend') }}</a>\n }\n </p>\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('disable') { @if (hasPassword()) {\n <val-form [props]=\"disableFormProps()\" (onSubmit)=\"onDisableSubmit($event)\" />\n } @else if (mfaMethod() === 'TOTP') {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaDisableTotpPrompt') }}</p>\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"disableWithMfaCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableButton') }} }\n </ion-button>\n } @else {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaDisableNeedsPassword') }}</p>\n }\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".mfa-modal{display:flex;flex-direction:column;gap:14px;padding-top:4px}.mfa-loading{display:flex;justify-content:center;padding:40px 0}.mfa-status{display:inline-flex;align-items:center;width:fit-content;padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin:0}.mfa-status--on{color:var(--ion-color-success-shade);background:rgba(var(--ion-color-success-rgb),.12)}.mfa-status--off{color:var(--ion-color-medium-shade);background:var(--ion-color-light)}.mfa-secret-row{display:flex;align-items:center;gap:8px}.mfa-secret-row .mfa-secret{flex:1;margin:0}.mfa-secret-row ion-button{flex-shrink:0;--color: var(--ion-color-dark);margin:0}.mfa-text{color:var(--ion-color-dark);font-size:14px;margin:0}.mfa-error{color:var(--ion-color-danger);font-size:13px;margin:4px 0 0}.mfa-block,.mfa-backup{display:flex;flex-direction:column;gap:12px}.mfa-block{padding:16px;border-radius:14px;background:var(--ion-color-light)}.mfa-block h3,.mfa-backup h3{font-size:15px;font-weight:600;margin:0;color:var(--ion-color-dark)}.mfa-alert{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:10px;background:var(--ion-color-light);border-left:3px solid var(--ion-color-primary)}.mfa-alert ion-icon{font-size:20px;color:var(--ion-color-primary);flex-shrink:0;margin-top:1px}.mfa-alert p{margin:0;font-size:13px;line-height:1.45;color:var(--ion-color-dark)}.mfa-qr{display:flex;justify-content:center;padding:8px 0}.mfa-secret{display:block;padding:10px 12px;border-radius:8px;background:var(--ion-color-light);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:15px;letter-spacing:.04em;word-break:break-all;text-align:center}.mfa-pin{display:flex;justify-content:center;padding:8px 0}.mfa-codes{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;padding:12px;border-radius:12px;background:var(--ion-color-light)}.mfa-code{padding:8px;border-radius:6px;background:var(--ion-background-color, #fff);color:var(--ion-color-dark);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;text-align:center}.mfa-resend{text-align:center;font-size:14px;color:var(--ion-color-dark);margin:4px 0}.mfa-resend a{color:var(--ion-color-primary);cursor:pointer;font-weight:600}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonInput, selector: "ion-input", inputs: ["accept", "autocapitalize", "autocomplete", "autocorrect", "autofocus", "clearInput", "clearOnEdit", "color", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "max", "maxlength", "min", "minlength", "mode", "multiple", "name", "pattern", "placeholder", "readonly", "required", "shape", "size", "spellcheck", "step", "type", "value"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonLabel, selector: "ion-label", inputs: ["color", "mode", "position"] }, { kind: "component", type: IonModal, selector: "ion-modal" }, { kind: "component", type: IonRadio, selector: "ion-radio", inputs: ["alignment", "color", "disabled", "justify", "labelPlacement", "mode", "name", "value"] }, { kind: "component", type: IonRadioGroup, selector: "ion-radio-group", inputs: ["allowEmptySelection", "compareWith", "errorText", "helperText", "name", "value"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }, { kind: "component", type: QrCodeComponent, selector: "val-qr-code", inputs: ["props"], outputs: ["actionComplete", "imageLoad", "imageError"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: PinInputComponent, selector: "val-pin-input", inputs: ["props"] }] }); }
32295
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: MfaModalComponent, isStandalone: true, selector: "val-mfa-modal", inputs: { isOpen: "isOpen", prefillCode: "prefillCode" }, outputs: { changed: "changed", enabledViaDeeplink: "enabledViaDeeplink", dismissed: "dismissed" }, ngImport: i0, template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n\n <ion-content class=\"ion-padding\">\n <section class=\"mfa-modal\">\n <val-display [props]=\"{ content: t('mfaManageTitle'), size: 'small', color: 'dark' }\" />\n\n @switch (step()) { @case ('loading') {\n <div class=\"mfa-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @case ('status') { @if (mfaEnabled()) {\n <p class=\"mfa-status mfa-status--on\">{{ t('mfaEnabledLabel') }} \u00B7 {{ methodLabel(mfaMethod()) }}</p>\n\n @if (mfaMethod() === 'TOTP') {\n <div class=\"mfa-block\">\n <val-title [props]=\"{ size: 'small', color: 'dark', bold: true, content: t('mfaBackupCodesTitle') }\" />\n <val-text\n [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaBackupCodesAvailable') + ': ' + backupCodesCount() }\"\n />\n\n @if (regeneratedCodes(); as codes) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of codes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(codes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n } @else { @if (backupCodesCount() < 3) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesLow') }}</p>\n </div>\n }\n <ion-button\n expand=\"block\"\n color=\"dark\"\n fill=\"outline\"\n [disabled]=\"working()\"\n (click)=\"regenerateBackupCodes()\"\n >\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaRegenerateCodes') }} }\n </ion-button>\n }\n </div>\n }\n\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"goToDisable()\">\n {{ t('mfaDisableButton') }}\n </ion-button>\n } @else {\n <p class=\"mfa-status mfa-status--off\">{{ t('mfaDisabledLabel') }}</p>\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisabledHint') }\" />\n <ion-button expand=\"block\" (click)=\"goToMethodSelect()\"> {{ t('mfaEnableButton') }} </ion-button>\n } } @case ('method-select') {\n <val-title [props]=\"{ content: t('mfaEnableTitle'), size: 'large', color: 'dark', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaMethodPrompt') }\" />\n\n <div class=\"mfa-method-list\" role=\"radiogroup\">\n <button\n type=\"button\"\n class=\"mfa-method-card\"\n [class.mfa-method-card--active]=\"selectedMethod() === 'TOTP'\"\n (click)=\"selectedMethod.set('TOTP')\"\n role=\"radio\"\n [attr.aria-checked]=\"selectedMethod() === 'TOTP'\"\n >\n <span class=\"mfa-method-card__dot\"></span>\n <span class=\"mfa-method-card__body\">\n <strong>{{ t('mfaMethodTotp') }}</strong>\n <span>{{ t('mfaMethodTotpHint') }}</span>\n </span>\n </button>\n <button\n type=\"button\"\n class=\"mfa-method-card\"\n [class.mfa-method-card--active]=\"selectedMethod() === 'EMAIL'\"\n (click)=\"selectedMethod.set('EMAIL')\"\n role=\"radio\"\n [attr.aria-checked]=\"selectedMethod() === 'EMAIL'\"\n >\n <span class=\"mfa-method-card__dot\"></span>\n <span class=\"mfa-method-card__body\">\n <strong>{{ t('mfaMethodEmail') }}</strong>\n <span>{{ t('mfaMethodEmailHint') }}</span>\n </span>\n </button>\n <button\n type=\"button\"\n class=\"mfa-method-card\"\n [class.mfa-method-card--active]=\"selectedMethod() === 'SMS'\"\n (click)=\"selectedMethod.set('SMS')\"\n role=\"radio\"\n [attr.aria-checked]=\"selectedMethod() === 'SMS'\"\n >\n <span class=\"mfa-method-card__dot\"></span>\n <span class=\"mfa-method-card__body\">\n <strong>{{ t('mfaMethodSms') }}</strong>\n <span>{{ t('mfaMethodSmsHint') }}</span>\n </span>\n </button>\n </div>\n\n @if (selectedMethod() === 'SMS' && !userPhone()) {\n <ion-input\n label=\"{{ t('mfaPhoneLabel') }}\"\n labelPlacement=\"floating\"\n fill=\"outline\"\n type=\"tel\"\n placeholder=\"+56912345678\"\n [formControl]=\"phoneControl\"\n ></ion-input>\n @if (phoneControl.invalid && phoneControl.touched) {\n <p class=\"mfa-error\">{{ t('mfaPhoneInvalid') }}</p>\n } } @if (selectedMethod() === 'SMS' && userPhone(); as phone) {\n <val-text\n [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaPhoneRegistered') + ': ' + phone }\"\n />\n }\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"proceedWithMethod()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaContinue') }} }\n </ion-button>\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('totp-setup') {\n <val-title [props]=\"{ content: t('mfaTotpSetupTitle'), size: 'large', color: '', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaTotpStep1') }\" />\n\n @if (totpQr(); as qr) {\n <div class=\"mfa-qr\">\n <val-qr-code [props]=\"{ qr: qr }\" />\n </div>\n } @if (totpSetup(); as setup) {\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaTotpManualEntry') }\" />\n <div class=\"mfa-secret-row\">\n <code class=\"mfa-secret\">{{ setup.secret }}</code>\n <ion-button fill=\"clear\" size=\"small\" (click)=\"copySecret(setup.secret)\" [attr.aria-label]=\"t('copy')\">\n @if (copiedSecret()) {\n <ion-icon name=\"checkmark-outline\"></ion-icon>\n } @else {\n <ion-icon name=\"copy-outline\"></ion-icon>\n }\n </ion-button>\n </div>\n\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaTotpStep2') }\" />\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"verifyTotp()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaTotpVerify') }} }\n </ion-button>\n\n <div class=\"mfa-backup\">\n <val-title [props]=\"{ size: 'small', color: 'dark', bold: true, content: t('mfaBackupCodesTitle') }\" />\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of setup.backupCodes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(setup.backupCodes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n </div>\n }\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('code-confirm') {\n <val-title [props]=\"{ content: t('mfaConfirmTitle'), size: 'large', color: '', bold: false }\" />\n <val-text\n [props]=\"{ size: 'medium', color: 'dark', bold: false, content: selectedMethod() === 'EMAIL' ? t('mfaConfirmPromptEmail') : t('mfaConfirmPromptSms') }\"\n />\n\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"confirmCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaConfirmButton') }} }\n </ion-button>\n\n <p class=\"mfa-resend\">\n {{ t('mfaNoCode') }} @if (resendCooldown() > 0) {\n <span>{{ t('mfaResendIn') }} {{ resendCooldown() }}s</span>\n } @else {\n <a (click)=\"resendCode()\">{{ t('mfaResend') }}</a>\n }\n </p>\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('disable') { @if (hasPassword()) {\n <val-form [props]=\"disableFormProps()\" (onSubmit)=\"onDisableSubmit($event)\" />\n } @else if (mfaMethod() === 'TOTP') {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableTotpPrompt') }\" />\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"disableWithMfaCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableButton') }} }\n </ion-button>\n } @else if (mfaMethod() === 'EMAIL' || mfaMethod() === 'SMS') {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n @if (!disableCodeSent()) {\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableCodePrompt') }\" />\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"sendDisableCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableSendCode') }} }\n </ion-button>\n } @else {\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableCodePrompt') }\" />\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"disableWithMfaCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableButton') }} }\n </ion-button>\n <ion-button expand=\"block\" fill=\"clear\" color=\"medium\" [disabled]=\"working()\" (click)=\"sendDisableCode()\">\n {{ t('mfaDisableResendCode') }}\n </ion-button>\n } } @else {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableNeedsPassword') }\" />\n }\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".mfa-modal{display:flex;flex-direction:column;gap:14px;padding-top:4px}.mfa-loading{display:flex;justify-content:center;padding:40px 0}.mfa-status{display:inline-flex;align-items:center;width:fit-content;padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin:0}.mfa-status--on{color:var(--ion-color-success-shade);background:rgba(var(--ion-color-success-rgb),.12)}.mfa-status--off{color:var(--ion-color-medium-shade);background:var(--ion-color-light)}.mfa-secret-row{display:flex;align-items:center;gap:8px}.mfa-secret-row .mfa-secret{flex:1;margin:0}.mfa-secret-row ion-button{flex-shrink:0;--color: var(--ion-color-dark);margin:0}.mfa-error{color:var(--ion-color-danger);font-size:13px;margin:4px 0 0}.mfa-block,.mfa-backup{display:flex;flex-direction:column;gap:12px}.mfa-block{padding:16px;border-radius:14px;background:var(--ion-color-light)}.mfa-alert{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:10px;background:var(--ion-color-light);border-left:3px solid var(--ion-color-primary)}.mfa-alert ion-icon{font-size:20px;color:var(--ion-color-primary);flex-shrink:0;margin-top:1px}.mfa-alert p{margin:0;font-size:13px;line-height:1.45;color:var(--ion-color-dark)}.mfa-qr{display:flex;justify-content:center;padding:8px 0}.mfa-secret{display:block;padding:10px 12px;border-radius:8px;background:var(--ion-color-light);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:15px;letter-spacing:.04em;word-break:break-all;text-align:center}.mfa-pin{display:flex;justify-content:center;padding:8px 0}.mfa-codes{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;padding:12px;border-radius:12px;background:var(--ion-color-light)}.mfa-code{padding:8px;border-radius:6px;background:var(--ion-background-color, #fff);color:var(--ion-color-dark);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;text-align:center}.mfa-method-list{display:flex;flex-direction:column;gap:8px}.mfa-method-card{display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-radius:12px;border:1.5px solid var(--ion-border-color, rgba(var(--ion-color-dark-rgb), .14));background:transparent;cursor:pointer;text-align:left;width:100%;transition:border-color .15s ease,background .15s ease}.mfa-method-card--active{border-color:var(--ion-color-primary);background:rgba(var(--ion-color-primary-rgb),.07)}.mfa-method-card__dot{flex-shrink:0;width:18px;height:18px;border-radius:50%;border:2px solid var(--ion-color-medium);margin-top:2px;transition:border-color .15s,box-shadow .15s}.mfa-method-card--active .mfa-method-card__dot{border-color:var(--ion-color-primary);box-shadow:inset 0 0 0 4px var(--ion-color-primary)}.mfa-method-card__body{display:flex;flex-direction:column;gap:3px}.mfa-method-card__body strong{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.mfa-method-card__body span{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.mfa-resend{text-align:center;font-size:14px;color:var(--ion-color-dark);margin:4px 0}.mfa-resend a{color:var(--ion-color-primary);cursor:pointer;font-weight:600}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonInput, selector: "ion-input", inputs: ["accept", "autocapitalize", "autocomplete", "autocorrect", "autofocus", "clearInput", "clearOnEdit", "color", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "max", "maxlength", "min", "minlength", "mode", "multiple", "name", "pattern", "placeholder", "readonly", "required", "shape", "size", "spellcheck", "step", "type", "value"] }, { kind: "component", type: IonModal, selector: "ion-modal" }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }, { kind: "component", type: QrCodeComponent, selector: "val-qr-code", inputs: ["props"], outputs: ["actionComplete", "imageLoad", "imageError"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: PinInputComponent, selector: "val-pin-input", inputs: ["props"] }] }); }
31694
32296
  }
31695
32297
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MfaModalComponent, decorators: [{
31696
32298
  type: Component,
@@ -31712,9 +32314,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
31712
32314
  DisplayComponent,
31713
32315
  FormComponent,
31714
32316
  QrCodeComponent,
32317
+ TextComponent,
31715
32318
  TitleComponent,
31716
32319
  PinInputComponent,
31717
- ], template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n\n <ion-content class=\"ion-padding\">\n <section class=\"mfa-modal\">\n <val-display [props]=\"{ content: t('mfaManageTitle'), size: 'small', color: 'dark' }\" />\n\n @switch (step()) { @case ('loading') {\n <div class=\"mfa-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @case ('status') { @if (mfaEnabled()) {\n <p class=\"mfa-status mfa-status--on\">{{ t('mfaEnabledLabel') }} \u00B7 {{ methodLabel(mfaMethod()) }}</p>\n\n @if (mfaMethod() === 'TOTP') {\n <div class=\"mfa-block\">\n <h3>{{ t('mfaBackupCodesTitle') }}</h3>\n <p class=\"mfa-text\">{{ t('mfaBackupCodesAvailable') }}: {{ backupCodesCount() }}</p>\n\n @if (regeneratedCodes(); as codes) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of codes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(codes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n } @else { @if (backupCodesCount() < 3) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesLow') }}</p>\n </div>\n }\n <ion-button\n expand=\"block\"\n color=\"dark\"\n fill=\"outline\"\n [disabled]=\"working()\"\n (click)=\"regenerateBackupCodes()\"\n >\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaRegenerateCodes') }} }\n </ion-button>\n }\n </div>\n }\n\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"goToDisable()\">\n {{ t('mfaDisableButton') }}\n </ion-button>\n } @else {\n <p class=\"mfa-status mfa-status--off\">{{ t('mfaDisabledLabel') }}</p>\n <p class=\"mfa-text\">{{ t('mfaDisabledHint') }}</p>\n <ion-button expand=\"block\" (click)=\"goToMethodSelect()\"> {{ t('mfaEnableButton') }} </ion-button>\n } } @case ('method-select') {\n <val-title [props]=\"{ content: t('mfaEnableTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaMethodPrompt') }}</p>\n\n <ion-radio-group [value]=\"selectedMethod()\" (ionChange)=\"selectedMethod.set($event.detail.value)\">\n <ion-item>\n <ion-radio slot=\"start\" value=\"TOTP\"></ion-radio>\n <ion-label>\n <strong>{{ t('mfaMethodTotp') }}</strong>\n <p>{{ t('mfaMethodTotpHint') }}</p>\n </ion-label>\n </ion-item>\n <ion-item>\n <ion-radio slot=\"start\" value=\"EMAIL\"></ion-radio>\n <ion-label>\n <strong>{{ t('mfaMethodEmail') }}</strong>\n <p>{{ t('mfaMethodEmailHint') }}</p>\n </ion-label>\n </ion-item>\n <ion-item>\n <ion-radio slot=\"start\" value=\"SMS\"></ion-radio>\n <ion-label>\n <strong>{{ t('mfaMethodSms') }}</strong>\n <p>{{ t('mfaMethodSmsHint') }}</p>\n </ion-label>\n </ion-item>\n </ion-radio-group>\n\n @if (selectedMethod() === 'SMS' && !userPhone()) {\n <ion-input\n label=\"{{ t('mfaPhoneLabel') }}\"\n labelPlacement=\"floating\"\n fill=\"outline\"\n type=\"tel\"\n placeholder=\"+56912345678\"\n [formControl]=\"phoneControl\"\n ></ion-input>\n @if (phoneControl.invalid && phoneControl.touched) {\n <p class=\"mfa-error\">{{ t('mfaPhoneInvalid') }}</p>\n } } @if (selectedMethod() === 'SMS' && userPhone(); as phone) {\n <p class=\"mfa-text\">{{ t('mfaPhoneRegistered') }}: {{ phone }}</p>\n }\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"proceedWithMethod()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaContinue') }} }\n </ion-button>\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('totp-setup') {\n <val-title [props]=\"{ content: t('mfaTotpSetupTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaTotpStep1') }}</p>\n\n @if (totpQr(); as qr) {\n <div class=\"mfa-qr\">\n <val-qr-code [props]=\"{ qr: qr }\" />\n </div>\n } @if (totpSetup(); as setup) {\n <p class=\"mfa-text\">{{ t('mfaTotpManualEntry') }}</p>\n <div class=\"mfa-secret-row\">\n <code class=\"mfa-secret\">{{ setup.secret }}</code>\n <ion-button fill=\"clear\" size=\"small\" (click)=\"copySecret(setup.secret)\" [attr.aria-label]=\"t('copy')\">\n @if (copiedSecret()) {\n <ion-icon name=\"checkmark-outline\"></ion-icon>\n } @else {\n <ion-icon name=\"copy-outline\"></ion-icon>\n }\n </ion-button>\n </div>\n\n <p class=\"mfa-text\">{{ t('mfaTotpStep2') }}</p>\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"verifyTotp()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaTotpVerify') }} }\n </ion-button>\n\n <div class=\"mfa-backup\">\n <h3>{{ t('mfaBackupCodesTitle') }}</h3>\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of setup.backupCodes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(setup.backupCodes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n </div>\n }\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('code-confirm') {\n <val-title [props]=\"{ content: t('mfaConfirmTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">\n {{ selectedMethod() === 'EMAIL' ? t('mfaConfirmPromptEmail') : t('mfaConfirmPromptSms') }}\n </p>\n\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"confirmCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaConfirmButton') }} }\n </ion-button>\n\n <p class=\"mfa-resend\">\n {{ t('mfaNoCode') }} @if (resendCooldown() > 0) {\n <span class=\"mfa-text\">{{ t('mfaResendIn') }} {{ resendCooldown() }}s</span>\n } @else {\n <a (click)=\"resendCode()\">{{ t('mfaResend') }}</a>\n }\n </p>\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('disable') { @if (hasPassword()) {\n <val-form [props]=\"disableFormProps()\" (onSubmit)=\"onDisableSubmit($event)\" />\n } @else if (mfaMethod() === 'TOTP') {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaDisableTotpPrompt') }}</p>\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"disableWithMfaCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableButton') }} }\n </ion-button>\n } @else {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <p class=\"mfa-text\">{{ t('mfaDisableNeedsPassword') }}</p>\n }\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".mfa-modal{display:flex;flex-direction:column;gap:14px;padding-top:4px}.mfa-loading{display:flex;justify-content:center;padding:40px 0}.mfa-status{display:inline-flex;align-items:center;width:fit-content;padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin:0}.mfa-status--on{color:var(--ion-color-success-shade);background:rgba(var(--ion-color-success-rgb),.12)}.mfa-status--off{color:var(--ion-color-medium-shade);background:var(--ion-color-light)}.mfa-secret-row{display:flex;align-items:center;gap:8px}.mfa-secret-row .mfa-secret{flex:1;margin:0}.mfa-secret-row ion-button{flex-shrink:0;--color: var(--ion-color-dark);margin:0}.mfa-text{color:var(--ion-color-dark);font-size:14px;margin:0}.mfa-error{color:var(--ion-color-danger);font-size:13px;margin:4px 0 0}.mfa-block,.mfa-backup{display:flex;flex-direction:column;gap:12px}.mfa-block{padding:16px;border-radius:14px;background:var(--ion-color-light)}.mfa-block h3,.mfa-backup h3{font-size:15px;font-weight:600;margin:0;color:var(--ion-color-dark)}.mfa-alert{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:10px;background:var(--ion-color-light);border-left:3px solid var(--ion-color-primary)}.mfa-alert ion-icon{font-size:20px;color:var(--ion-color-primary);flex-shrink:0;margin-top:1px}.mfa-alert p{margin:0;font-size:13px;line-height:1.45;color:var(--ion-color-dark)}.mfa-qr{display:flex;justify-content:center;padding:8px 0}.mfa-secret{display:block;padding:10px 12px;border-radius:8px;background:var(--ion-color-light);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:15px;letter-spacing:.04em;word-break:break-all;text-align:center}.mfa-pin{display:flex;justify-content:center;padding:8px 0}.mfa-codes{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;padding:12px;border-radius:12px;background:var(--ion-color-light)}.mfa-code{padding:8px;border-radius:6px;background:var(--ion-background-color, #fff);color:var(--ion-color-dark);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;text-align:center}.mfa-resend{text-align:center;font-size:14px;color:var(--ion-color-dark);margin:4px 0}.mfa-resend a{color:var(--ion-color-primary);cursor:pointer;font-weight:600}\n"] }]
32320
+ ], template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n\n <ion-content class=\"ion-padding\">\n <section class=\"mfa-modal\">\n <val-display [props]=\"{ content: t('mfaManageTitle'), size: 'small', color: 'dark' }\" />\n\n @switch (step()) { @case ('loading') {\n <div class=\"mfa-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @case ('status') { @if (mfaEnabled()) {\n <p class=\"mfa-status mfa-status--on\">{{ t('mfaEnabledLabel') }} \u00B7 {{ methodLabel(mfaMethod()) }}</p>\n\n @if (mfaMethod() === 'TOTP') {\n <div class=\"mfa-block\">\n <val-title [props]=\"{ size: 'small', color: 'dark', bold: true, content: t('mfaBackupCodesTitle') }\" />\n <val-text\n [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaBackupCodesAvailable') + ': ' + backupCodesCount() }\"\n />\n\n @if (regeneratedCodes(); as codes) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of codes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(codes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n } @else { @if (backupCodesCount() < 3) {\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesLow') }}</p>\n </div>\n }\n <ion-button\n expand=\"block\"\n color=\"dark\"\n fill=\"outline\"\n [disabled]=\"working()\"\n (click)=\"regenerateBackupCodes()\"\n >\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaRegenerateCodes') }} }\n </ion-button>\n }\n </div>\n }\n\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"goToDisable()\">\n {{ t('mfaDisableButton') }}\n </ion-button>\n } @else {\n <p class=\"mfa-status mfa-status--off\">{{ t('mfaDisabledLabel') }}</p>\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisabledHint') }\" />\n <ion-button expand=\"block\" (click)=\"goToMethodSelect()\"> {{ t('mfaEnableButton') }} </ion-button>\n } } @case ('method-select') {\n <val-title [props]=\"{ content: t('mfaEnableTitle'), size: 'large', color: 'dark', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaMethodPrompt') }\" />\n\n <div class=\"mfa-method-list\" role=\"radiogroup\">\n <button\n type=\"button\"\n class=\"mfa-method-card\"\n [class.mfa-method-card--active]=\"selectedMethod() === 'TOTP'\"\n (click)=\"selectedMethod.set('TOTP')\"\n role=\"radio\"\n [attr.aria-checked]=\"selectedMethod() === 'TOTP'\"\n >\n <span class=\"mfa-method-card__dot\"></span>\n <span class=\"mfa-method-card__body\">\n <strong>{{ t('mfaMethodTotp') }}</strong>\n <span>{{ t('mfaMethodTotpHint') }}</span>\n </span>\n </button>\n <button\n type=\"button\"\n class=\"mfa-method-card\"\n [class.mfa-method-card--active]=\"selectedMethod() === 'EMAIL'\"\n (click)=\"selectedMethod.set('EMAIL')\"\n role=\"radio\"\n [attr.aria-checked]=\"selectedMethod() === 'EMAIL'\"\n >\n <span class=\"mfa-method-card__dot\"></span>\n <span class=\"mfa-method-card__body\">\n <strong>{{ t('mfaMethodEmail') }}</strong>\n <span>{{ t('mfaMethodEmailHint') }}</span>\n </span>\n </button>\n <button\n type=\"button\"\n class=\"mfa-method-card\"\n [class.mfa-method-card--active]=\"selectedMethod() === 'SMS'\"\n (click)=\"selectedMethod.set('SMS')\"\n role=\"radio\"\n [attr.aria-checked]=\"selectedMethod() === 'SMS'\"\n >\n <span class=\"mfa-method-card__dot\"></span>\n <span class=\"mfa-method-card__body\">\n <strong>{{ t('mfaMethodSms') }}</strong>\n <span>{{ t('mfaMethodSmsHint') }}</span>\n </span>\n </button>\n </div>\n\n @if (selectedMethod() === 'SMS' && !userPhone()) {\n <ion-input\n label=\"{{ t('mfaPhoneLabel') }}\"\n labelPlacement=\"floating\"\n fill=\"outline\"\n type=\"tel\"\n placeholder=\"+56912345678\"\n [formControl]=\"phoneControl\"\n ></ion-input>\n @if (phoneControl.invalid && phoneControl.touched) {\n <p class=\"mfa-error\">{{ t('mfaPhoneInvalid') }}</p>\n } } @if (selectedMethod() === 'SMS' && userPhone(); as phone) {\n <val-text\n [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaPhoneRegistered') + ': ' + phone }\"\n />\n }\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"proceedWithMethod()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaContinue') }} }\n </ion-button>\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('totp-setup') {\n <val-title [props]=\"{ content: t('mfaTotpSetupTitle'), size: 'large', color: '', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaTotpStep1') }\" />\n\n @if (totpQr(); as qr) {\n <div class=\"mfa-qr\">\n <val-qr-code [props]=\"{ qr: qr }\" />\n </div>\n } @if (totpSetup(); as setup) {\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaTotpManualEntry') }\" />\n <div class=\"mfa-secret-row\">\n <code class=\"mfa-secret\">{{ setup.secret }}</code>\n <ion-button fill=\"clear\" size=\"small\" (click)=\"copySecret(setup.secret)\" [attr.aria-label]=\"t('copy')\">\n @if (copiedSecret()) {\n <ion-icon name=\"checkmark-outline\"></ion-icon>\n } @else {\n <ion-icon name=\"copy-outline\"></ion-icon>\n }\n </ion-button>\n </div>\n\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaTotpStep2') }\" />\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"verifyTotp()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaTotpVerify') }} }\n </ion-button>\n\n <div class=\"mfa-backup\">\n <val-title [props]=\"{ size: 'small', color: 'dark', bold: true, content: t('mfaBackupCodesTitle') }\" />\n <div class=\"mfa-alert\">\n <ion-icon name=\"information-circle-outline\"></ion-icon>\n <p>{{ t('mfaBackupCodesSaveWarning') }} {{ t('mfaBackupCodesExplain') }}</p>\n </div>\n <div class=\"mfa-codes\">\n @for (code of setup.backupCodes; track code) {\n <code class=\"mfa-code\">{{ code }}</code>\n }\n </div>\n <ion-button expand=\"block\" color=\"dark\" fill=\"outline\" (click)=\"copyCodes(setup.backupCodes)\">\n {{ t('mfaCopyCodes') }}\n </ion-button>\n </div>\n }\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('code-confirm') {\n <val-title [props]=\"{ content: t('mfaConfirmTitle'), size: 'large', color: '', bold: false }\" />\n <val-text\n [props]=\"{ size: 'medium', color: 'dark', bold: false, content: selectedMethod() === 'EMAIL' ? t('mfaConfirmPromptEmail') : t('mfaConfirmPromptSms') }\"\n />\n\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"confirmCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaConfirmButton') }} }\n </ion-button>\n\n <p class=\"mfa-resend\">\n {{ t('mfaNoCode') }} @if (resendCooldown() > 0) {\n <span>{{ t('mfaResendIn') }} {{ resendCooldown() }}s</span>\n } @else {\n <a (click)=\"resendCode()\">{{ t('mfaResend') }}</a>\n }\n </p>\n\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } @case ('disable') { @if (hasPassword()) {\n <val-form [props]=\"disableFormProps()\" (onSubmit)=\"onDisableSubmit($event)\" />\n } @else if (mfaMethod() === 'TOTP') {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableTotpPrompt') }\" />\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"disableWithMfaCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableButton') }} }\n </ion-button>\n } @else if (mfaMethod() === 'EMAIL' || mfaMethod() === 'SMS') {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n @if (!disableCodeSent()) {\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableCodePrompt') }\" />\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"sendDisableCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableSendCode') }} }\n </ion-button>\n } @else {\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableCodePrompt') }\" />\n <div class=\"mfa-pin\">\n <val-pin-input [props]=\"pinInputProps\" />\n </div>\n <ion-button expand=\"block\" [disabled]=\"working()\" (click)=\"disableWithMfaCode()\">\n @if (working()) {\n <ion-spinner name=\"crescent\"></ion-spinner>\n } @else { {{ t('mfaDisableButton') }} }\n </ion-button>\n <ion-button expand=\"block\" fill=\"clear\" color=\"medium\" [disabled]=\"working()\" (click)=\"sendDisableCode()\">\n {{ t('mfaDisableResendCode') }}\n </ion-button>\n } } @else {\n <val-title [props]=\"{ content: t('mfaDisableTitle'), size: 'large', color: '', bold: false }\" />\n <val-text [props]=\"{ size: 'medium', color: 'dark', bold: false, content: t('mfaDisableNeedsPassword') }\" />\n }\n <ion-button expand=\"block\" fill=\"clear\" color=\"dark\" (click)=\"backToStatus()\">\n {{ t('mfaCancel') }}\n </ion-button>\n } }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".mfa-modal{display:flex;flex-direction:column;gap:14px;padding-top:4px}.mfa-loading{display:flex;justify-content:center;padding:40px 0}.mfa-status{display:inline-flex;align-items:center;width:fit-content;padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin:0}.mfa-status--on{color:var(--ion-color-success-shade);background:rgba(var(--ion-color-success-rgb),.12)}.mfa-status--off{color:var(--ion-color-medium-shade);background:var(--ion-color-light)}.mfa-secret-row{display:flex;align-items:center;gap:8px}.mfa-secret-row .mfa-secret{flex:1;margin:0}.mfa-secret-row ion-button{flex-shrink:0;--color: var(--ion-color-dark);margin:0}.mfa-error{color:var(--ion-color-danger);font-size:13px;margin:4px 0 0}.mfa-block,.mfa-backup{display:flex;flex-direction:column;gap:12px}.mfa-block{padding:16px;border-radius:14px;background:var(--ion-color-light)}.mfa-alert{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:10px;background:var(--ion-color-light);border-left:3px solid var(--ion-color-primary)}.mfa-alert ion-icon{font-size:20px;color:var(--ion-color-primary);flex-shrink:0;margin-top:1px}.mfa-alert p{margin:0;font-size:13px;line-height:1.45;color:var(--ion-color-dark)}.mfa-qr{display:flex;justify-content:center;padding:8px 0}.mfa-secret{display:block;padding:10px 12px;border-radius:8px;background:var(--ion-color-light);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:15px;letter-spacing:.04em;word-break:break-all;text-align:center}.mfa-pin{display:flex;justify-content:center;padding:8px 0}.mfa-codes{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;padding:12px;border-radius:12px;background:var(--ion-color-light)}.mfa-code{padding:8px;border-radius:6px;background:var(--ion-background-color, #fff);color:var(--ion-color-dark);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;text-align:center}.mfa-method-list{display:flex;flex-direction:column;gap:8px}.mfa-method-card{display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-radius:12px;border:1.5px solid var(--ion-border-color, rgba(var(--ion-color-dark-rgb), .14));background:transparent;cursor:pointer;text-align:left;width:100%;transition:border-color .15s ease,background .15s ease}.mfa-method-card--active{border-color:var(--ion-color-primary);background:rgba(var(--ion-color-primary-rgb),.07)}.mfa-method-card__dot{flex-shrink:0;width:18px;height:18px;border-radius:50%;border:2px solid var(--ion-color-medium);margin-top:2px;transition:border-color .15s,box-shadow .15s}.mfa-method-card--active .mfa-method-card__dot{border-color:var(--ion-color-primary);box-shadow:inset 0 0 0 4px var(--ion-color-primary)}.mfa-method-card__body{display:flex;flex-direction:column;gap:3px}.mfa-method-card__body strong{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.mfa-method-card__body span{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.mfa-resend{text-align:center;font-size:14px;color:var(--ion-color-dark);margin:4px 0}.mfa-resend a{color:var(--ion-color-primary);cursor:pointer;font-weight:600}\n"] }]
31718
32321
  }], ctorParameters: () => [], propDecorators: { isOpen: [{
31719
32322
  type: Input
31720
32323
  }], prefillCode: [{
@@ -32318,7 +32921,7 @@ class WizardComponent {
32318
32921
  this.onClick = new EventEmitter();
32319
32922
  this.wrapperId = 'wizard-wrapper';
32320
32923
  this.currentStep = null;
32321
- this.currentStepTitles = null;
32924
+ this.currentStepTitles = { title: '' };
32322
32925
  this.loadingText = 'Por favor espere...';
32323
32926
  this.cdr = inject(ChangeDetectorRef);
32324
32927
  }
@@ -32328,23 +32931,12 @@ class WizardComponent {
32328
32931
  ngOnChanges(changes) {
32329
32932
  if (changes['props']) {
32330
32933
  this.updateCurrentStep();
32331
- this.cdr.detectChanges();
32332
32934
  }
32333
32935
  }
32334
32936
  updateCurrentStep() {
32335
32937
  if (this.props?.steps && this.props?.current) {
32336
32938
  this.currentStep = this.props.steps[this.props.current];
32337
- // Forzar nueva referencia de objeto para asegurar detección de cambios
32338
- // Agregar timestamp para garantizar que es un objeto completamente nuevo
32339
- this.currentStepTitles = this.currentStep?.titles
32340
- ? {
32341
- ...JSON.parse(JSON.stringify(this.currentStep.titles)),
32342
- _timestamp: Date.now(), // Forzar nueva referencia
32343
- _step: this.props.current, // Agregar identificador del paso
32344
- }
32345
- : null;
32346
- // Forzar detección de cambios inmediatamente
32347
- this.cdr.detectChanges();
32939
+ this.currentStepTitles = this.currentStep?.titles ?? { title: '' };
32348
32940
  }
32349
32941
  }
32350
32942
  working() {
@@ -32372,7 +32964,7 @@ class WizardComponent {
32372
32964
  if (this.props?.steps && this.props?.current) {
32373
32965
  return this.props.steps[this.props.current];
32374
32966
  }
32375
- return this.currentStep || { titles: null, buttons: [] };
32967
+ return this.currentStep || { titles: { title: '' }, buttons: [] };
32376
32968
  }
32377
32969
  setCurrent(newStep) {
32378
32970
  if (newStep === this.props.current) {
@@ -32380,24 +32972,19 @@ class WizardComponent {
32380
32972
  }
32381
32973
  this.props.current = newStep;
32382
32974
  this.updateCurrentStep();
32383
- // Forzar múltiples ciclos de detección de cambios
32384
- this.cdr.detectChanges();
32385
- setTimeout(() => {
32386
- this.cdr.detectChanges();
32387
- }, 0);
32388
32975
  goToTop(this.wrapperId);
32389
32976
  }
32390
32977
  setError(error) {
32391
32978
  if (this.props.state === ComponentStates.ERROR) {
32392
32979
  return;
32393
32980
  }
32394
- this.props.error.titles.bottomContent.content.bellowTitle.content = error;
32981
+ this.props.error.titles.description = error;
32395
32982
  this.props.state = ComponentStates.ERROR;
32396
32983
  this.cdr.markForCheck();
32397
32984
  goToTop(this.wrapperId);
32398
32985
  }
32399
32986
  reset() {
32400
- this.props.error.titles.bottomContent.content.bellowTitle.content = '';
32987
+ this.props.error.titles.description = '';
32401
32988
  this.done();
32402
32989
  }
32403
32990
  clickHandler(token) {
@@ -32413,7 +33000,7 @@ class WizardComponent {
32413
33000
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: WizardComponent, isStandalone: true, selector: "val-wizard", inputs: { props: "props" }, outputs: { onClick: "onClick" }, usesOnChanges: true, ngImport: i0, template: `
32414
33001
  <div [id]="wrapperId" class="wrapper">
32415
33002
  <ng-container *ngIf="props.state !== 'ERROR'">
32416
- <val-no-content [props]="currentStepTitles" [attr.data-step]="props.current"></val-no-content>
33003
+ <val-empty-state [props]="currentStepTitles" [attr.data-step]="props.current"></val-empty-state>
32417
33004
  <div class="step">
32418
33005
  <div *ngIf="props.state === 'WORKING'">
32419
33006
  <val-content-loader
@@ -32429,17 +33016,17 @@ class WizardComponent {
32429
33016
  </div>
32430
33017
  </ng-container>
32431
33018
  <ng-container *ngIf="props.state === 'ERROR'">
32432
- <val-no-content [props]="props.error.titles" (onClick)="clickHandler($event)"></val-no-content>
33019
+ <val-empty-state [props]="props.error.titles"></val-empty-state>
32433
33020
  </ng-container>
32434
33021
  </div>
32435
- `, isInline: true, styles: ["@charset \"UTF-8\";:root{--val-container-sm: 540px;--val-container-md: 720px;--val-container-lg: 880px;--val-container-xl: 1100px;--val-container-padding: 16px;--ion-color-primary: #7026df;--ion-color-primary-rgb: 112, 38, 223;--ion-color-primary-contrast: #ffffff;--ion-color-primary-contrast-rgb: 255, 255, 255;--ion-color-primary-shade: #6321c4;--ion-color-primary-tint: #7e3ce2;--ion-color-secondary: #e2ccff;--ion-color-secondary-rgb: 226, 204, 255;--ion-color-secondary-contrast: #000000;--ion-color-secondary-contrast-rgb: 0, 0, 0;--ion-color-secondary-shade: #c7b4e0;--ion-color-secondary-tint: #e5d1ff;--ion-color-texti: #354c69;--ion-color-texti-rgb: 53, 76, 105;--ion-color-texti-contrast: #ffffff;--ion-color-texti-contrast-rgb: 255, 255, 255;--ion-color-texti-shade: #2f435c;--ion-color-texti-tint: #495e78;--ion-color-darki: #090f1b;--ion-color-darki-rgb: 9, 15, 27;--ion-color-darki-contrast: #ffffff;--ion-color-darki-contrast-rgb: 255, 255, 255;--ion-color-darki-shade: #080d18;--ion-color-darki-tint: #222732;--ion-color-medium: #9e9e9e;--ion-color-medium-rgb: 158, 158, 158;--ion-color-medium-contrast: #000000;--ion-color-medium-contrast-rgb: 0, 0, 0;--ion-color-medium-shade: #8b8b8b;--ion-color-medium-tint: #a8a8a8;--swiper-pagination-color: var(--ion-color-primary);--swiper-navigation-color: var(--ion-color-primary);--swiper-pagination-bullet-inactive-color: var(--ion-color-medium)}body.dark,html.ion-palette-dark,body[data-theme=dark]{--ion-color-texti: #8fc1ff;--ion-color-texti-rgb: 143, 193, 255;--ion-color-texti-contrast: #000000;--ion-color-texti-contrast-rgb: 0, 0, 0;--ion-color-texti-shade: #7eaae0;--ion-color-texti-tint: #9ac7ff;--ion-color-darki: #ffffff;--ion-color-darki-rgb: 255, 255, 255;--ion-color-darki-contrast: #000000;--ion-color-darki-contrast-rgb: 0, 0, 0;--ion-color-darki-shade: #e0e0e0;--ion-color-darki-tint: #ffffff;--ion-color-primary: #8f49f8;--ion-color-primary-rgb: 143, 73, 248;--ion-color-primary-contrast: #ffffff;--ion-color-primary-contrast-rgb: 255, 255, 255;--ion-color-primary-shade: #7e40da;--ion-color-primary-tint: #9a5bf9}.ion-color-texti{--ion-color-base: var(--ion-color-texti);--ion-color-base-rgb: var(--ion-color-texti-rgb);--ion-color-contrast: var(--ion-color-texti-contrast);--ion-color-contrast-rgb: var(--ion-color-texti-contrast-rgb);--ion-color-shade: var(--ion-color-texti-shade);--ion-color-tint: var(--ion-color-texti-tint)}.ion-color-darki{--ion-color-base: var(--ion-color-darki);--ion-color-base-rgb: var(--ion-color-darki-rgb);--ion-color-contrast: var(--ion-color-darki-contrast);--ion-color-contrast-rgb: var(--ion-color-darki-contrast-rgb);--ion-color-shade: var(--ion-color-darki-shade);--ion-color-tint: var(--ion-color-darki-tint)}.wrapper{height:auto;display:flex;flex-direction:column;justify-content:space-between;position:relative;min-height:320px}.step{min-height:9.375rem;margin:16px 0;text-align:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: NoContentComponent, selector: "val-no-content", inputs: ["props"], outputs: ["onClick"] }, { kind: "component", type: ContentLoaderComponent, selector: "val-content-loader", inputs: ["props"] }] }); }
33022
+ `, isInline: true, styles: ["@charset \"UTF-8\";:root{--val-container-sm: 540px;--val-container-md: 720px;--val-container-lg: 880px;--val-container-xl: 1100px;--val-container-padding: 16px;--ion-color-primary: #7026df;--ion-color-primary-rgb: 112, 38, 223;--ion-color-primary-contrast: #ffffff;--ion-color-primary-contrast-rgb: 255, 255, 255;--ion-color-primary-shade: #6321c4;--ion-color-primary-tint: #7e3ce2;--ion-color-secondary: #e2ccff;--ion-color-secondary-rgb: 226, 204, 255;--ion-color-secondary-contrast: #000000;--ion-color-secondary-contrast-rgb: 0, 0, 0;--ion-color-secondary-shade: #c7b4e0;--ion-color-secondary-tint: #e5d1ff;--ion-color-texti: #354c69;--ion-color-texti-rgb: 53, 76, 105;--ion-color-texti-contrast: #ffffff;--ion-color-texti-contrast-rgb: 255, 255, 255;--ion-color-texti-shade: #2f435c;--ion-color-texti-tint: #495e78;--ion-color-darki: #090f1b;--ion-color-darki-rgb: 9, 15, 27;--ion-color-darki-contrast: #ffffff;--ion-color-darki-contrast-rgb: 255, 255, 255;--ion-color-darki-shade: #080d18;--ion-color-darki-tint: #222732;--ion-color-medium: #9e9e9e;--ion-color-medium-rgb: 158, 158, 158;--ion-color-medium-contrast: #000000;--ion-color-medium-contrast-rgb: 0, 0, 0;--ion-color-medium-shade: #8b8b8b;--ion-color-medium-tint: #a8a8a8;--swiper-pagination-color: var(--ion-color-primary);--swiper-navigation-color: var(--ion-color-primary);--swiper-pagination-bullet-inactive-color: var(--ion-color-medium)}body.dark,html.ion-palette-dark,body[data-theme=dark]{--ion-color-texti: #8fc1ff;--ion-color-texti-rgb: 143, 193, 255;--ion-color-texti-contrast: #000000;--ion-color-texti-contrast-rgb: 0, 0, 0;--ion-color-texti-shade: #7eaae0;--ion-color-texti-tint: #9ac7ff;--ion-color-darki: #ffffff;--ion-color-darki-rgb: 255, 255, 255;--ion-color-darki-contrast: #000000;--ion-color-darki-contrast-rgb: 0, 0, 0;--ion-color-darki-shade: #e0e0e0;--ion-color-darki-tint: #ffffff;--ion-color-primary: #8f49f8;--ion-color-primary-rgb: 143, 73, 248;--ion-color-primary-contrast: #ffffff;--ion-color-primary-contrast-rgb: 255, 255, 255;--ion-color-primary-shade: #7e40da;--ion-color-primary-tint: #9a5bf9}.ion-color-texti{--ion-color-base: var(--ion-color-texti);--ion-color-base-rgb: var(--ion-color-texti-rgb);--ion-color-contrast: var(--ion-color-texti-contrast);--ion-color-contrast-rgb: var(--ion-color-texti-contrast-rgb);--ion-color-shade: var(--ion-color-texti-shade);--ion-color-tint: var(--ion-color-texti-tint)}.ion-color-darki{--ion-color-base: var(--ion-color-darki);--ion-color-base-rgb: var(--ion-color-darki-rgb);--ion-color-contrast: var(--ion-color-darki-contrast);--ion-color-contrast-rgb: var(--ion-color-darki-contrast-rgb);--ion-color-shade: var(--ion-color-darki-shade);--ion-color-tint: var(--ion-color-darki-tint)}.wrapper{height:auto;display:flex;flex-direction:column;justify-content:space-between;position:relative;min-height:320px}.step{min-height:9.375rem;margin:16px 0;text-align:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }, { kind: "component", type: ContentLoaderComponent, selector: "val-content-loader", inputs: ["props"] }] }); }
32436
33023
  }
32437
33024
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: WizardComponent, decorators: [{
32438
33025
  type: Component,
32439
- args: [{ selector: 'val-wizard', standalone: true, imports: [CommonModule, NoContentComponent, ContentLoaderComponent], template: `
33026
+ args: [{ selector: 'val-wizard', standalone: true, imports: [CommonModule, EmptyStateComponent, ContentLoaderComponent], template: `
32440
33027
  <div [id]="wrapperId" class="wrapper">
32441
33028
  <ng-container *ngIf="props.state !== 'ERROR'">
32442
- <val-no-content [props]="currentStepTitles" [attr.data-step]="props.current"></val-no-content>
33029
+ <val-empty-state [props]="currentStepTitles" [attr.data-step]="props.current"></val-empty-state>
32443
33030
  <div class="step">
32444
33031
  <div *ngIf="props.state === 'WORKING'">
32445
33032
  <val-content-loader
@@ -32455,7 +33042,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
32455
33042
  </div>
32456
33043
  </ng-container>
32457
33044
  <ng-container *ngIf="props.state === 'ERROR'">
32458
- <val-no-content [props]="props.error.titles" (onClick)="clickHandler($event)"></val-no-content>
33045
+ <val-empty-state [props]="props.error.titles"></val-empty-state>
32459
33046
  </ng-container>
32460
33047
  </div>
32461
33048
  `, styles: ["@charset \"UTF-8\";:root{--val-container-sm: 540px;--val-container-md: 720px;--val-container-lg: 880px;--val-container-xl: 1100px;--val-container-padding: 16px;--ion-color-primary: #7026df;--ion-color-primary-rgb: 112, 38, 223;--ion-color-primary-contrast: #ffffff;--ion-color-primary-contrast-rgb: 255, 255, 255;--ion-color-primary-shade: #6321c4;--ion-color-primary-tint: #7e3ce2;--ion-color-secondary: #e2ccff;--ion-color-secondary-rgb: 226, 204, 255;--ion-color-secondary-contrast: #000000;--ion-color-secondary-contrast-rgb: 0, 0, 0;--ion-color-secondary-shade: #c7b4e0;--ion-color-secondary-tint: #e5d1ff;--ion-color-texti: #354c69;--ion-color-texti-rgb: 53, 76, 105;--ion-color-texti-contrast: #ffffff;--ion-color-texti-contrast-rgb: 255, 255, 255;--ion-color-texti-shade: #2f435c;--ion-color-texti-tint: #495e78;--ion-color-darki: #090f1b;--ion-color-darki-rgb: 9, 15, 27;--ion-color-darki-contrast: #ffffff;--ion-color-darki-contrast-rgb: 255, 255, 255;--ion-color-darki-shade: #080d18;--ion-color-darki-tint: #222732;--ion-color-medium: #9e9e9e;--ion-color-medium-rgb: 158, 158, 158;--ion-color-medium-contrast: #000000;--ion-color-medium-contrast-rgb: 0, 0, 0;--ion-color-medium-shade: #8b8b8b;--ion-color-medium-tint: #a8a8a8;--swiper-pagination-color: var(--ion-color-primary);--swiper-navigation-color: var(--ion-color-primary);--swiper-pagination-bullet-inactive-color: var(--ion-color-medium)}body.dark,html.ion-palette-dark,body[data-theme=dark]{--ion-color-texti: #8fc1ff;--ion-color-texti-rgb: 143, 193, 255;--ion-color-texti-contrast: #000000;--ion-color-texti-contrast-rgb: 0, 0, 0;--ion-color-texti-shade: #7eaae0;--ion-color-texti-tint: #9ac7ff;--ion-color-darki: #ffffff;--ion-color-darki-rgb: 255, 255, 255;--ion-color-darki-contrast: #000000;--ion-color-darki-contrast-rgb: 0, 0, 0;--ion-color-darki-shade: #e0e0e0;--ion-color-darki-tint: #ffffff;--ion-color-primary: #8f49f8;--ion-color-primary-rgb: 143, 73, 248;--ion-color-primary-contrast: #ffffff;--ion-color-primary-contrast-rgb: 255, 255, 255;--ion-color-primary-shade: #7e40da;--ion-color-primary-tint: #9a5bf9}.ion-color-texti{--ion-color-base: var(--ion-color-texti);--ion-color-base-rgb: var(--ion-color-texti-rgb);--ion-color-contrast: var(--ion-color-texti-contrast);--ion-color-contrast-rgb: var(--ion-color-texti-contrast-rgb);--ion-color-shade: var(--ion-color-texti-shade);--ion-color-tint: var(--ion-color-texti-tint)}.ion-color-darki{--ion-color-base: var(--ion-color-darki);--ion-color-base-rgb: var(--ion-color-darki-rgb);--ion-color-contrast: var(--ion-color-darki-contrast);--ion-color-contrast-rgb: var(--ion-color-darki-contrast-rgb);--ion-color-shade: var(--ion-color-darki-shade);--ion-color-tint: var(--ion-color-darki-tint)}.wrapper{height:auto;display:flex;flex-direction:column;justify-content:space-between;position:relative;min-height:320px}.step{min-height:9.375rem;margin:16px 0;text-align:center}\n"] }]
@@ -44299,498 +44886,6 @@ function news() {
44299
44886
  */
44300
44887
  // Transformer
44301
44888
 
44302
- /**
44303
- * Token de inyección para la configuración de Feedback.
44304
- */
44305
- const VALTECH_FEEDBACK_CONFIG = new InjectionToken('ValtechFeedbackConfig');
44306
- /**
44307
- * Configuración por defecto.
44308
- */
44309
- const DEFAULT_FEEDBACK_CONFIG = {
44310
- feedbackPrefix: '/v1/feedback',
44311
- maxAttachments: 5,
44312
- // Estándar acordado para adjuntos de feedback: solo imágenes (JPEG/PNG/WebP)
44313
- // y PDF, máx 5 MB. Reflejado en `storage.rules` (path `users/{uid}/feedback/`).
44314
- maxFileSize: 5 * 1024 * 1024,
44315
- allowedFileTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
44316
- storagePath: 'feedback',
44317
- };
44318
- /**
44319
- * Provee el servicio de feedback a la aplicación Angular.
44320
- *
44321
- * @param config - Configuración de feedback
44322
- * @returns EnvironmentProviders para usar en bootstrapApplication
44323
- *
44324
- * @example
44325
- * ```typescript
44326
- * // main.ts
44327
- * import { bootstrapApplication } from '@angular/platform-browser';
44328
- * import { provideValtechFeedback } from 'valtech-components';
44329
- * import { environment } from './environments/environment';
44330
- *
44331
- * bootstrapApplication(AppComponent, {
44332
- * providers: [
44333
- * provideValtechAuth({ apiUrl: environment.apiUrl }),
44334
- * provideValtechFeedback({
44335
- * apiUrl: environment.apiUrl,
44336
- * appId: 'my-app-name',
44337
- * }),
44338
- * ],
44339
- * });
44340
- * ```
44341
- */
44342
- function provideValtechFeedback(config) {
44343
- const mergedConfig = {
44344
- ...DEFAULT_FEEDBACK_CONFIG,
44345
- ...config,
44346
- };
44347
- return makeEnvironmentProviders([{ provide: VALTECH_FEEDBACK_CONFIG, useValue: mergedConfig }]);
44348
- }
44349
-
44350
- /**
44351
- * Servicio para gestionar feedback de usuarios.
44352
- *
44353
- * @example
44354
- * ```typescript
44355
- * @Component({...})
44356
- * export class MyComponent {
44357
- * private feedbackService = inject(FeedbackService);
44358
- *
44359
- * async submitFeedback() {
44360
- * const response = await this.feedbackService.createAsync(
44361
- * 'feedback',
44362
- * 'Mi comentario',
44363
- * 'Descripción detallada...'
44364
- * );
44365
- * console.log('Feedback enviado:', response.feedbackId);
44366
- * }
44367
- * }
44368
- * ```
44369
- */
44370
- class FeedbackService {
44371
- constructor() {
44372
- this.config = inject(VALTECH_FEEDBACK_CONFIG);
44373
- this.http = inject(HttpClient);
44374
- this.firestore = inject(FirestoreService, { optional: true });
44375
- this.storage = inject(StorageService, { optional: true });
44376
- this.auth = inject(AuthService, { optional: true });
44377
- }
44378
- /**
44379
- * URL base para endpoints de feedback.
44380
- */
44381
- get baseUrl() {
44382
- return `${this.config.apiUrl}${this.config.feedbackPrefix}`;
44383
- }
44384
- /**
44385
- * Captura el contexto del dispositivo automáticamente.
44386
- */
44387
- captureDeviceContext() {
44388
- const ua = navigator.userAgent;
44389
- return {
44390
- browser: this.detectBrowser(ua),
44391
- os: this.detectOS(ua),
44392
- viewport: `${window.innerWidth}x${window.innerHeight}`,
44393
- language: navigator.language,
44394
- userAgent: ua,
44395
- pageUrl: window.location.href,
44396
- };
44397
- }
44398
- /**
44399
- * Crea un nuevo feedback.
44400
- *
44401
- * @param type - Tipo de feedback
44402
- * @param title - Título del feedback
44403
- * @param description - Descripción detallada
44404
- * @param attachments - URLs de archivos adjuntos (opcional)
44405
- * @param contentRef - Referencia a contenido específico (opcional)
44406
- * @returns Observable con la respuesta
44407
- */
44408
- create(type, title, description, attachments = [], contentRef) {
44409
- const request = {
44410
- type,
44411
- title,
44412
- description,
44413
- attachments,
44414
- contentRef,
44415
- deviceContext: this.captureDeviceContext(),
44416
- appId: this.config.appId,
44417
- };
44418
- return this.http.post(this.baseUrl, request);
44419
- }
44420
- /**
44421
- * Crea un nuevo feedback (versión async/await).
44422
- */
44423
- async createAsync(type, title, description, attachments = [], contentRef) {
44424
- return firstValueFrom(this.create(type, title, description, attachments, contentRef));
44425
- }
44426
- /**
44427
- * Obtiene un feedback por ID (solo el propietario).
44428
- *
44429
- * @param feedbackId - ID del feedback
44430
- * @returns Observable con la respuesta
44431
- */
44432
- getById(feedbackId) {
44433
- return this.http.get(`${this.baseUrl}/${feedbackId}`);
44434
- }
44435
- /**
44436
- * Obtiene un feedback por ID (versión async/await).
44437
- */
44438
- async getByIdAsync(feedbackId) {
44439
- return firstValueFrom(this.getById(feedbackId));
44440
- }
44441
- /**
44442
- * Valida si un archivo cumple con las restricciones.
44443
- */
44444
- validateFile(file) {
44445
- // Verificar tamaño
44446
- if (file.size > this.config.maxFileSize) {
44447
- const maxSizeMB = Math.round(this.config.maxFileSize / (1024 * 1024));
44448
- return {
44449
- valid: false,
44450
- error: `El archivo excede el tamaño máximo de ${maxSizeMB}MB`,
44451
- };
44452
- }
44453
- // Verificar tipo
44454
- const allowedTypes = this.config.allowedFileTypes || [];
44455
- const isAllowed = allowedTypes.some(pattern => {
44456
- if (pattern.endsWith('/*')) {
44457
- const baseType = pattern.replace('/*', '');
44458
- return file.type.startsWith(baseType);
44459
- }
44460
- return file.type === pattern;
44461
- });
44462
- if (!isAllowed) {
44463
- return {
44464
- valid: false,
44465
- error: 'Tipo de archivo no permitido',
44466
- };
44467
- }
44468
- return { valid: true };
44469
- }
44470
- /**
44471
- * Valida el CONTENIDO del archivo por magic-bytes (firma binaria) —
44472
- * defensa contra spoofing del content-type declarado. Lee los primeros 12
44473
- * bytes y los compara con las firmas de JPEG/PNG/WebP/PDF (whitelist
44474
- * estricta). Es client-side, ergo bypasseable; la defensa real vive en las
44475
- * Storage rules. Esto corta el 99% de los casos accidentales o no-targeted.
44476
- */
44477
- async validateFileContent(file) {
44478
- const buf = await file.slice(0, 12).arrayBuffer();
44479
- const b = new Uint8Array(buf);
44480
- // JPEG: FF D8 FF
44481
- if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
44482
- return { valid: true };
44483
- }
44484
- // PNG: 89 50 4E 47 0D 0A 1A 0A
44485
- if (b[0] === 0x89 &&
44486
- b[1] === 0x50 &&
44487
- b[2] === 0x4e &&
44488
- b[3] === 0x47 &&
44489
- b[4] === 0x0d &&
44490
- b[5] === 0x0a &&
44491
- b[6] === 0x1a &&
44492
- b[7] === 0x0a) {
44493
- return { valid: true };
44494
- }
44495
- // WebP: "RIFF" (0..3) + "WEBP" (8..11)
44496
- if (b[0] === 0x52 &&
44497
- b[1] === 0x49 &&
44498
- b[2] === 0x46 &&
44499
- b[3] === 0x46 &&
44500
- b[8] === 0x57 &&
44501
- b[9] === 0x45 &&
44502
- b[10] === 0x42 &&
44503
- b[11] === 0x50) {
44504
- return { valid: true };
44505
- }
44506
- // PDF: "%PDF-"
44507
- if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) {
44508
- return { valid: true };
44509
- }
44510
- return {
44511
- valid: false,
44512
- error: 'El contenido del archivo no coincide con un tipo permitido (imagen o PDF).',
44513
- };
44514
- }
44515
- /**
44516
- * Sube un adjunto a Firebase Storage en `users/{uid}/feedback/{uuid}/{name}`
44517
- * y devuelve su download URL. Ejecuta tres validaciones en orden:
44518
- * 1. tamaño + tipo declarado (`validateFile`)
44519
- * 2. contenido por magic-bytes (`validateFileContent`)
44520
- * 3. usuario autenticado (no soportamos adjuntos en feedback anónimo)
44521
- *
44522
- * Si cualquier validación falla → rechaza la promesa con un Error legible;
44523
- * el componente que la consuma debe cancelar la operación de adjuntar.
44524
- */
44525
- async uploadAttachment(file) {
44526
- const sizeTypeCheck = this.validateFile(file);
44527
- if (!sizeTypeCheck.valid) {
44528
- throw new Error(sizeTypeCheck.error);
44529
- }
44530
- const contentCheck = await this.validateFileContent(file);
44531
- if (!contentCheck.valid) {
44532
- throw new Error(contentCheck.error);
44533
- }
44534
- const userId = this.auth?.user()?.userId;
44535
- if (!userId) {
44536
- throw new Error('Debes iniciar sesión para adjuntar archivos.');
44537
- }
44538
- if (!this.storage) {
44539
- throw new Error('StorageService no está configurado.');
44540
- }
44541
- // Path bajo `users/{uid}/feedback/` — sibling de `files/`, con su propia
44542
- // regla en `storage.rules` (whitelist estricta JPEG/PNG/WebP/PDF + 5MB).
44543
- // skipPrefix=true porque `users/{uid}/` es path GLOBAL cross-app.
44544
- const uuid = typeof crypto !== 'undefined' && 'randomUUID' in crypto
44545
- ? crypto.randomUUID()
44546
- : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
44547
- const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
44548
- const path = `users/${userId}/feedback/${uuid}/${safeName}`;
44549
- const result = await this.storage.uploadAndGetUrl(path, file, {
44550
- contentType: file.type,
44551
- skipPrefix: true,
44552
- });
44553
- return result.downloadUrl;
44554
- }
44555
- /**
44556
- * Obtiene la configuración actual del servicio.
44557
- */
44558
- getConfig() {
44559
- return this.config;
44560
- }
44561
- // =========================================================================
44562
- // Reaction Methods (Content feedback with emojis)
44563
- // =========================================================================
44564
- /**
44565
- * Verifica si el usuario ya dio feedback para una entidad específica.
44566
- *
44567
- * Primero intenta leer de Firebase (rápido, sin latencia de red al backend).
44568
- * Si Firebase no está disponible o falla, hace fallback a la API.
44569
- *
44570
- * @param entityType - Tipo de entidad (article, docs, feature, etc.)
44571
- * @param entityId - ID de la entidad
44572
- * @returns Promise con la respuesta de verificación
44573
- *
44574
- * @example
44575
- * ```typescript
44576
- * const check = await this.feedbackService.checkFeedback('article', 'art-123');
44577
- * if (check.hasFeedback) {
44578
- * console.log('Ya dio feedback:', check.reactionValue);
44579
- * }
44580
- * ```
44581
- */
44582
- async checkFeedback(entityType, entityId) {
44583
- // Si no hay usuario autenticado, no puede haber feedback previo
44584
- // Retornar inmediatamente sin llamar al API (evita 401 y redirect a login)
44585
- const userId = this.auth?.user()?.userId;
44586
- if (!userId) {
44587
- return { operationId: '', hasFeedback: false };
44588
- }
44589
- // 1. Intentar Firebase primero (si está disponible)
44590
- if (this.firestore) {
44591
- try {
44592
- // Path: feedback/{entityType}/{entityId}/{userId}
44593
- // FirestoreService agrega automáticamente el prefijo apps/{appId}/
44594
- const collectionPath = `feedback/${entityType}/${entityId}`;
44595
- const doc = await this.firestore.getDoc(collectionPath, userId);
44596
- if (doc) {
44597
- return {
44598
- operationId: '',
44599
- hasFeedback: true,
44600
- feedbackId: doc.feedbackId,
44601
- type: doc.type,
44602
- reactionValue: doc.reactionValue,
44603
- createdAt: doc.createdAt?.toISOString(),
44604
- };
44605
- }
44606
- // Doc no existe = no hay feedback
44607
- return { operationId: '', hasFeedback: false };
44608
- }
44609
- catch (error) {
44610
- console.warn('[FeedbackService] Firebase check failed, falling back to API:', error);
44611
- // Fallback a API
44612
- }
44613
- }
44614
- // 2. Fallback: llamar API (solo si hay usuario autenticado)
44615
- const params = new URLSearchParams({
44616
- appId: this.config.appId,
44617
- entityType,
44618
- entityId,
44619
- });
44620
- return firstValueFrom(this.http.get(`${this.baseUrl}/check?${params}`));
44621
- }
44622
- /**
44623
- * Crea o actualiza una reacción (feedback con emoji).
44624
- *
44625
- * @param entityRef - Referencia a la entidad
44626
- * @param value - Valor de la reacción (negative, neutral, positive)
44627
- * @param comment - Comentario opcional (máx 500 caracteres)
44628
- * @returns Promise con la respuesta
44629
- *
44630
- * @example
44631
- * ```typescript
44632
- * const response = await this.feedbackService.createReaction(
44633
- * { entityType: 'article', entityId: 'art-123' },
44634
- * 'positive',
44635
- * 'Muy útil!'
44636
- * );
44637
- * ```
44638
- */
44639
- async createReaction(entityRef, value, comment) {
44640
- const request = {
44641
- type: 'reaction',
44642
- entityRef,
44643
- reactionValue: value,
44644
- description: comment || '',
44645
- deviceContext: this.captureDeviceContext(),
44646
- appId: this.config.appId,
44647
- };
44648
- return firstValueFrom(this.http.post(this.baseUrl, request));
44649
- }
44650
- /**
44651
- * Crea feedback anónimo (sin autenticación requerida).
44652
- * Usado para blogs, FAQs y contenido público.
44653
- *
44654
- * @param entityRef - Referencia a la entidad
44655
- * @param value - Valor de la reacción (negative, neutral, positive)
44656
- * @param comment - Comentario opcional (máx 500 caracteres)
44657
- * @returns Promise con la respuesta
44658
- *
44659
- * @example
44660
- * ```typescript
44661
- * // En un blog público
44662
- * const response = await this.feedbackService.createAnonymousReaction(
44663
- * { entityType: 'blog', entityId: 'my-post-slug' },
44664
- * 'positive'
44665
- * );
44666
- * ```
44667
- */
44668
- async createAnonymousReaction(entityRef, value, comment) {
44669
- const request = {
44670
- type: 'reaction',
44671
- entityRef,
44672
- reactionValue: value,
44673
- description: comment || '',
44674
- deviceContext: this.captureDeviceContext(),
44675
- appId: this.config.appId,
44676
- };
44677
- return firstValueFrom(this.http.post(`${this.baseUrl}/anonymous`, request));
44678
- }
44679
- // =========================================================================
44680
- // Helpers privados para detección de browser/OS
44681
- // =========================================================================
44682
- detectBrowser(ua) {
44683
- if (ua.includes('Edg/'))
44684
- return 'Edge';
44685
- if (ua.includes('Chrome/'))
44686
- return 'Chrome';
44687
- if (ua.includes('Firefox/'))
44688
- return 'Firefox';
44689
- if (ua.includes('Safari/') && !ua.includes('Chrome'))
44690
- return 'Safari';
44691
- if (ua.includes('Opera') || ua.includes('OPR/'))
44692
- return 'Opera';
44693
- return 'Unknown';
44694
- }
44695
- detectOS(ua) {
44696
- if (ua.includes('Windows NT 10'))
44697
- return 'Windows 10';
44698
- if (ua.includes('Windows NT 11'))
44699
- return 'Windows 11';
44700
- if (ua.includes('Windows'))
44701
- return 'Windows';
44702
- if (ua.includes('Mac OS X')) {
44703
- const match = ua.match(/Mac OS X (\d+[._]\d+)/);
44704
- if (match) {
44705
- return `macOS ${match[1].replace('_', '.')}`;
44706
- }
44707
- return 'macOS';
44708
- }
44709
- if (ua.includes('Android'))
44710
- return 'Android';
44711
- if (ua.includes('iPhone') || ua.includes('iPad'))
44712
- return 'iOS';
44713
- if (ua.includes('Linux'))
44714
- return 'Linux';
44715
- return 'Unknown';
44716
- }
44717
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
44718
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, providedIn: 'root' }); }
44719
- }
44720
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, decorators: [{
44721
- type: Injectable,
44722
- args: [{ providedIn: 'root' }]
44723
- }] });
44724
-
44725
- /**
44726
- * Configuración por defecto de tipos de feedback.
44727
- */
44728
- const DEFAULT_FEEDBACK_TYPE_OPTIONS = [
44729
- {
44730
- value: 'issue',
44731
- label: 'Reportar problema',
44732
- description: 'Algo no funciona correctamente',
44733
- icon: 'bug-outline',
44734
- },
44735
- {
44736
- value: 'poor-content',
44737
- label: 'Contenido incorrecto',
44738
- description: 'Información incorrecta o desactualizada',
44739
- icon: 'document-text-outline',
44740
- },
44741
- {
44742
- value: 'feedback',
44743
- label: 'Comentario general',
44744
- description: 'Tu opinión o experiencia',
44745
- icon: 'chatbubble-outline',
44746
- },
44747
- {
44748
- value: 'suggestion',
44749
- label: 'Sugerencia',
44750
- description: 'Propuesta de mejora o nueva funcionalidad',
44751
- icon: 'bulb-outline',
44752
- },
44753
- ];
44754
-
44755
- /**
44756
- * Valtech Feedback Service
44757
- *
44758
- * Servicio para gestionar feedback de usuarios a nivel de plataforma.
44759
- *
44760
- * @example
44761
- * ```typescript
44762
- * // main.ts - Configuración
44763
- * import { provideValtechFeedback } from 'valtech-components';
44764
- *
44765
- * bootstrapApplication(AppComponent, {
44766
- * providers: [
44767
- * provideValtechAuth({ apiUrl: environment.apiUrl }),
44768
- * provideValtechFeedback({
44769
- * apiUrl: environment.apiUrl,
44770
- * appId: 'my-app-name',
44771
- * }),
44772
- * ],
44773
- * });
44774
- *
44775
- * // component.ts - Uso
44776
- * import { FeedbackService } from 'valtech-components';
44777
- *
44778
- * @Component({...})
44779
- * export class MyComponent {
44780
- * private feedbackService = inject(FeedbackService);
44781
- *
44782
- * async submitFeedback() {
44783
- * const response = await this.feedbackService.createAsync(
44784
- * 'feedback',
44785
- * 'Título',
44786
- * 'Descripción...'
44787
- * );
44788
- * }
44789
- * }
44790
- * ```
44791
- */
44792
- // Configuration
44793
-
44794
44889
  /**
44795
44890
  * Token de inyección para la configuración de Donation/Support.
44796
44891
  */
@@ -44943,317 +45038,200 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
44943
45038
  * ```
44944
45039
  */
44945
45040
 
44946
- /**
44947
- * val-feedback-form
44948
- *
44949
- * Formulario reutilizable para enviar feedback desde cualquier parte de la aplicación.
44950
- *
44951
- * @example
44952
- * ```html
44953
- * <!-- Feedback general -->
44954
- * <val-feedback-form
44955
- * [props]="{ defaultType: 'feedback', showTypeSelector: true }"
44956
- * (onSubmit)="handleSuccess($event)"
44957
- * (onCancel)="closeModal()"
44958
- * />
44959
- *
44960
- * <!-- Reportar contenido incorrecto -->
44961
- * <val-feedback-form
44962
- * [props]="{
44963
- * defaultType: 'poor-content',
44964
- * showTypeSelector: false,
44965
- * contentRef: { contentId: article.id, contentType: 'article' },
44966
- * submitButtonText: 'Reportar contenido'
44967
- * }"
44968
- * />
44969
- * ```
44970
- */
44971
45041
  class FeedbackFormComponent {
44972
45042
  constructor() {
44973
- /**
44974
- * Configuración del formulario.
44975
- */
44976
45043
  this.props = {};
44977
- /**
44978
- * Evento emitido cuando el feedback se envía exitosamente.
44979
- */
44980
45044
  this.onSubmit = new EventEmitter();
44981
- /**
44982
- * Evento emitido cuando el usuario cancela.
44983
- */
44984
45045
  this.onCancel = new EventEmitter();
44985
- this.fb = inject(FormBuilder);
44986
- this.feedbackService = inject(FeedbackService);
44987
45046
  this.i18n = inject(I18nService);
45047
+ this.feedbackService = inject(FeedbackService);
44988
45048
  this.typeOptions = DEFAULT_FEEDBACK_TYPE_OPTIONS;
44989
45049
  this.isSubmitting = signal(false);
44990
45050
  this.isSuccess = signal(false);
44991
45051
  this.error = signal(null);
44992
- addIcons({
44993
- bugOutline,
44994
- bulbOutline,
44995
- chatbubbleOutline,
44996
- checkmarkCircleOutline,
44997
- closeCircleOutline,
44998
- documentTextOutline,
44999
- });
45052
+ this.currentAttachments = [];
45053
+ addIcons({ checkmarkCircleOutline, closeCircleOutline });
45000
45054
  }
45001
45055
  ngOnInit() {
45002
- // Filtrar tipos habilitados si se especifica
45003
45056
  if (this.props.enabledTypes?.length) {
45004
45057
  this.typeOptions = this.typeOptions.filter(opt => this.props.enabledTypes.includes(opt.value));
45005
45058
  }
45006
- // Usar opciones personalizadas si se proporcionan
45007
45059
  if (this.props.typeOptions?.length) {
45008
45060
  this.typeOptions = this.props.typeOptions;
45009
45061
  }
45010
- // Inicializar formulario
45011
- this.form = this.fb.group({
45012
- type: [this.props.defaultType || 'feedback', Validators.required],
45013
- title: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(200)]],
45014
- description: ['', [Validators.required, Validators.minLength(10), Validators.maxLength(5000)]],
45062
+ this.formProps = this.buildFormProps();
45063
+ }
45064
+ buildFormProps() {
45065
+ const fields = [];
45066
+ if (this.props.showTypeSelector !== false) {
45067
+ const options = this.typeOptions.map((opt, i) => ({
45068
+ id: opt.value,
45069
+ name: opt.label,
45070
+ order: i,
45071
+ selected: opt.value === (this.props.defaultType ?? 'feedback'),
45072
+ }));
45073
+ fields.push({
45074
+ token: 'feedback-type',
45075
+ name: 'type',
45076
+ label: this.i18n.t('feedbackType'),
45077
+ hint: '',
45078
+ placeholder: '',
45079
+ type: InputType.SELECT,
45080
+ order: 1,
45081
+ validators: [Validators.required],
45082
+ options,
45083
+ value: this.props.defaultType ?? 'feedback',
45084
+ errors: {},
45085
+ state: ComponentStates.ENABLED,
45086
+ });
45087
+ }
45088
+ fields.push({
45089
+ token: 'feedback-title',
45090
+ name: 'title',
45091
+ label: this.props.titleLabel ?? this.i18n.t('title'),
45092
+ hint: '',
45093
+ placeholder: this.props.titlePlaceholder ?? this.i18n.t('titlePlaceholder'),
45094
+ type: InputType.TEXT,
45095
+ order: 2,
45096
+ validators: [Validators.required, Validators.minLength(5), Validators.maxLength(200)],
45097
+ errors: {
45098
+ required: this.i18n.t('titleValidation'),
45099
+ minlength: this.i18n.t('titleValidation'),
45100
+ maxlength: this.i18n.t('titleValidation'),
45101
+ },
45102
+ state: ComponentStates.ENABLED,
45103
+ }, {
45104
+ token: 'feedback-description',
45105
+ name: 'description',
45106
+ label: this.props.descriptionLabel ?? this.i18n.t('description'),
45107
+ hint: '',
45108
+ placeholder: this.props.descriptionPlaceholder ?? this.i18n.t('descriptionPlaceholder'),
45109
+ type: InputType.TEXTAREA,
45110
+ order: 3,
45111
+ range: { min: 10, max: 5000 },
45112
+ validators: [Validators.required, Validators.minLength(10), Validators.maxLength(5000)],
45113
+ errors: {
45114
+ required: this.i18n.t('descriptionValidation'),
45115
+ minlength: this.i18n.t('descriptionValidation'),
45116
+ maxlength: this.i18n.t('descriptionValidation'),
45117
+ },
45118
+ state: ComponentStates.ENABLED,
45015
45119
  });
45120
+ return {
45121
+ name: '',
45122
+ sections: [{ name: '', order: 0, fields }],
45123
+ actions: {
45124
+ type: 'submit',
45125
+ color: 'primary',
45126
+ text: this.props.submitButtonText ?? this.i18n.t('submit'),
45127
+ state: ComponentStates.ENABLED,
45128
+ expand: 'block',
45129
+ },
45130
+ state: ComponentStates.ENABLED,
45131
+ };
45016
45132
  }
45017
- async handleSubmit() {
45018
- if (this.form.invalid || this.isSubmitting())
45133
+ async handleFormSubmit(submitted) {
45134
+ if (this.isSubmitting())
45135
+ return;
45136
+ if (this.currentAttachments.some(a => a.status === 'uploading'))
45019
45137
  return;
45020
45138
  this.isSubmitting.set(true);
45021
45139
  this.error.set(null);
45022
45140
  this.isSuccess.set(false);
45141
+ this.formProps.state = ComponentStates.WORKING;
45142
+ this.formProps.actions.state = ComponentStates.WORKING;
45023
45143
  try {
45024
- const { type, title, description } = this.form.value;
45025
- const response = await this.feedbackService.createAsync(type, title, description, [], // attachments (por ahora vacío)
45026
- this.props.contentRef);
45144
+ const type = submitted.fields['type'] ?? this.props.defaultType ?? 'feedback';
45145
+ const title = submitted.fields['title'];
45146
+ const description = submitted.fields['description'];
45147
+ const attachmentUrls = this.currentAttachments.filter(a => a.status === 'ready').map(a => a.url);
45148
+ const response = await this.feedbackService.createAsync(type, title, description, attachmentUrls, this.props.contentRef);
45027
45149
  this.isSuccess.set(true);
45028
- this.form.reset({ type: this.props.defaultType || 'feedback' });
45029
- this.onSubmit.emit({
45030
- response,
45031
- type: type,
45032
- title,
45033
- });
45150
+ this.onSubmit.emit({ response, type, title, attachmentUrls });
45034
45151
  }
45035
45152
  catch (err) {
45036
45153
  this.error.set(err.error?.message || err.message || this.i18n.t('feedbackError'));
45037
45154
  }
45038
45155
  finally {
45039
45156
  this.isSubmitting.set(false);
45157
+ this.formProps.state = ComponentStates.ENABLED;
45158
+ this.formProps.actions.state = ComponentStates.ENABLED;
45040
45159
  }
45041
45160
  }
45161
+ onAttachmentsChange(items) {
45162
+ this.currentAttachments = items;
45163
+ }
45042
45164
  onCancelClick() {
45043
45165
  this.onCancel.emit();
45044
45166
  }
45045
45167
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
45046
45168
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FeedbackFormComponent, isStandalone: true, selector: "val-feedback-form", inputs: { props: "props" }, outputs: { onSubmit: "onSubmit", onCancel: "onCancel" }, ngImport: i0, template: `
45047
- <form
45048
- [formGroup]="form"
45049
- (ngSubmit)="handleSubmit()"
45050
- class="feedback-form"
45051
- [class.compact]="props.compact"
45052
- [ngClass]="props.cssClass"
45053
- >
45054
- <!-- Type selector -->
45055
- @if (props.showTypeSelector !== false) {
45056
- <ion-item>
45057
- <ion-select
45058
- formControlName="type"
45059
- [label]="i18n.t('feedbackType')"
45060
- labelPlacement="floating"
45061
- interface="popover"
45062
- >
45063
- @for (option of typeOptions; track option.value) {
45064
- <ion-select-option [value]="option.value">
45065
- {{ option.label }}
45066
- </ion-select-option>
45067
- }
45068
- </ion-select>
45069
- </ion-item>
45070
- }
45071
-
45072
- <!-- Title -->
45073
- <ion-item>
45074
- <ion-textarea
45075
- formControlName="title"
45076
- [label]="props.titleLabel || i18n.t('title')"
45077
- labelPlacement="floating"
45078
- [placeholder]="props.titlePlaceholder || i18n.t('titlePlaceholder')"
45079
- [maxlength]="200"
45080
- [counter]="true"
45081
- [autoGrow]="false"
45082
- rows="1"
45083
- ></ion-textarea>
45084
- </ion-item>
45085
- @if (form.get('title')?.invalid && form.get('title')?.touched) {
45086
- <ion-note color="danger" class="ion-padding-start">
45087
- {{ i18n.t('titleValidation') }}
45088
- </ion-note>
45089
- }
45090
-
45091
- <!-- Description -->
45092
- <ion-item>
45093
- <ion-textarea
45094
- formControlName="description"
45095
- [label]="props.descriptionLabel || i18n.t('description')"
45096
- labelPlacement="floating"
45097
- [placeholder]="props.descriptionPlaceholder || i18n.t('descriptionPlaceholder')"
45098
- [maxlength]="5000"
45099
- [counter]="true"
45100
- [autoGrow]="true"
45101
- rows="4"
45102
- ></ion-textarea>
45103
- </ion-item>
45104
- @if (form.get('description')?.invalid && form.get('description')?.touched) {
45105
- <ion-note color="danger" class="ion-padding-start">
45106
- {{ i18n.t('descriptionValidation') }}
45107
- </ion-note>
45108
- }
45109
-
45110
- <!-- Error message -->
45111
- @if (error()) {
45112
- <div class="feedback-alert error">
45113
- <ion-icon name="close-circle-outline"></ion-icon>
45114
- <span>{{ error() }}</span>
45115
- </div>
45116
- }
45117
-
45118
- <!-- Success message -->
45119
- @if (isSuccess()) {
45120
- <div class="feedback-alert success">
45121
- <ion-icon name="checkmark-circle-outline"></ion-icon>
45122
- <span>{{ props.successMessage || i18n.t('feedbackSuccess') }}</span>
45123
- </div>
45124
- }
45169
+ <div class="feedback-form-wrapper" [class.compact]="props.compact" [ngClass]="props.cssClass">
45170
+ <val-form [props]="formProps" (onSubmit)="handleFormSubmit($event)">
45171
+ @if (props.showAttachments !== false) {
45172
+ <val-attachment-uploader
45173
+ [props]="{ maxFiles: 5 }"
45174
+ (attachmentsChange)="onAttachmentsChange($event)"
45175
+ ></val-attachment-uploader>
45176
+ }
45177
+
45178
+ @if (error()) {
45179
+ <div class="feedback-alert error">
45180
+ <ion-icon name="close-circle-outline"></ion-icon>
45181
+ <span>{{ error() }}</span>
45182
+ </div>
45183
+ }
45125
45184
 
45126
- <!-- Actions -->
45127
- <div class="form-actions">
45128
- @if (props.cancelButtonText) {
45129
- <ion-button fill="outline" color="medium" type="button" (click)="onCancelClick()">
45130
- {{ props.cancelButtonText }}
45131
- </ion-button>
45185
+ @if (isSuccess()) {
45186
+ <div class="feedback-alert success">
45187
+ <ion-icon name="checkmark-circle-outline"></ion-icon>
45188
+ <span>{{ props.successMessage || i18n.t('feedbackSuccess') }}</span>
45189
+ </div>
45132
45190
  }
45133
- <ion-button type="submit" [disabled]="form.invalid || isSubmitting()" expand="block">
45134
- @if (isSubmitting()) {
45135
- <ion-spinner name="circular"></ion-spinner>
45136
- } @else {
45137
- {{ props.submitButtonText || i18n.t('submit') }}
45138
- }
45191
+ </val-form>
45192
+
45193
+ @if (props.cancelButtonText) {
45194
+ <ion-button fill="outline" color="medium" expand="block" class="cancel-button" (click)="onCancelClick()">
45195
+ {{ props.cancelButtonText }}
45139
45196
  </ion-button>
45140
- </div>
45141
- </form>
45142
- `, isInline: true, styles: [".feedback-form{display:flex;flex-direction:column;gap:8px;&.compact{gap:4px}}.form-actions{display:flex;gap:8px;margin-top:16px;justify-content:flex-end;ion-button{flex:1}}.feedback-alert{display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:8px;margin-top:8px;&.error{background-color:var(--ion-color-danger-tint);color:var(--ion-color-danger-shade)}&.success{background-color:var(--ion-color-success-tint);color:var(--ion-color-success-shade)}ion-icon{font-size:20px}}ion-note{font-size:12px;margin-top:-4px;margin-bottom:8px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1$3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$3.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }, { kind: "component", type: IonSelect, selector: "ion-select", inputs: ["cancelText", "color", "compareWith", "disabled", "errorText", "expandedIcon", "fill", "helperText", "interface", "interfaceOptions", "justify", "label", "labelPlacement", "mode", "multiple", "name", "okText", "placeholder", "selectedText", "shape", "toggleIcon", "value"] }, { kind: "component", type: IonSelectOption, selector: "ion-select-option", inputs: ["disabled", "value"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: IonTextarea, selector: "ion-textarea", inputs: ["autoGrow", "autocapitalize", "autofocus", "clearOnEdit", "color", "cols", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "maxlength", "minlength", "mode", "name", "placeholder", "readonly", "required", "rows", "shape", "spellcheck", "value", "wrap"] }] }); }
45197
+ }
45198
+ </div>
45199
+ `, isInline: true, styles: [".feedback-form-wrapper{display:flex;flex-direction:column;gap:8px;&.compact{gap:4px}}.feedback-alert{display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:8px;margin-top:8px;&.error{background-color:var(--ion-color-danger-tint);color:var(--ion-color-danger-shade)}&.success{background-color:var(--ion-color-success-tint);color:var(--ion-color-success-shade)}ion-icon{font-size:20px}}.cancel-button{margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }, { kind: "component", type: AttachmentUploaderComponent, selector: "val-attachment-uploader", inputs: ["props"], outputs: ["attachmentsChange"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }] }); }
45143
45200
  }
45144
45201
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackFormComponent, decorators: [{
45145
45202
  type: Component,
45146
- args: [{ selector: 'val-feedback-form', standalone: true, imports: [
45147
- CommonModule,
45148
- ReactiveFormsModule,
45149
- IonButton,
45150
- IonIcon,
45151
- IonItem,
45152
- IonLabel,
45153
- IonList,
45154
- IonNote,
45155
- IonSelect,
45156
- IonSelectOption,
45157
- IonSpinner,
45158
- IonText,
45159
- IonTextarea,
45160
- ], template: `
45161
- <form
45162
- [formGroup]="form"
45163
- (ngSubmit)="handleSubmit()"
45164
- class="feedback-form"
45165
- [class.compact]="props.compact"
45166
- [ngClass]="props.cssClass"
45167
- >
45168
- <!-- Type selector -->
45169
- @if (props.showTypeSelector !== false) {
45170
- <ion-item>
45171
- <ion-select
45172
- formControlName="type"
45173
- [label]="i18n.t('feedbackType')"
45174
- labelPlacement="floating"
45175
- interface="popover"
45176
- >
45177
- @for (option of typeOptions; track option.value) {
45178
- <ion-select-option [value]="option.value">
45179
- {{ option.label }}
45180
- </ion-select-option>
45181
- }
45182
- </ion-select>
45183
- </ion-item>
45184
- }
45185
-
45186
- <!-- Title -->
45187
- <ion-item>
45188
- <ion-textarea
45189
- formControlName="title"
45190
- [label]="props.titleLabel || i18n.t('title')"
45191
- labelPlacement="floating"
45192
- [placeholder]="props.titlePlaceholder || i18n.t('titlePlaceholder')"
45193
- [maxlength]="200"
45194
- [counter]="true"
45195
- [autoGrow]="false"
45196
- rows="1"
45197
- ></ion-textarea>
45198
- </ion-item>
45199
- @if (form.get('title')?.invalid && form.get('title')?.touched) {
45200
- <ion-note color="danger" class="ion-padding-start">
45201
- {{ i18n.t('titleValidation') }}
45202
- </ion-note>
45203
- }
45204
-
45205
- <!-- Description -->
45206
- <ion-item>
45207
- <ion-textarea
45208
- formControlName="description"
45209
- [label]="props.descriptionLabel || i18n.t('description')"
45210
- labelPlacement="floating"
45211
- [placeholder]="props.descriptionPlaceholder || i18n.t('descriptionPlaceholder')"
45212
- [maxlength]="5000"
45213
- [counter]="true"
45214
- [autoGrow]="true"
45215
- rows="4"
45216
- ></ion-textarea>
45217
- </ion-item>
45218
- @if (form.get('description')?.invalid && form.get('description')?.touched) {
45219
- <ion-note color="danger" class="ion-padding-start">
45220
- {{ i18n.t('descriptionValidation') }}
45221
- </ion-note>
45222
- }
45223
-
45224
- <!-- Error message -->
45225
- @if (error()) {
45226
- <div class="feedback-alert error">
45227
- <ion-icon name="close-circle-outline"></ion-icon>
45228
- <span>{{ error() }}</span>
45229
- </div>
45230
- }
45231
-
45232
- <!-- Success message -->
45233
- @if (isSuccess()) {
45234
- <div class="feedback-alert success">
45235
- <ion-icon name="checkmark-circle-outline"></ion-icon>
45236
- <span>{{ props.successMessage || i18n.t('feedbackSuccess') }}</span>
45237
- </div>
45238
- }
45203
+ args: [{ selector: 'val-feedback-form', standalone: true, imports: [CommonModule, FormComponent, AttachmentUploaderComponent, IonButton, IonIcon], template: `
45204
+ <div class="feedback-form-wrapper" [class.compact]="props.compact" [ngClass]="props.cssClass">
45205
+ <val-form [props]="formProps" (onSubmit)="handleFormSubmit($event)">
45206
+ @if (props.showAttachments !== false) {
45207
+ <val-attachment-uploader
45208
+ [props]="{ maxFiles: 5 }"
45209
+ (attachmentsChange)="onAttachmentsChange($event)"
45210
+ ></val-attachment-uploader>
45211
+ }
45212
+
45213
+ @if (error()) {
45214
+ <div class="feedback-alert error">
45215
+ <ion-icon name="close-circle-outline"></ion-icon>
45216
+ <span>{{ error() }}</span>
45217
+ </div>
45218
+ }
45239
45219
 
45240
- <!-- Actions -->
45241
- <div class="form-actions">
45242
- @if (props.cancelButtonText) {
45243
- <ion-button fill="outline" color="medium" type="button" (click)="onCancelClick()">
45244
- {{ props.cancelButtonText }}
45245
- </ion-button>
45220
+ @if (isSuccess()) {
45221
+ <div class="feedback-alert success">
45222
+ <ion-icon name="checkmark-circle-outline"></ion-icon>
45223
+ <span>{{ props.successMessage || i18n.t('feedbackSuccess') }}</span>
45224
+ </div>
45246
45225
  }
45247
- <ion-button type="submit" [disabled]="form.invalid || isSubmitting()" expand="block">
45248
- @if (isSubmitting()) {
45249
- <ion-spinner name="circular"></ion-spinner>
45250
- } @else {
45251
- {{ props.submitButtonText || i18n.t('submit') }}
45252
- }
45226
+ </val-form>
45227
+
45228
+ @if (props.cancelButtonText) {
45229
+ <ion-button fill="outline" color="medium" expand="block" class="cancel-button" (click)="onCancelClick()">
45230
+ {{ props.cancelButtonText }}
45253
45231
  </ion-button>
45254
- </div>
45255
- </form>
45256
- `, styles: [".feedback-form{display:flex;flex-direction:column;gap:8px;&.compact{gap:4px}}.form-actions{display:flex;gap:8px;margin-top:16px;justify-content:flex-end;ion-button{flex:1}}.feedback-alert{display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:8px;margin-top:8px;&.error{background-color:var(--ion-color-danger-tint);color:var(--ion-color-danger-shade)}&.success{background-color:var(--ion-color-success-tint);color:var(--ion-color-success-shade)}ion-icon{font-size:20px}}ion-note{font-size:12px;margin-top:-4px;margin-bottom:8px}\n"] }]
45232
+ }
45233
+ </div>
45234
+ `, styles: [".feedback-form-wrapper{display:flex;flex-direction:column;gap:8px;&.compact{gap:4px}}.feedback-alert{display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:8px;margin-top:8px;&.error{background-color:var(--ion-color-danger-tint);color:var(--ion-color-danger-shade)}&.success{background-color:var(--ion-color-success-tint);color:var(--ion-color-success-shade)}ion-icon{font-size:20px}}.cancel-button{margin-top:8px}\n"] }]
45257
45235
  }], ctorParameters: () => [], propDecorators: { props: [{
45258
45236
  type: Input
45259
45237
  }], onSubmit: [{
@@ -48234,5 +48212,5 @@ function buildFooterLinks(links, t, resolver) {
48234
48212
  * Generated bundle index. Do not edit.
48235
48213
  */
48236
48214
 
48237
- export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, ARTICLE_SPACING, AVATAR_UPLOAD_DEFAULTS, AccordionComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AppConfigService, AppVersionService, ArticleBuilder, ArticleComponent, AuthBackgroundComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BaseDefault, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangePasswordModalComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentLoaderComponent, ContentReactionComponent, ContentTransformer, CookieBannerComponent, CountdownComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DataTableComponent, DateInputComponent, DateRangeInputComponent, DebugConsoleComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EmailInputComponent, EmptyStateComponent, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FooterComponent, FooterLinksComponent, FormComponent, FormFooterComponent, FormSkeletonComponent, FunHeaderComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, I18nService, IMAGE_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoComponent, InputI18nHelper, InputType, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGIN_DEFAULTS, LanguageSelectorComponent, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadingDirective, LocalStorageService, LocaleService, LoginComponent, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MenuComponent, MessagingService, MetaService, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NavigationService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationsService, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OrgSwitchService, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PATTERN_MOTIFS, PATTERN_PALETTES, PLATFORM_CONFIGS, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PresetService, PriceTagComponent, PrimarySolidBlockButton, PrimarySolidBlockHrefButton, PrimarySolidBlockIconButton, PrimarySolidBlockIconHrefButton, PrimarySolidDefaultRoundButton, PrimarySolidDefaultRoundHrefButton, PrimarySolidDefaultRoundIconButton, PrimarySolidDefaultRoundIconHrefButton, PrimarySolidFullButton, PrimarySolidFullHrefButton, PrimarySolidFullIconButton, PrimarySolidFullIconHrefButton, PrimarySolidLargeRoundButton, PrimarySolidLargeRoundHrefButton, PrimarySolidLargeRoundIconButton, PrimarySolidLargeRoundIconHrefButton, PrimarySolidSmallRoundButton, PrimarySolidSmallRoundHrefButton, PrimarySolidSmallRoundIconButton, PrimarySolidSmallRoundIconHrefButton, ProcessLinksPipe, ProfileSkeletonComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QueryBuilder, QuoteBoxComponent, RadioInputComponent, RangeInputComponent, RatingComponent, RefresherComponent, RightsFooterComponent, RotatingTextComponent, SHAPE_KEYS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, SearchSelectorComponent, SearchbarComponent, SecondarySolidBlockButton, SecondarySolidBlockHrefButton, SecondarySolidBlockIconButton, SecondarySolidBlockIconHrefButton, SecondarySolidDefaultRoundButton, SecondarySolidDefaultRoundHrefButton, SecondarySolidDefaultRoundIconButton, SecondarySolidDefaultRoundIconHrefButton, SecondarySolidFullButton, SecondarySolidFullHrefButton, SecondarySolidFullIconButton, SecondarySolidFullIconHrefButton, SecondarySolidLargeRoundButton, SecondarySolidLargeRoundHrefButton, SecondarySolidLargeRoundIconButton, SecondarySolidLargeRoundIconHrefButton, SecondarySolidSmallRoundButton, SecondarySolidSmallRoundHrefButton, SecondarySolidSmallRoundIconButton, SecondarySolidSmallRoundIconHrefButton, SegmentControlComponent, SelectSearchComponent, SessionService, ShareButtonsComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TranslatePipe, TypedCollection, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_SOCIAL_LINKS, VERSION, ValtechErrorService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, blogPost, buildFooterLinks, buildPath, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, guestGuard, hasEmulators, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideValtechAds, provideValtechAppConfig, provideValtechAppVersion, provideValtechAuth, provideValtechAuthInterceptor, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechPresets, provideValtechSkeleton, query, renderPatternSvgInner, replaceSpecialChars, resolveColor, resolveInputDefaultValue, roleGuard, storagePaths, superAdminGuard, toArticle };
48215
+ export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, ARTICLE_SPACING, AVATAR_UPLOAD_DEFAULTS, AccordionComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AppConfigService, AppVersionService, ArticleBuilder, ArticleComponent, AttachmentUploaderComponent, AuthBackgroundComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BaseDefault, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangePasswordModalComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentLoaderComponent, ContentReactionComponent, ContentTransformer, CookieBannerComponent, CountdownComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DataTableComponent, DateInputComponent, DateRangeInputComponent, DebugConsoleComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EmailInputComponent, EmptyStateComponent, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FooterComponent, FooterLinksComponent, FormComponent, FormFooterComponent, FormSkeletonComponent, FunHeaderComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, I18nService, IMAGE_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoComponent, InputI18nHelper, InputType, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGIN_DEFAULTS, LanguageSelectorComponent, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadingDirective, LocalStorageService, LocaleService, LoginComponent, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MenuComponent, MessagingService, MetaService, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NavigationService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationsService, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OrgSwitchService, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PATTERN_MOTIFS, PATTERN_PALETTES, PLATFORM_CONFIGS, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PresetService, PriceTagComponent, PrimarySolidBlockButton, PrimarySolidBlockHrefButton, PrimarySolidBlockIconButton, PrimarySolidBlockIconHrefButton, PrimarySolidDefaultRoundButton, PrimarySolidDefaultRoundHrefButton, PrimarySolidDefaultRoundIconButton, PrimarySolidDefaultRoundIconHrefButton, PrimarySolidFullButton, PrimarySolidFullHrefButton, PrimarySolidFullIconButton, PrimarySolidFullIconHrefButton, PrimarySolidLargeRoundButton, PrimarySolidLargeRoundHrefButton, PrimarySolidLargeRoundIconButton, PrimarySolidLargeRoundIconHrefButton, PrimarySolidSmallRoundButton, PrimarySolidSmallRoundHrefButton, PrimarySolidSmallRoundIconButton, PrimarySolidSmallRoundIconHrefButton, ProcessLinksPipe, ProfileSkeletonComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QueryBuilder, QuoteBoxComponent, RadioInputComponent, RangeInputComponent, RatingComponent, RefresherComponent, RightsFooterComponent, RotatingTextComponent, SHAPE_KEYS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, SearchSelectorComponent, SearchbarComponent, SecondarySolidBlockButton, SecondarySolidBlockHrefButton, SecondarySolidBlockIconButton, SecondarySolidBlockIconHrefButton, SecondarySolidDefaultRoundButton, SecondarySolidDefaultRoundHrefButton, SecondarySolidDefaultRoundIconButton, SecondarySolidDefaultRoundIconHrefButton, SecondarySolidFullButton, SecondarySolidFullHrefButton, SecondarySolidFullIconButton, SecondarySolidFullIconHrefButton, SecondarySolidLargeRoundButton, SecondarySolidLargeRoundHrefButton, SecondarySolidLargeRoundIconButton, SecondarySolidLargeRoundIconHrefButton, SecondarySolidSmallRoundButton, SecondarySolidSmallRoundHrefButton, SecondarySolidSmallRoundIconButton, SecondarySolidSmallRoundIconHrefButton, SegmentControlComponent, SelectSearchComponent, SessionService, ShareButtonsComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TranslatePipe, TypedCollection, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_SOCIAL_LINKS, VERSION, ValtechErrorService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, blogPost, buildFooterLinks, buildPath, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, guestGuard, hasEmulators, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideValtechAds, provideValtechAppConfig, provideValtechAppVersion, provideValtechAuth, provideValtechAuthInterceptor, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechPresets, provideValtechSkeleton, query, renderPatternSvgInner, replaceSpecialChars, resolveColor, resolveInputDefaultValue, roleGuard, storagePaths, superAdminGuard, toArticle };
48238
48216
  //# sourceMappingURL=valtech-components.mjs.map