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