valtech-components 2.0.857 → 2.0.859

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 (30) 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 +39 -4
  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 +929 -934
  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 +9 -1
  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
  30. package/src/lib/services/firebase/firebase-messaging-sw.js +145 -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.857';
56
+ const VERSION = '2.0.859';
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>
@@ -31276,6 +31862,12 @@ class MfaModalComponent {
31276
31862
  this._isOpen = false;
31277
31863
  /** Emite cuando el estado MFA cambia (habilitado / deshabilitado). */
31278
31864
  this.changed = new EventEmitter();
31865
+ /**
31866
+ * Emitido cuando un setup MFA se confirma exitosamente y el modal fue abierto
31867
+ * vía deep-link (con `prefillCode`). El host puede usarlo para cerrar el
31868
+ * modal y navegar (ej. a home) en lugar de quedarse en la página de Security.
31869
+ */
31870
+ this.enabledViaDeeplink = new EventEmitter();
31279
31871
  /** Emite cuando el user cierra el modal (botón X o backdrop). */
31280
31872
  this.dismissed = new EventEmitter();
31281
31873
  this.auth = inject(AuthService);
@@ -31304,6 +31896,7 @@ class MfaModalComponent {
31304
31896
  /** Marca momentánea cuando el secreto TOTP se acaba de copiar (feedback visual). */
31305
31897
  this.copiedSecret = signal(false);
31306
31898
  this.resendCooldown = signal(0);
31899
+ this.disableCodeSent = signal(false);
31307
31900
  this.pinControl = new FormControl('', [Validators.required, Validators.minLength(6), Validators.maxLength(6)]);
31308
31901
  this.phoneControl = new FormControl('', [Validators.required, Validators.pattern(/^\+[1-9]\d{6,14}$/)]);
31309
31902
  this.pinInputProps = {
@@ -31419,6 +32012,7 @@ class MfaModalComponent {
31419
32012
  }
31420
32013
  backToStatus() {
31421
32014
  this.stopCooldown();
32015
+ this.disableCodeSent.set(false);
31422
32016
  this.resolveStatus();
31423
32017
  }
31424
32018
  // ===========================================================================
@@ -31506,13 +32100,22 @@ class MfaModalComponent {
31506
32100
  return;
31507
32101
  }
31508
32102
  this.working.set(true);
32103
+ const cameFromDeeplink = !!this.prefillCode;
31509
32104
  this.auth.confirmMFA(code).subscribe({
31510
32105
  next: res => {
31511
32106
  this.working.set(false);
31512
32107
  if (res.mfaEnabled) {
31513
32108
  this.showToast(this.t('mfaEnabledOk'));
31514
32109
  this.changed.emit();
31515
- this.resolveStatus();
32110
+ if (cameFromDeeplink) {
32111
+ // El user llegó vía link del email → el host se encarga de cerrar
32112
+ // el modal y navegar (ej. a home). No resolvemos status acá porque
32113
+ // implicaría dejar el modal abierto mostrando el estado nuevo.
32114
+ this.enabledViaDeeplink.emit();
32115
+ }
32116
+ else {
32117
+ this.resolveStatus();
32118
+ }
31516
32119
  }
31517
32120
  },
31518
32121
  error: err => {
@@ -31564,6 +32167,20 @@ class MfaModalComponent {
31564
32167
  }
31565
32168
  this.disable({ mfaCode: code });
31566
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
+ }
31567
32184
  disable(input) {
31568
32185
  this.working.set(true);
31569
32186
  this.auth.disableMFA(input).subscribe({
@@ -31675,7 +32292,7 @@ class MfaModalComponent {
31675
32292
  this.toast.show({ message, duration: 3500 });
31676
32293
  }
31677
32294
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MfaModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
31678
- 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", 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"] }] }); }
31679
32296
  }
31680
32297
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MfaModalComponent, decorators: [{
31681
32298
  type: Component,
@@ -31697,15 +32314,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
31697
32314
  DisplayComponent,
31698
32315
  FormComponent,
31699
32316
  QrCodeComponent,
32317
+ TextComponent,
31700
32318
  TitleComponent,
31701
32319
  PinInputComponent,
31702
- ], 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"] }]
31703
32321
  }], ctorParameters: () => [], propDecorators: { isOpen: [{
31704
32322
  type: Input
31705
32323
  }], prefillCode: [{
31706
32324
  type: Input
31707
32325
  }], changed: [{
31708
32326
  type: Output
32327
+ }], enabledViaDeeplink: [{
32328
+ type: Output
31709
32329
  }], dismissed: [{
31710
32330
  type: Output
31711
32331
  }] } });
@@ -32301,7 +32921,7 @@ class WizardComponent {
32301
32921
  this.onClick = new EventEmitter();
32302
32922
  this.wrapperId = 'wizard-wrapper';
32303
32923
  this.currentStep = null;
32304
- this.currentStepTitles = null;
32924
+ this.currentStepTitles = { title: '' };
32305
32925
  this.loadingText = 'Por favor espere...';
32306
32926
  this.cdr = inject(ChangeDetectorRef);
32307
32927
  }
@@ -32311,23 +32931,12 @@ class WizardComponent {
32311
32931
  ngOnChanges(changes) {
32312
32932
  if (changes['props']) {
32313
32933
  this.updateCurrentStep();
32314
- this.cdr.detectChanges();
32315
32934
  }
32316
32935
  }
32317
32936
  updateCurrentStep() {
32318
32937
  if (this.props?.steps && this.props?.current) {
32319
32938
  this.currentStep = this.props.steps[this.props.current];
32320
- // Forzar nueva referencia de objeto para asegurar detección de cambios
32321
- // Agregar timestamp para garantizar que es un objeto completamente nuevo
32322
- this.currentStepTitles = this.currentStep?.titles
32323
- ? {
32324
- ...JSON.parse(JSON.stringify(this.currentStep.titles)),
32325
- _timestamp: Date.now(), // Forzar nueva referencia
32326
- _step: this.props.current, // Agregar identificador del paso
32327
- }
32328
- : null;
32329
- // Forzar detección de cambios inmediatamente
32330
- this.cdr.detectChanges();
32939
+ this.currentStepTitles = this.currentStep?.titles ?? { title: '' };
32331
32940
  }
32332
32941
  }
32333
32942
  working() {
@@ -32355,7 +32964,7 @@ class WizardComponent {
32355
32964
  if (this.props?.steps && this.props?.current) {
32356
32965
  return this.props.steps[this.props.current];
32357
32966
  }
32358
- return this.currentStep || { titles: null, buttons: [] };
32967
+ return this.currentStep || { titles: { title: '' }, buttons: [] };
32359
32968
  }
32360
32969
  setCurrent(newStep) {
32361
32970
  if (newStep === this.props.current) {
@@ -32363,24 +32972,19 @@ class WizardComponent {
32363
32972
  }
32364
32973
  this.props.current = newStep;
32365
32974
  this.updateCurrentStep();
32366
- // Forzar múltiples ciclos de detección de cambios
32367
- this.cdr.detectChanges();
32368
- setTimeout(() => {
32369
- this.cdr.detectChanges();
32370
- }, 0);
32371
32975
  goToTop(this.wrapperId);
32372
32976
  }
32373
32977
  setError(error) {
32374
32978
  if (this.props.state === ComponentStates.ERROR) {
32375
32979
  return;
32376
32980
  }
32377
- this.props.error.titles.bottomContent.content.bellowTitle.content = error;
32981
+ this.props.error.titles.description = error;
32378
32982
  this.props.state = ComponentStates.ERROR;
32379
32983
  this.cdr.markForCheck();
32380
32984
  goToTop(this.wrapperId);
32381
32985
  }
32382
32986
  reset() {
32383
- this.props.error.titles.bottomContent.content.bellowTitle.content = '';
32987
+ this.props.error.titles.description = '';
32384
32988
  this.done();
32385
32989
  }
32386
32990
  clickHandler(token) {
@@ -32396,7 +33000,7 @@ class WizardComponent {
32396
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: `
32397
33001
  <div [id]="wrapperId" class="wrapper">
32398
33002
  <ng-container *ngIf="props.state !== 'ERROR'">
32399
- <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>
32400
33004
  <div class="step">
32401
33005
  <div *ngIf="props.state === 'WORKING'">
32402
33006
  <val-content-loader
@@ -32412,17 +33016,17 @@ class WizardComponent {
32412
33016
  </div>
32413
33017
  </ng-container>
32414
33018
  <ng-container *ngIf="props.state === 'ERROR'">
32415
- <val-no-content [props]="props.error.titles" (onClick)="clickHandler($event)"></val-no-content>
33019
+ <val-empty-state [props]="props.error.titles"></val-empty-state>
32416
33020
  </ng-container>
32417
33021
  </div>
32418
- `, 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"] }] }); }
32419
33023
  }
32420
33024
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: WizardComponent, decorators: [{
32421
33025
  type: Component,
32422
- args: [{ selector: 'val-wizard', standalone: true, imports: [CommonModule, NoContentComponent, ContentLoaderComponent], template: `
33026
+ args: [{ selector: 'val-wizard', standalone: true, imports: [CommonModule, EmptyStateComponent, ContentLoaderComponent], template: `
32423
33027
  <div [id]="wrapperId" class="wrapper">
32424
33028
  <ng-container *ngIf="props.state !== 'ERROR'">
32425
- <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>
32426
33030
  <div class="step">
32427
33031
  <div *ngIf="props.state === 'WORKING'">
32428
33032
  <val-content-loader
@@ -32438,7 +33042,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
32438
33042
  </div>
32439
33043
  </ng-container>
32440
33044
  <ng-container *ngIf="props.state === 'ERROR'">
32441
- <val-no-content [props]="props.error.titles" (onClick)="clickHandler($event)"></val-no-content>
33045
+ <val-empty-state [props]="props.error.titles"></val-empty-state>
32442
33046
  </ng-container>
32443
33047
  </div>
32444
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"] }]
@@ -44282,498 +44886,6 @@ function news() {
44282
44886
  */
44283
44887
  // Transformer
44284
44888
 
44285
- /**
44286
- * Token de inyección para la configuración de Feedback.
44287
- */
44288
- const VALTECH_FEEDBACK_CONFIG = new InjectionToken('ValtechFeedbackConfig');
44289
- /**
44290
- * Configuración por defecto.
44291
- */
44292
- const DEFAULT_FEEDBACK_CONFIG = {
44293
- feedbackPrefix: '/v1/feedback',
44294
- maxAttachments: 5,
44295
- // Estándar acordado para adjuntos de feedback: solo imágenes (JPEG/PNG/WebP)
44296
- // y PDF, máx 5 MB. Reflejado en `storage.rules` (path `users/{uid}/feedback/`).
44297
- maxFileSize: 5 * 1024 * 1024,
44298
- allowedFileTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
44299
- storagePath: 'feedback',
44300
- };
44301
- /**
44302
- * Provee el servicio de feedback a la aplicación Angular.
44303
- *
44304
- * @param config - Configuración de feedback
44305
- * @returns EnvironmentProviders para usar en bootstrapApplication
44306
- *
44307
- * @example
44308
- * ```typescript
44309
- * // main.ts
44310
- * import { bootstrapApplication } from '@angular/platform-browser';
44311
- * import { provideValtechFeedback } from 'valtech-components';
44312
- * import { environment } from './environments/environment';
44313
- *
44314
- * bootstrapApplication(AppComponent, {
44315
- * providers: [
44316
- * provideValtechAuth({ apiUrl: environment.apiUrl }),
44317
- * provideValtechFeedback({
44318
- * apiUrl: environment.apiUrl,
44319
- * appId: 'my-app-name',
44320
- * }),
44321
- * ],
44322
- * });
44323
- * ```
44324
- */
44325
- function provideValtechFeedback(config) {
44326
- const mergedConfig = {
44327
- ...DEFAULT_FEEDBACK_CONFIG,
44328
- ...config,
44329
- };
44330
- return makeEnvironmentProviders([{ provide: VALTECH_FEEDBACK_CONFIG, useValue: mergedConfig }]);
44331
- }
44332
-
44333
- /**
44334
- * Servicio para gestionar feedback de usuarios.
44335
- *
44336
- * @example
44337
- * ```typescript
44338
- * @Component({...})
44339
- * export class MyComponent {
44340
- * private feedbackService = inject(FeedbackService);
44341
- *
44342
- * async submitFeedback() {
44343
- * const response = await this.feedbackService.createAsync(
44344
- * 'feedback',
44345
- * 'Mi comentario',
44346
- * 'Descripción detallada...'
44347
- * );
44348
- * console.log('Feedback enviado:', response.feedbackId);
44349
- * }
44350
- * }
44351
- * ```
44352
- */
44353
- class FeedbackService {
44354
- constructor() {
44355
- this.config = inject(VALTECH_FEEDBACK_CONFIG);
44356
- this.http = inject(HttpClient);
44357
- this.firestore = inject(FirestoreService, { optional: true });
44358
- this.storage = inject(StorageService, { optional: true });
44359
- this.auth = inject(AuthService, { optional: true });
44360
- }
44361
- /**
44362
- * URL base para endpoints de feedback.
44363
- */
44364
- get baseUrl() {
44365
- return `${this.config.apiUrl}${this.config.feedbackPrefix}`;
44366
- }
44367
- /**
44368
- * Captura el contexto del dispositivo automáticamente.
44369
- */
44370
- captureDeviceContext() {
44371
- const ua = navigator.userAgent;
44372
- return {
44373
- browser: this.detectBrowser(ua),
44374
- os: this.detectOS(ua),
44375
- viewport: `${window.innerWidth}x${window.innerHeight}`,
44376
- language: navigator.language,
44377
- userAgent: ua,
44378
- pageUrl: window.location.href,
44379
- };
44380
- }
44381
- /**
44382
- * Crea un nuevo feedback.
44383
- *
44384
- * @param type - Tipo de feedback
44385
- * @param title - Título del feedback
44386
- * @param description - Descripción detallada
44387
- * @param attachments - URLs de archivos adjuntos (opcional)
44388
- * @param contentRef - Referencia a contenido específico (opcional)
44389
- * @returns Observable con la respuesta
44390
- */
44391
- create(type, title, description, attachments = [], contentRef) {
44392
- const request = {
44393
- type,
44394
- title,
44395
- description,
44396
- attachments,
44397
- contentRef,
44398
- deviceContext: this.captureDeviceContext(),
44399
- appId: this.config.appId,
44400
- };
44401
- return this.http.post(this.baseUrl, request);
44402
- }
44403
- /**
44404
- * Crea un nuevo feedback (versión async/await).
44405
- */
44406
- async createAsync(type, title, description, attachments = [], contentRef) {
44407
- return firstValueFrom(this.create(type, title, description, attachments, contentRef));
44408
- }
44409
- /**
44410
- * Obtiene un feedback por ID (solo el propietario).
44411
- *
44412
- * @param feedbackId - ID del feedback
44413
- * @returns Observable con la respuesta
44414
- */
44415
- getById(feedbackId) {
44416
- return this.http.get(`${this.baseUrl}/${feedbackId}`);
44417
- }
44418
- /**
44419
- * Obtiene un feedback por ID (versión async/await).
44420
- */
44421
- async getByIdAsync(feedbackId) {
44422
- return firstValueFrom(this.getById(feedbackId));
44423
- }
44424
- /**
44425
- * Valida si un archivo cumple con las restricciones.
44426
- */
44427
- validateFile(file) {
44428
- // Verificar tamaño
44429
- if (file.size > this.config.maxFileSize) {
44430
- const maxSizeMB = Math.round(this.config.maxFileSize / (1024 * 1024));
44431
- return {
44432
- valid: false,
44433
- error: `El archivo excede el tamaño máximo de ${maxSizeMB}MB`,
44434
- };
44435
- }
44436
- // Verificar tipo
44437
- const allowedTypes = this.config.allowedFileTypes || [];
44438
- const isAllowed = allowedTypes.some(pattern => {
44439
- if (pattern.endsWith('/*')) {
44440
- const baseType = pattern.replace('/*', '');
44441
- return file.type.startsWith(baseType);
44442
- }
44443
- return file.type === pattern;
44444
- });
44445
- if (!isAllowed) {
44446
- return {
44447
- valid: false,
44448
- error: 'Tipo de archivo no permitido',
44449
- };
44450
- }
44451
- return { valid: true };
44452
- }
44453
- /**
44454
- * Valida el CONTENIDO del archivo por magic-bytes (firma binaria) —
44455
- * defensa contra spoofing del content-type declarado. Lee los primeros 12
44456
- * bytes y los compara con las firmas de JPEG/PNG/WebP/PDF (whitelist
44457
- * estricta). Es client-side, ergo bypasseable; la defensa real vive en las
44458
- * Storage rules. Esto corta el 99% de los casos accidentales o no-targeted.
44459
- */
44460
- async validateFileContent(file) {
44461
- const buf = await file.slice(0, 12).arrayBuffer();
44462
- const b = new Uint8Array(buf);
44463
- // JPEG: FF D8 FF
44464
- if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
44465
- return { valid: true };
44466
- }
44467
- // PNG: 89 50 4E 47 0D 0A 1A 0A
44468
- if (b[0] === 0x89 &&
44469
- b[1] === 0x50 &&
44470
- b[2] === 0x4e &&
44471
- b[3] === 0x47 &&
44472
- b[4] === 0x0d &&
44473
- b[5] === 0x0a &&
44474
- b[6] === 0x1a &&
44475
- b[7] === 0x0a) {
44476
- return { valid: true };
44477
- }
44478
- // WebP: "RIFF" (0..3) + "WEBP" (8..11)
44479
- if (b[0] === 0x52 &&
44480
- b[1] === 0x49 &&
44481
- b[2] === 0x46 &&
44482
- b[3] === 0x46 &&
44483
- b[8] === 0x57 &&
44484
- b[9] === 0x45 &&
44485
- b[10] === 0x42 &&
44486
- b[11] === 0x50) {
44487
- return { valid: true };
44488
- }
44489
- // PDF: "%PDF-"
44490
- if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) {
44491
- return { valid: true };
44492
- }
44493
- return {
44494
- valid: false,
44495
- error: 'El contenido del archivo no coincide con un tipo permitido (imagen o PDF).',
44496
- };
44497
- }
44498
- /**
44499
- * Sube un adjunto a Firebase Storage en `users/{uid}/feedback/{uuid}/{name}`
44500
- * y devuelve su download URL. Ejecuta tres validaciones en orden:
44501
- * 1. tamaño + tipo declarado (`validateFile`)
44502
- * 2. contenido por magic-bytes (`validateFileContent`)
44503
- * 3. usuario autenticado (no soportamos adjuntos en feedback anónimo)
44504
- *
44505
- * Si cualquier validación falla → rechaza la promesa con un Error legible;
44506
- * el componente que la consuma debe cancelar la operación de adjuntar.
44507
- */
44508
- async uploadAttachment(file) {
44509
- const sizeTypeCheck = this.validateFile(file);
44510
- if (!sizeTypeCheck.valid) {
44511
- throw new Error(sizeTypeCheck.error);
44512
- }
44513
- const contentCheck = await this.validateFileContent(file);
44514
- if (!contentCheck.valid) {
44515
- throw new Error(contentCheck.error);
44516
- }
44517
- const userId = this.auth?.user()?.userId;
44518
- if (!userId) {
44519
- throw new Error('Debes iniciar sesión para adjuntar archivos.');
44520
- }
44521
- if (!this.storage) {
44522
- throw new Error('StorageService no está configurado.');
44523
- }
44524
- // Path bajo `users/{uid}/feedback/` — sibling de `files/`, con su propia
44525
- // regla en `storage.rules` (whitelist estricta JPEG/PNG/WebP/PDF + 5MB).
44526
- // skipPrefix=true porque `users/{uid}/` es path GLOBAL cross-app.
44527
- const uuid = typeof crypto !== 'undefined' && 'randomUUID' in crypto
44528
- ? crypto.randomUUID()
44529
- : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
44530
- const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
44531
- const path = `users/${userId}/feedback/${uuid}/${safeName}`;
44532
- const result = await this.storage.uploadAndGetUrl(path, file, {
44533
- contentType: file.type,
44534
- skipPrefix: true,
44535
- });
44536
- return result.downloadUrl;
44537
- }
44538
- /**
44539
- * Obtiene la configuración actual del servicio.
44540
- */
44541
- getConfig() {
44542
- return this.config;
44543
- }
44544
- // =========================================================================
44545
- // Reaction Methods (Content feedback with emojis)
44546
- // =========================================================================
44547
- /**
44548
- * Verifica si el usuario ya dio feedback para una entidad específica.
44549
- *
44550
- * Primero intenta leer de Firebase (rápido, sin latencia de red al backend).
44551
- * Si Firebase no está disponible o falla, hace fallback a la API.
44552
- *
44553
- * @param entityType - Tipo de entidad (article, docs, feature, etc.)
44554
- * @param entityId - ID de la entidad
44555
- * @returns Promise con la respuesta de verificación
44556
- *
44557
- * @example
44558
- * ```typescript
44559
- * const check = await this.feedbackService.checkFeedback('article', 'art-123');
44560
- * if (check.hasFeedback) {
44561
- * console.log('Ya dio feedback:', check.reactionValue);
44562
- * }
44563
- * ```
44564
- */
44565
- async checkFeedback(entityType, entityId) {
44566
- // Si no hay usuario autenticado, no puede haber feedback previo
44567
- // Retornar inmediatamente sin llamar al API (evita 401 y redirect a login)
44568
- const userId = this.auth?.user()?.userId;
44569
- if (!userId) {
44570
- return { operationId: '', hasFeedback: false };
44571
- }
44572
- // 1. Intentar Firebase primero (si está disponible)
44573
- if (this.firestore) {
44574
- try {
44575
- // Path: feedback/{entityType}/{entityId}/{userId}
44576
- // FirestoreService agrega automáticamente el prefijo apps/{appId}/
44577
- const collectionPath = `feedback/${entityType}/${entityId}`;
44578
- const doc = await this.firestore.getDoc(collectionPath, userId);
44579
- if (doc) {
44580
- return {
44581
- operationId: '',
44582
- hasFeedback: true,
44583
- feedbackId: doc.feedbackId,
44584
- type: doc.type,
44585
- reactionValue: doc.reactionValue,
44586
- createdAt: doc.createdAt?.toISOString(),
44587
- };
44588
- }
44589
- // Doc no existe = no hay feedback
44590
- return { operationId: '', hasFeedback: false };
44591
- }
44592
- catch (error) {
44593
- console.warn('[FeedbackService] Firebase check failed, falling back to API:', error);
44594
- // Fallback a API
44595
- }
44596
- }
44597
- // 2. Fallback: llamar API (solo si hay usuario autenticado)
44598
- const params = new URLSearchParams({
44599
- appId: this.config.appId,
44600
- entityType,
44601
- entityId,
44602
- });
44603
- return firstValueFrom(this.http.get(`${this.baseUrl}/check?${params}`));
44604
- }
44605
- /**
44606
- * Crea o actualiza una reacción (feedback con emoji).
44607
- *
44608
- * @param entityRef - Referencia a la entidad
44609
- * @param value - Valor de la reacción (negative, neutral, positive)
44610
- * @param comment - Comentario opcional (máx 500 caracteres)
44611
- * @returns Promise con la respuesta
44612
- *
44613
- * @example
44614
- * ```typescript
44615
- * const response = await this.feedbackService.createReaction(
44616
- * { entityType: 'article', entityId: 'art-123' },
44617
- * 'positive',
44618
- * 'Muy útil!'
44619
- * );
44620
- * ```
44621
- */
44622
- async createReaction(entityRef, value, comment) {
44623
- const request = {
44624
- type: 'reaction',
44625
- entityRef,
44626
- reactionValue: value,
44627
- description: comment || '',
44628
- deviceContext: this.captureDeviceContext(),
44629
- appId: this.config.appId,
44630
- };
44631
- return firstValueFrom(this.http.post(this.baseUrl, request));
44632
- }
44633
- /**
44634
- * Crea feedback anónimo (sin autenticación requerida).
44635
- * Usado para blogs, FAQs y contenido público.
44636
- *
44637
- * @param entityRef - Referencia a la entidad
44638
- * @param value - Valor de la reacción (negative, neutral, positive)
44639
- * @param comment - Comentario opcional (máx 500 caracteres)
44640
- * @returns Promise con la respuesta
44641
- *
44642
- * @example
44643
- * ```typescript
44644
- * // En un blog público
44645
- * const response = await this.feedbackService.createAnonymousReaction(
44646
- * { entityType: 'blog', entityId: 'my-post-slug' },
44647
- * 'positive'
44648
- * );
44649
- * ```
44650
- */
44651
- async createAnonymousReaction(entityRef, value, comment) {
44652
- const request = {
44653
- type: 'reaction',
44654
- entityRef,
44655
- reactionValue: value,
44656
- description: comment || '',
44657
- deviceContext: this.captureDeviceContext(),
44658
- appId: this.config.appId,
44659
- };
44660
- return firstValueFrom(this.http.post(`${this.baseUrl}/anonymous`, request));
44661
- }
44662
- // =========================================================================
44663
- // Helpers privados para detección de browser/OS
44664
- // =========================================================================
44665
- detectBrowser(ua) {
44666
- if (ua.includes('Edg/'))
44667
- return 'Edge';
44668
- if (ua.includes('Chrome/'))
44669
- return 'Chrome';
44670
- if (ua.includes('Firefox/'))
44671
- return 'Firefox';
44672
- if (ua.includes('Safari/') && !ua.includes('Chrome'))
44673
- return 'Safari';
44674
- if (ua.includes('Opera') || ua.includes('OPR/'))
44675
- return 'Opera';
44676
- return 'Unknown';
44677
- }
44678
- detectOS(ua) {
44679
- if (ua.includes('Windows NT 10'))
44680
- return 'Windows 10';
44681
- if (ua.includes('Windows NT 11'))
44682
- return 'Windows 11';
44683
- if (ua.includes('Windows'))
44684
- return 'Windows';
44685
- if (ua.includes('Mac OS X')) {
44686
- const match = ua.match(/Mac OS X (\d+[._]\d+)/);
44687
- if (match) {
44688
- return `macOS ${match[1].replace('_', '.')}`;
44689
- }
44690
- return 'macOS';
44691
- }
44692
- if (ua.includes('Android'))
44693
- return 'Android';
44694
- if (ua.includes('iPhone') || ua.includes('iPad'))
44695
- return 'iOS';
44696
- if (ua.includes('Linux'))
44697
- return 'Linux';
44698
- return 'Unknown';
44699
- }
44700
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
44701
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, providedIn: 'root' }); }
44702
- }
44703
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackService, decorators: [{
44704
- type: Injectable,
44705
- args: [{ providedIn: 'root' }]
44706
- }] });
44707
-
44708
- /**
44709
- * Configuración por defecto de tipos de feedback.
44710
- */
44711
- const DEFAULT_FEEDBACK_TYPE_OPTIONS = [
44712
- {
44713
- value: 'issue',
44714
- label: 'Reportar problema',
44715
- description: 'Algo no funciona correctamente',
44716
- icon: 'bug-outline',
44717
- },
44718
- {
44719
- value: 'poor-content',
44720
- label: 'Contenido incorrecto',
44721
- description: 'Información incorrecta o desactualizada',
44722
- icon: 'document-text-outline',
44723
- },
44724
- {
44725
- value: 'feedback',
44726
- label: 'Comentario general',
44727
- description: 'Tu opinión o experiencia',
44728
- icon: 'chatbubble-outline',
44729
- },
44730
- {
44731
- value: 'suggestion',
44732
- label: 'Sugerencia',
44733
- description: 'Propuesta de mejora o nueva funcionalidad',
44734
- icon: 'bulb-outline',
44735
- },
44736
- ];
44737
-
44738
- /**
44739
- * Valtech Feedback Service
44740
- *
44741
- * Servicio para gestionar feedback de usuarios a nivel de plataforma.
44742
- *
44743
- * @example
44744
- * ```typescript
44745
- * // main.ts - Configuración
44746
- * import { provideValtechFeedback } from 'valtech-components';
44747
- *
44748
- * bootstrapApplication(AppComponent, {
44749
- * providers: [
44750
- * provideValtechAuth({ apiUrl: environment.apiUrl }),
44751
- * provideValtechFeedback({
44752
- * apiUrl: environment.apiUrl,
44753
- * appId: 'my-app-name',
44754
- * }),
44755
- * ],
44756
- * });
44757
- *
44758
- * // component.ts - Uso
44759
- * import { FeedbackService } from 'valtech-components';
44760
- *
44761
- * @Component({...})
44762
- * export class MyComponent {
44763
- * private feedbackService = inject(FeedbackService);
44764
- *
44765
- * async submitFeedback() {
44766
- * const response = await this.feedbackService.createAsync(
44767
- * 'feedback',
44768
- * 'Título',
44769
- * 'Descripción...'
44770
- * );
44771
- * }
44772
- * }
44773
- * ```
44774
- */
44775
- // Configuration
44776
-
44777
44889
  /**
44778
44890
  * Token de inyección para la configuración de Donation/Support.
44779
44891
  */
@@ -44926,317 +45038,200 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
44926
45038
  * ```
44927
45039
  */
44928
45040
 
44929
- /**
44930
- * val-feedback-form
44931
- *
44932
- * Formulario reutilizable para enviar feedback desde cualquier parte de la aplicación.
44933
- *
44934
- * @example
44935
- * ```html
44936
- * <!-- Feedback general -->
44937
- * <val-feedback-form
44938
- * [props]="{ defaultType: 'feedback', showTypeSelector: true }"
44939
- * (onSubmit)="handleSuccess($event)"
44940
- * (onCancel)="closeModal()"
44941
- * />
44942
- *
44943
- * <!-- Reportar contenido incorrecto -->
44944
- * <val-feedback-form
44945
- * [props]="{
44946
- * defaultType: 'poor-content',
44947
- * showTypeSelector: false,
44948
- * contentRef: { contentId: article.id, contentType: 'article' },
44949
- * submitButtonText: 'Reportar contenido'
44950
- * }"
44951
- * />
44952
- * ```
44953
- */
44954
45041
  class FeedbackFormComponent {
44955
45042
  constructor() {
44956
- /**
44957
- * Configuración del formulario.
44958
- */
44959
45043
  this.props = {};
44960
- /**
44961
- * Evento emitido cuando el feedback se envía exitosamente.
44962
- */
44963
45044
  this.onSubmit = new EventEmitter();
44964
- /**
44965
- * Evento emitido cuando el usuario cancela.
44966
- */
44967
45045
  this.onCancel = new EventEmitter();
44968
- this.fb = inject(FormBuilder);
44969
- this.feedbackService = inject(FeedbackService);
44970
45046
  this.i18n = inject(I18nService);
45047
+ this.feedbackService = inject(FeedbackService);
44971
45048
  this.typeOptions = DEFAULT_FEEDBACK_TYPE_OPTIONS;
44972
45049
  this.isSubmitting = signal(false);
44973
45050
  this.isSuccess = signal(false);
44974
45051
  this.error = signal(null);
44975
- addIcons({
44976
- bugOutline,
44977
- bulbOutline,
44978
- chatbubbleOutline,
44979
- checkmarkCircleOutline,
44980
- closeCircleOutline,
44981
- documentTextOutline,
44982
- });
45052
+ this.currentAttachments = [];
45053
+ addIcons({ checkmarkCircleOutline, closeCircleOutline });
44983
45054
  }
44984
45055
  ngOnInit() {
44985
- // Filtrar tipos habilitados si se especifica
44986
45056
  if (this.props.enabledTypes?.length) {
44987
45057
  this.typeOptions = this.typeOptions.filter(opt => this.props.enabledTypes.includes(opt.value));
44988
45058
  }
44989
- // Usar opciones personalizadas si se proporcionan
44990
45059
  if (this.props.typeOptions?.length) {
44991
45060
  this.typeOptions = this.props.typeOptions;
44992
45061
  }
44993
- // Inicializar formulario
44994
- this.form = this.fb.group({
44995
- type: [this.props.defaultType || 'feedback', Validators.required],
44996
- title: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(200)]],
44997
- 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,
44998
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
+ };
44999
45132
  }
45000
- async handleSubmit() {
45001
- 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'))
45002
45137
  return;
45003
45138
  this.isSubmitting.set(true);
45004
45139
  this.error.set(null);
45005
45140
  this.isSuccess.set(false);
45141
+ this.formProps.state = ComponentStates.WORKING;
45142
+ this.formProps.actions.state = ComponentStates.WORKING;
45006
45143
  try {
45007
- const { type, title, description } = this.form.value;
45008
- const response = await this.feedbackService.createAsync(type, title, description, [], // attachments (por ahora vacío)
45009
- 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);
45010
45149
  this.isSuccess.set(true);
45011
- this.form.reset({ type: this.props.defaultType || 'feedback' });
45012
- this.onSubmit.emit({
45013
- response,
45014
- type: type,
45015
- title,
45016
- });
45150
+ this.onSubmit.emit({ response, type, title, attachmentUrls });
45017
45151
  }
45018
45152
  catch (err) {
45019
45153
  this.error.set(err.error?.message || err.message || this.i18n.t('feedbackError'));
45020
45154
  }
45021
45155
  finally {
45022
45156
  this.isSubmitting.set(false);
45157
+ this.formProps.state = ComponentStates.ENABLED;
45158
+ this.formProps.actions.state = ComponentStates.ENABLED;
45023
45159
  }
45024
45160
  }
45161
+ onAttachmentsChange(items) {
45162
+ this.currentAttachments = items;
45163
+ }
45025
45164
  onCancelClick() {
45026
45165
  this.onCancel.emit();
45027
45166
  }
45028
45167
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
45029
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: `
45030
- <form
45031
- [formGroup]="form"
45032
- (ngSubmit)="handleSubmit()"
45033
- class="feedback-form"
45034
- [class.compact]="props.compact"
45035
- [ngClass]="props.cssClass"
45036
- >
45037
- <!-- Type selector -->
45038
- @if (props.showTypeSelector !== false) {
45039
- <ion-item>
45040
- <ion-select
45041
- formControlName="type"
45042
- [label]="i18n.t('feedbackType')"
45043
- labelPlacement="floating"
45044
- interface="popover"
45045
- >
45046
- @for (option of typeOptions; track option.value) {
45047
- <ion-select-option [value]="option.value">
45048
- {{ option.label }}
45049
- </ion-select-option>
45050
- }
45051
- </ion-select>
45052
- </ion-item>
45053
- }
45054
-
45055
- <!-- Title -->
45056
- <ion-item>
45057
- <ion-textarea
45058
- formControlName="title"
45059
- [label]="props.titleLabel || i18n.t('title')"
45060
- labelPlacement="floating"
45061
- [placeholder]="props.titlePlaceholder || i18n.t('titlePlaceholder')"
45062
- [maxlength]="200"
45063
- [counter]="true"
45064
- [autoGrow]="false"
45065
- rows="1"
45066
- ></ion-textarea>
45067
- </ion-item>
45068
- @if (form.get('title')?.invalid && form.get('title')?.touched) {
45069
- <ion-note color="danger" class="ion-padding-start">
45070
- {{ i18n.t('titleValidation') }}
45071
- </ion-note>
45072
- }
45073
-
45074
- <!-- Description -->
45075
- <ion-item>
45076
- <ion-textarea
45077
- formControlName="description"
45078
- [label]="props.descriptionLabel || i18n.t('description')"
45079
- labelPlacement="floating"
45080
- [placeholder]="props.descriptionPlaceholder || i18n.t('descriptionPlaceholder')"
45081
- [maxlength]="5000"
45082
- [counter]="true"
45083
- [autoGrow]="true"
45084
- rows="4"
45085
- ></ion-textarea>
45086
- </ion-item>
45087
- @if (form.get('description')?.invalid && form.get('description')?.touched) {
45088
- <ion-note color="danger" class="ion-padding-start">
45089
- {{ i18n.t('descriptionValidation') }}
45090
- </ion-note>
45091
- }
45092
-
45093
- <!-- Error message -->
45094
- @if (error()) {
45095
- <div class="feedback-alert error">
45096
- <ion-icon name="close-circle-outline"></ion-icon>
45097
- <span>{{ error() }}</span>
45098
- </div>
45099
- }
45100
-
45101
- <!-- Success message -->
45102
- @if (isSuccess()) {
45103
- <div class="feedback-alert success">
45104
- <ion-icon name="checkmark-circle-outline"></ion-icon>
45105
- <span>{{ props.successMessage || i18n.t('feedbackSuccess') }}</span>
45106
- </div>
45107
- }
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
+ }
45108
45184
 
45109
- <!-- Actions -->
45110
- <div class="form-actions">
45111
- @if (props.cancelButtonText) {
45112
- <ion-button fill="outline" color="medium" type="button" (click)="onCancelClick()">
45113
- {{ props.cancelButtonText }}
45114
- </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>
45115
45190
  }
45116
- <ion-button type="submit" [disabled]="form.invalid || isSubmitting()" expand="block">
45117
- @if (isSubmitting()) {
45118
- <ion-spinner name="circular"></ion-spinner>
45119
- } @else {
45120
- {{ props.submitButtonText || i18n.t('submit') }}
45121
- }
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 }}
45122
45196
  </ion-button>
45123
- </div>
45124
- </form>
45125
- `, 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"] }] }); }
45126
45200
  }
45127
45201
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FeedbackFormComponent, decorators: [{
45128
45202
  type: Component,
45129
- args: [{ selector: 'val-feedback-form', standalone: true, imports: [
45130
- CommonModule,
45131
- ReactiveFormsModule,
45132
- IonButton,
45133
- IonIcon,
45134
- IonItem,
45135
- IonLabel,
45136
- IonList,
45137
- IonNote,
45138
- IonSelect,
45139
- IonSelectOption,
45140
- IonSpinner,
45141
- IonText,
45142
- IonTextarea,
45143
- ], template: `
45144
- <form
45145
- [formGroup]="form"
45146
- (ngSubmit)="handleSubmit()"
45147
- class="feedback-form"
45148
- [class.compact]="props.compact"
45149
- [ngClass]="props.cssClass"
45150
- >
45151
- <!-- Type selector -->
45152
- @if (props.showTypeSelector !== false) {
45153
- <ion-item>
45154
- <ion-select
45155
- formControlName="type"
45156
- [label]="i18n.t('feedbackType')"
45157
- labelPlacement="floating"
45158
- interface="popover"
45159
- >
45160
- @for (option of typeOptions; track option.value) {
45161
- <ion-select-option [value]="option.value">
45162
- {{ option.label }}
45163
- </ion-select-option>
45164
- }
45165
- </ion-select>
45166
- </ion-item>
45167
- }
45168
-
45169
- <!-- Title -->
45170
- <ion-item>
45171
- <ion-textarea
45172
- formControlName="title"
45173
- [label]="props.titleLabel || i18n.t('title')"
45174
- labelPlacement="floating"
45175
- [placeholder]="props.titlePlaceholder || i18n.t('titlePlaceholder')"
45176
- [maxlength]="200"
45177
- [counter]="true"
45178
- [autoGrow]="false"
45179
- rows="1"
45180
- ></ion-textarea>
45181
- </ion-item>
45182
- @if (form.get('title')?.invalid && form.get('title')?.touched) {
45183
- <ion-note color="danger" class="ion-padding-start">
45184
- {{ i18n.t('titleValidation') }}
45185
- </ion-note>
45186
- }
45187
-
45188
- <!-- Description -->
45189
- <ion-item>
45190
- <ion-textarea
45191
- formControlName="description"
45192
- [label]="props.descriptionLabel || i18n.t('description')"
45193
- labelPlacement="floating"
45194
- [placeholder]="props.descriptionPlaceholder || i18n.t('descriptionPlaceholder')"
45195
- [maxlength]="5000"
45196
- [counter]="true"
45197
- [autoGrow]="true"
45198
- rows="4"
45199
- ></ion-textarea>
45200
- </ion-item>
45201
- @if (form.get('description')?.invalid && form.get('description')?.touched) {
45202
- <ion-note color="danger" class="ion-padding-start">
45203
- {{ i18n.t('descriptionValidation') }}
45204
- </ion-note>
45205
- }
45206
-
45207
- <!-- Error message -->
45208
- @if (error()) {
45209
- <div class="feedback-alert error">
45210
- <ion-icon name="close-circle-outline"></ion-icon>
45211
- <span>{{ error() }}</span>
45212
- </div>
45213
- }
45214
-
45215
- <!-- Success message -->
45216
- @if (isSuccess()) {
45217
- <div class="feedback-alert success">
45218
- <ion-icon name="checkmark-circle-outline"></ion-icon>
45219
- <span>{{ props.successMessage || i18n.t('feedbackSuccess') }}</span>
45220
- </div>
45221
- }
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
+ }
45222
45219
 
45223
- <!-- Actions -->
45224
- <div class="form-actions">
45225
- @if (props.cancelButtonText) {
45226
- <ion-button fill="outline" color="medium" type="button" (click)="onCancelClick()">
45227
- {{ props.cancelButtonText }}
45228
- </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>
45229
45225
  }
45230
- <ion-button type="submit" [disabled]="form.invalid || isSubmitting()" expand="block">
45231
- @if (isSubmitting()) {
45232
- <ion-spinner name="circular"></ion-spinner>
45233
- } @else {
45234
- {{ props.submitButtonText || i18n.t('submit') }}
45235
- }
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 }}
45236
45231
  </ion-button>
45237
- </div>
45238
- </form>
45239
- `, 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"] }]
45240
45235
  }], ctorParameters: () => [], propDecorators: { props: [{
45241
45236
  type: Input
45242
45237
  }], onSubmit: [{
@@ -48217,5 +48212,5 @@ function buildFooterLinks(links, t, resolver) {
48217
48212
  * Generated bundle index. Do not edit.
48218
48213
  */
48219
48214
 
48220
- 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 };
48221
48216
  //# sourceMappingURL=valtech-components.mjs.map