valtech-components 2.0.510 → 2.0.511

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/esm2022/lib/components/molecules/refresher/refresher.component.mjs +254 -0
  2. package/esm2022/lib/components/molecules/refresher/types.mjs +15 -0
  3. package/esm2022/lib/components/organisms/infinite-list/infinite-list.component.mjs +618 -0
  4. package/esm2022/lib/components/organisms/infinite-list/types.mjs +15 -0
  5. package/esm2022/lib/components/templates/page-template/page-template.component.mjs +3 -3
  6. package/esm2022/lib/services/pagination/index.mjs +5 -0
  7. package/esm2022/lib/services/pagination/pagination.service.mjs +218 -0
  8. package/esm2022/lib/services/pagination/types.mjs +14 -0
  9. package/esm2022/lib/services/skeleton/config.mjs +79 -0
  10. package/esm2022/lib/services/skeleton/directives/loading.directive.mjs +215 -0
  11. package/esm2022/lib/services/skeleton/index.mjs +16 -0
  12. package/esm2022/lib/services/skeleton/skeleton.service.mjs +198 -0
  13. package/esm2022/lib/services/skeleton/templates/detail-skeleton.component.mjs +223 -0
  14. package/esm2022/lib/services/skeleton/templates/form-skeleton.component.mjs +127 -0
  15. package/esm2022/lib/services/skeleton/templates/grid-skeleton.component.mjs +154 -0
  16. package/esm2022/lib/services/skeleton/templates/list-skeleton.component.mjs +110 -0
  17. package/esm2022/lib/services/skeleton/templates/profile-skeleton.component.mjs +207 -0
  18. package/esm2022/lib/services/skeleton/templates/table-skeleton.component.mjs +116 -0
  19. package/esm2022/lib/services/skeleton/types.mjs +11 -0
  20. package/esm2022/public-api.mjs +12 -1
  21. package/fesm2022/valtech-components.mjs +3467 -950
  22. package/fesm2022/valtech-components.mjs.map +1 -1
  23. package/lib/components/molecules/refresher/refresher.component.d.ts +79 -0
  24. package/lib/components/molecules/refresher/types.d.ts +86 -0
  25. package/lib/components/organisms/infinite-list/infinite-list.component.d.ts +111 -0
  26. package/lib/components/organisms/infinite-list/types.d.ts +197 -0
  27. package/lib/services/pagination/index.d.ts +2 -0
  28. package/lib/services/pagination/pagination.service.d.ts +43 -0
  29. package/lib/services/pagination/types.d.ts +113 -0
  30. package/lib/services/skeleton/config.d.ts +30 -0
  31. package/lib/services/skeleton/directives/loading.directive.d.ts +71 -0
  32. package/lib/services/skeleton/index.d.ts +10 -0
  33. package/lib/services/skeleton/skeleton.service.d.ts +127 -0
  34. package/lib/services/skeleton/templates/detail-skeleton.component.d.ts +18 -0
  35. package/lib/services/skeleton/templates/form-skeleton.component.d.ts +22 -0
  36. package/lib/services/skeleton/templates/grid-skeleton.component.d.ts +18 -0
  37. package/lib/services/skeleton/templates/list-skeleton.component.d.ts +17 -0
  38. package/lib/services/skeleton/templates/profile-skeleton.component.d.ts +20 -0
  39. package/lib/services/skeleton/templates/table-skeleton.component.d.ts +17 -0
  40. package/lib/services/skeleton/types.d.ts +111 -0
  41. package/package.json +1 -1
  42. package/public-api.d.ts +6 -0
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
- import { EventEmitter, Component, Input, Output, Injectable, signal, makeEnvironmentProviders, APP_INITIALIZER, inject, HostListener, Pipe, ChangeDetectionStrategy, computed, ViewChild, ChangeDetectorRef, ElementRef, PLATFORM_ID, Inject, ErrorHandler, DestroyRef, InjectionToken, Optional, runInInjectionContext, effect } from '@angular/core';
2
+ import { EventEmitter, Component, Input, Output, Injectable, signal, makeEnvironmentProviders, APP_INITIALIZER, inject, HostListener, Pipe, ChangeDetectionStrategy, computed, ViewChild, ChangeDetectorRef, ElementRef, ContentChild, PLATFORM_ID, Inject, ErrorHandler, DestroyRef, InjectionToken, Optional, runInInjectionContext, effect, TemplateRef, ViewContainerRef, isSignal, Directive } from '@angular/core';
3
3
  import * as i2$1 from '@ionic/angular/standalone';
4
- import { IonAvatar, IonCard, IonIcon, IonButton, IonSpinner, IonText, IonModal, IonHeader, IonToolbar, IonContent, IonButtons, IonTitle, IonProgressBar, IonSkeletonText, IonFab, IonFabButton, IonFabList, IonLabel, IonCardContent, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCheckbox, IonTextarea, IonDatetime, IonDatetimeButton, IonInput, IonSelect, IonSelectOption, IonPopover, IonList, IonItem, IonRadioGroup, IonRadio, IonRange, IonSearchbar, IonSegment, IonSegmentButton, IonToggle, IonAccordion, IonAccordionGroup, IonTabBar, IonTabButton, IonBadge, IonBreadcrumb, IonBreadcrumbs, IonChip, IonNote, ToastController as ToastController$1, IonCol, IonRow, IonMenuButton, IonFooter, IonListHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonGrid, MenuController, IonMenu, IonMenuToggle, AlertController } from '@ionic/angular/standalone';
4
+ import { IonAvatar, IonCard, IonIcon, IonButton, IonSpinner, IonText, IonModal, IonHeader, IonToolbar, IonContent, IonButtons, IonTitle, IonProgressBar, IonSkeletonText, IonFab, IonFabButton, IonFabList, IonLabel, IonCardContent, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCheckbox, IonTextarea, IonDatetime, IonDatetimeButton, IonInput, IonSelect, IonSelectOption, IonPopover, IonList, IonItem, IonRadioGroup, IonRadio, IonRange, IonSearchbar, IonSegment, IonSegmentButton, IonToggle, IonAccordion, IonAccordionGroup, IonTabBar, IonTabButton, IonBadge, IonBreadcrumb, IonBreadcrumbs, IonChip, IonNote, ToastController as ToastController$1, IonCol, IonRow, IonRefresher, IonRefresherContent, IonMenuButton, IonFooter, IonListHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonGrid, MenuController, IonMenu, IonMenuToggle, AlertController } from '@ionic/angular/standalone';
5
5
  import * as i1 from '@angular/common';
6
6
  import { CommonModule, NgStyle, NgFor, NgClass, isPlatformBrowser } from '@angular/common';
7
7
  import { addIcons } from 'ionicons';
@@ -13,7 +13,7 @@ import * as i1$2 from '@angular/platform-browser';
13
13
  import QRCodeStyling from 'qr-code-styling';
14
14
  import * as i1$3 from '@angular/forms';
15
15
  import { ReactiveFormsModule, FormsModule, FormControl, Validators } from '@angular/forms';
16
- import { BehaviorSubject, filter, map, distinctUntilChanged, Subject, throwError, Observable, firstValueFrom, of, from } from 'rxjs';
16
+ import { BehaviorSubject, isObservable, firstValueFrom, filter, map, distinctUntilChanged, Subject, throwError, Observable, of, from } from 'rxjs';
17
17
  import * as i1$4 from 'ng-otp-input';
18
18
  import { NgOtpInputComponent, NgOtpInputModule } from 'ng-otp-input';
19
19
  import * as i2 from '@ionic/angular';
@@ -36,7 +36,7 @@ import { provideFirestore, getFirestore, connectFirestoreEmulator, enableIndexed
36
36
  import { provideMessaging, getMessaging, Messaging, getToken, deleteToken, onMessage } from '@angular/fire/messaging';
37
37
  import * as i1$7 from '@angular/fire/storage';
38
38
  import { provideStorage, getStorage, connectStorageEmulator, ref, uploadBytesResumable, getDownloadURL, getMetadata, deleteObject, listAll } from '@angular/fire/storage';
39
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
39
+ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
40
40
  import { filter as filter$1, catchError, switchMap, finalize, take, tap, map as map$1 } from 'rxjs/operators';
41
41
  import * as i1$8 from '@angular/common/http';
42
42
  import { provideHttpClient, withInterceptors } from '@angular/common/http';
@@ -16074,6 +16074,269 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
16074
16074
  args: ['accordionGroup']
16075
16075
  }] } });
16076
16076
 
16077
+ /**
16078
+ * Valores por defecto para el refresher.
16079
+ */
16080
+ const DEFAULT_REFRESHER_METADATA = {
16081
+ disabled: false,
16082
+ pullThreshold: 70,
16083
+ pullMax: 200,
16084
+ pullFactor: 0.5,
16085
+ snapbackDuration: 280,
16086
+ closeDuration: 280,
16087
+ spinnerType: 'crescent',
16088
+ hapticFeedback: true,
16089
+ webMode: false,
16090
+ };
16091
+
16092
+ /**
16093
+ * Componente de pull-to-refresh para movil y web.
16094
+ *
16095
+ * @example
16096
+ * <!-- Uso basico -->
16097
+ * <val-refresher
16098
+ * [props]="{
16099
+ * color: 'primary',
16100
+ * pullingText: 'Arrastra para actualizar',
16101
+ * refreshingText: 'Cargando...'
16102
+ * }"
16103
+ * (refresh)="onRefresh($event)"
16104
+ * >
16105
+ * <ion-content>
16106
+ * <!-- Contenido scrolleable -->
16107
+ * </ion-content>
16108
+ * </val-refresher>
16109
+ *
16110
+ * @example
16111
+ * <!-- Con indicador personalizado -->
16112
+ * <val-refresher [props]="{ color: 'primary' }" (refresh)="onRefresh($event)">
16113
+ * <ng-template #pullingIndicator let-progress="progress">
16114
+ * <div class="custom-indicator">
16115
+ * <ion-icon name="arrow-down" [style.transform]="'rotate(' + progress * 180 + 'deg)'"></ion-icon>
16116
+ * </div>
16117
+ * </ng-template>
16118
+ * <ion-content>...</ion-content>
16119
+ * </val-refresher>
16120
+ */
16121
+ class RefresherComponent {
16122
+ constructor() {
16123
+ /** Configuracion del refresher */
16124
+ this.props = {};
16125
+ /** Evento emitido cuando se activa el refresh */
16126
+ this.refresh = new EventEmitter();
16127
+ /** Evento de progreso durante el pull */
16128
+ this.pullProgressChange = new EventEmitter();
16129
+ /** Evento de cambio de estado */
16130
+ this.stateChange = new EventEmitter();
16131
+ /** Estado actual del refresher */
16132
+ this.state = signal('idle');
16133
+ /** Progreso actual del pull (0-1) */
16134
+ this.pullProgress = signal(0);
16135
+ }
16136
+ /** Props combinados con defaults */
16137
+ get mergedProps() {
16138
+ return { ...DEFAULT_REFRESHER_METADATA, ...this.props };
16139
+ }
16140
+ /** Si hay indicadores personalizados */
16141
+ get hasCustomIndicator() {
16142
+ return !!(this.pullingIndicator ||
16143
+ this.refreshingIndicator ||
16144
+ this.completingIndicator ||
16145
+ this.props.indicatorConfig);
16146
+ }
16147
+ /**
16148
+ * Activa programaticamente el refresh.
16149
+ */
16150
+ triggerRefresh() {
16151
+ this.state.set('refreshing');
16152
+ this.emitRefreshEvent();
16153
+ }
16154
+ /**
16155
+ * Completa la operacion de refresh actual.
16156
+ */
16157
+ complete() {
16158
+ this.state.set('completing');
16159
+ this.stateChange.emit('completing');
16160
+ this.ionRefresher?.complete();
16161
+ // Resetear a idle despues de la animacion
16162
+ setTimeout(() => {
16163
+ this.state.set('idle');
16164
+ this.stateChange.emit('idle');
16165
+ }, this.mergedProps.closeDuration ?? 280);
16166
+ }
16167
+ /**
16168
+ * Cancela la operacion de refresh actual.
16169
+ */
16170
+ cancel() {
16171
+ this.ionRefresher?.cancel();
16172
+ this.state.set('idle');
16173
+ this.stateChange.emit('idle');
16174
+ }
16175
+ /** Handler para evento ionRefresh */
16176
+ onIonRefresh(event) {
16177
+ this.state.set('refreshing');
16178
+ this.stateChange.emit('refreshing');
16179
+ this.emitRefreshEvent();
16180
+ }
16181
+ /** Handler para evento ionPull */
16182
+ onIonPull(event) {
16183
+ const detail = event.detail;
16184
+ const progress = Math.min(detail.progress, 1);
16185
+ this.pullProgress.set(progress);
16186
+ this.pullProgressChange.emit({
16187
+ progress,
16188
+ thresholdReached: progress >= 1,
16189
+ });
16190
+ }
16191
+ /** Handler para evento ionStart */
16192
+ onIonStart() {
16193
+ this.state.set('pulling');
16194
+ this.stateChange.emit('pulling');
16195
+ }
16196
+ emitRefreshEvent() {
16197
+ const event = {
16198
+ complete: () => this.complete(),
16199
+ cancel: () => this.cancel(),
16200
+ timestamp: new Date(),
16201
+ };
16202
+ this.refresh.emit(event);
16203
+ }
16204
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RefresherComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
16205
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: RefresherComponent, isStandalone: true, selector: "val-refresher", inputs: { props: "props" }, outputs: { refresh: "refresh", pullProgressChange: "pullProgressChange", stateChange: "stateChange" }, queries: [{ propertyName: "pullingIndicator", first: true, predicate: ["pullingIndicator"], descendants: true }, { propertyName: "refreshingIndicator", first: true, predicate: ["refreshingIndicator"], descendants: true }, { propertyName: "completingIndicator", first: true, predicate: ["completingIndicator"], descendants: true }], viewQueries: [{ propertyName: "ionRefresher", first: true, predicate: ["refresher"], descendants: true }], ngImport: i0, template: `
16206
+ <ion-refresher
16207
+ #refresher
16208
+ slot="fixed"
16209
+ [disabled]="mergedProps.disabled"
16210
+ [pullMin]="mergedProps.pullThreshold"
16211
+ [pullMax]="mergedProps.pullMax"
16212
+ [pullFactor]="mergedProps.pullFactor"
16213
+ [snapbackDuration]="mergedProps.snapbackDuration"
16214
+ [closeDuration]="mergedProps.closeDuration"
16215
+ (ionRefresh)="onIonRefresh($event)"
16216
+ (ionPull)="onIonPull($event)"
16217
+ (ionStart)="onIonStart()"
16218
+ >
16219
+ @if (hasCustomIndicator) {
16220
+ <!-- Custom indicator templates -->
16221
+ <div class="refresher-custom-content">
16222
+ @switch (state()) {
16223
+ @case ('pulling') {
16224
+ @if (pullingIndicator) {
16225
+ <ng-container
16226
+ *ngTemplateOutlet="pullingIndicator; context: { progress: pullProgress() }"
16227
+ ></ng-container>
16228
+ }
16229
+ }
16230
+ @case ('refreshing') {
16231
+ @if (refreshingIndicator) {
16232
+ <ng-container *ngTemplateOutlet="refreshingIndicator"></ng-container>
16233
+ } @else {
16234
+ <ion-spinner [name]="mergedProps.spinnerType" [color]="mergedProps.color"></ion-spinner>
16235
+ @if (mergedProps.refreshingText) {
16236
+ <ion-text [color]="mergedProps.color">{{ mergedProps.refreshingText }}</ion-text>
16237
+ }
16238
+ }
16239
+ }
16240
+ @case ('completing') {
16241
+ @if (completingIndicator) {
16242
+ <ng-container *ngTemplateOutlet="completingIndicator"></ng-container>
16243
+ }
16244
+ }
16245
+ }
16246
+ </div>
16247
+ } @else {
16248
+ <!-- Default Ionic refresher content -->
16249
+ <ion-refresher-content
16250
+ [pullingIcon]="mergedProps.spinnerType === 'crescent' ? 'chevron-down-circle-outline' : undefined"
16251
+ [pullingText]="mergedProps.pullingText"
16252
+ [refreshingSpinner]="mergedProps.spinnerType"
16253
+ [refreshingText]="mergedProps.refreshingText"
16254
+ ></ion-refresher-content>
16255
+ }
16256
+ </ion-refresher>
16257
+
16258
+ <ng-content></ng-content>
16259
+ `, isInline: true, styles: [":host{display:block;position:relative}.refresher-custom-content{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: IonRefresher, selector: "ion-refresher", inputs: ["closeDuration", "disabled", "mode", "pullFactor", "pullMax", "pullMin", "snapbackDuration"] }, { kind: "component", type: IonRefresherContent, selector: "ion-refresher-content", inputs: ["pullingIcon", "pullingText", "refreshingSpinner", "refreshingText"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: IonText, selector: "ion-text", inputs: ["color", "mode"] }] }); }
16260
+ }
16261
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RefresherComponent, decorators: [{
16262
+ type: Component,
16263
+ args: [{ selector: 'val-refresher', standalone: true, imports: [CommonModule, IonRefresher, IonRefresherContent, IonSpinner, IonText], template: `
16264
+ <ion-refresher
16265
+ #refresher
16266
+ slot="fixed"
16267
+ [disabled]="mergedProps.disabled"
16268
+ [pullMin]="mergedProps.pullThreshold"
16269
+ [pullMax]="mergedProps.pullMax"
16270
+ [pullFactor]="mergedProps.pullFactor"
16271
+ [snapbackDuration]="mergedProps.snapbackDuration"
16272
+ [closeDuration]="mergedProps.closeDuration"
16273
+ (ionRefresh)="onIonRefresh($event)"
16274
+ (ionPull)="onIonPull($event)"
16275
+ (ionStart)="onIonStart()"
16276
+ >
16277
+ @if (hasCustomIndicator) {
16278
+ <!-- Custom indicator templates -->
16279
+ <div class="refresher-custom-content">
16280
+ @switch (state()) {
16281
+ @case ('pulling') {
16282
+ @if (pullingIndicator) {
16283
+ <ng-container
16284
+ *ngTemplateOutlet="pullingIndicator; context: { progress: pullProgress() }"
16285
+ ></ng-container>
16286
+ }
16287
+ }
16288
+ @case ('refreshing') {
16289
+ @if (refreshingIndicator) {
16290
+ <ng-container *ngTemplateOutlet="refreshingIndicator"></ng-container>
16291
+ } @else {
16292
+ <ion-spinner [name]="mergedProps.spinnerType" [color]="mergedProps.color"></ion-spinner>
16293
+ @if (mergedProps.refreshingText) {
16294
+ <ion-text [color]="mergedProps.color">{{ mergedProps.refreshingText }}</ion-text>
16295
+ }
16296
+ }
16297
+ }
16298
+ @case ('completing') {
16299
+ @if (completingIndicator) {
16300
+ <ng-container *ngTemplateOutlet="completingIndicator"></ng-container>
16301
+ }
16302
+ }
16303
+ }
16304
+ </div>
16305
+ } @else {
16306
+ <!-- Default Ionic refresher content -->
16307
+ <ion-refresher-content
16308
+ [pullingIcon]="mergedProps.spinnerType === 'crescent' ? 'chevron-down-circle-outline' : undefined"
16309
+ [pullingText]="mergedProps.pullingText"
16310
+ [refreshingSpinner]="mergedProps.spinnerType"
16311
+ [refreshingText]="mergedProps.refreshingText"
16312
+ ></ion-refresher-content>
16313
+ }
16314
+ </ion-refresher>
16315
+
16316
+ <ng-content></ng-content>
16317
+ `, styles: [":host{display:block;position:relative}.refresher-custom-content{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px}\n"] }]
16318
+ }], propDecorators: { ionRefresher: [{
16319
+ type: ViewChild,
16320
+ args: ['refresher']
16321
+ }], pullingIndicator: [{
16322
+ type: ContentChild,
16323
+ args: ['pullingIndicator']
16324
+ }], refreshingIndicator: [{
16325
+ type: ContentChild,
16326
+ args: ['refreshingIndicator']
16327
+ }], completingIndicator: [{
16328
+ type: ContentChild,
16329
+ args: ['completingIndicator']
16330
+ }], props: [{
16331
+ type: Input
16332
+ }], refresh: [{
16333
+ type: Output
16334
+ }], pullProgressChange: [{
16335
+ type: Output
16336
+ }], stateChange: [{
16337
+ type: Output
16338
+ }] } });
16339
+
16077
16340
  /**
16078
16341
  * Configuración de espaciado predefinida
16079
16342
  */
@@ -20854,203 +21117,1033 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
20854
21117
  type: Output
20855
21118
  }] } });
20856
21119
 
20857
- class LayoutComponent {
20858
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LayoutComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
20859
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: LayoutComponent, isStandalone: true, selector: "val-layout", ngImport: i0, template: `
20860
- <div class="layout-container">
20861
- <ng-content></ng-content>
20862
- </div>
20863
- `, isInline: true, styles: [":root{--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)}@media (prefers-color-scheme: dark){:root{--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)}.layout-container{margin:0 auto;padding:0;width:100%;box-sizing:border-box;margin-bottom:1rem;padding-top:.5rem}@media (max-width: 768px){.layout-container{max-width:100%}}@media (min-width: 768px){.layout-container{margin:0 auto;max-width:33.75rem;margin-bottom:1.5rem}}@media (min-width: 1200px){.layout-container{margin:0 auto;max-width:45rem}}\n"] }); }
20864
- }
20865
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LayoutComponent, decorators: [{
20866
- type: Component,
20867
- args: [{ selector: 'val-layout', standalone: true, imports: [], template: `
20868
- <div class="layout-container">
20869
- <ng-content></ng-content>
20870
- </div>
20871
- `, styles: [":root{--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)}@media (prefers-color-scheme: dark){:root{--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)}.layout-container{margin:0 auto;padding:0;width:100%;box-sizing:border-box;margin-bottom:1rem;padding-top:.5rem}@media (max-width: 768px){.layout-container{max-width:100%}}@media (min-width: 768px){.layout-container{margin:0 auto;max-width:33.75rem;margin-bottom:1.5rem}}@media (min-width: 1200px){.layout-container{margin:0 auto;max-width:45rem}}\n"] }]
20872
- }] });
20873
-
20874
- class SimpleComponent {
20875
- constructor() {
20876
- this.onClick = new EventEmitter();
20877
- this.onScrollEvent = new EventEmitter();
20878
- this.theme = inject(ThemeService);
20879
- }
20880
- onClickHandler(token) {
20881
- this.onClick.emit(token);
20882
- }
20883
- onScrollHandler($event) {
20884
- this.onScrollEvent.emit(true);
20885
- }
20886
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
20887
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SimpleComponent, isStandalone: true, selector: "val-simple", inputs: { props: "props" }, outputs: { onClick: "onClick", onScrollEvent: "onScrollEvent" }, ngImport: i0, template: `
20888
- <val-header [props]="props.header" />
20889
-
20890
- <ion-content
20891
- [fullscreen]="true"
20892
- [ngStyle]="{
20893
- '--background': theme.IsDark ? 'var(--ion-background-color)' : props.background,
20894
- }"
20895
- [scrollEvents]="true"
20896
- (ionScroll)="onScrollHandler($event)"
20897
- >
20898
- <ion-header collapse="condense">
20899
- <ion-toolbar style="--background: transparent;">
20900
- <ion-title style="padding: 0;" size="large">{{ props.pageTitle }}</ion-title>
20901
- </ion-toolbar>
20902
- </ion-header>
20903
- @if (props.pageDescription) {
20904
- <div class="description-container">
20905
- <val-expandable-text
20906
- [props]="{
20907
- limit: 180,
20908
- content: props.pageDescription,
20909
- color: 'primary',
20910
- expandText: 'más',
20911
- }"
20912
- />
20913
- </div>
20914
- }
20915
- @if (props.link) {
20916
- <val-link [props]="props.link" (onClick)="onClickHandler($event)"></val-link>
20917
- }
20918
- @if (props.withDivider) {
20919
- <val-divider [props]="{ fill: 'solid', size: 'medium', color: 'dark' }" />
20920
- }
20921
- <val-layout>
20922
- <ng-content select="[inner-container]"></ng-content>
20923
- </val-layout>
20924
- </ion-content>
20925
- <ng-content select="[outter-container]"></ng-content>
20926
- `, isInline: true, styles: [".description-container{padding:0 16px}\n"], dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonTitle, selector: "ion-title", inputs: ["color", "size"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: HeaderComponent, selector: "val-header", inputs: ["props"], outputs: ["onClick"] }, { kind: "component", type: LayoutComponent, selector: "val-layout" }, { kind: "component", type: DividerComponent, selector: "val-divider", inputs: ["props"] }, { kind: "component", type: LinkComponent, selector: "val-link", inputs: ["props"], outputs: ["onClick"] }, { kind: "component", type: ExpandableTextComponent, selector: "val-expandable-text", inputs: ["props"] }] }); }
20927
- }
20928
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleComponent, decorators: [{
20929
- type: Component,
20930
- args: [{ selector: 'val-simple', standalone: true, imports: [
20931
- NgStyle,
20932
- IonHeader,
20933
- IonToolbar,
20934
- IonTitle,
20935
- IonContent,
20936
- HeaderComponent,
20937
- LayoutComponent,
20938
- DividerComponent,
20939
- LinkComponent,
20940
- ExpandableTextComponent,
20941
- ], template: `
20942
- <val-header [props]="props.header" />
21120
+ /**
21121
+ * Valores por defecto para infinite list.
21122
+ */
21123
+ const DEFAULT_INFINITE_LIST_METADATA = {
21124
+ direction: 'bottom',
21125
+ pageSize: 20,
21126
+ threshold: '100px',
21127
+ debounceTime: 300,
21128
+ autoLoad: true,
21129
+ spinnerType: 'crescent',
21130
+ useLoadMoreButton: false,
21131
+ showDividers: false,
21132
+ enableRefresh: false,
21133
+ };
20943
21134
 
20944
- <ion-content
20945
- [fullscreen]="true"
20946
- [ngStyle]="{
20947
- '--background': theme.IsDark ? 'var(--ion-background-color)' : props.background,
20948
- }"
20949
- [scrollEvents]="true"
20950
- (ionScroll)="onScrollHandler($event)"
20951
- >
20952
- <ion-header collapse="condense">
20953
- <ion-toolbar style="--background: transparent;">
20954
- <ion-title style="padding: 0;" size="large">{{ props.pageTitle }}</ion-title>
20955
- </ion-toolbar>
20956
- </ion-header>
20957
- @if (props.pageDescription) {
20958
- <div class="description-container">
20959
- <val-expandable-text
20960
- [props]="{
20961
- limit: 180,
20962
- content: props.pageDescription,
20963
- color: 'primary',
20964
- expandText: 'más',
20965
- }"
20966
- />
20967
- </div>
20968
- }
20969
- @if (props.link) {
20970
- <val-link [props]="props.link" (onClick)="onClickHandler($event)"></val-link>
20971
- }
20972
- @if (props.withDivider) {
20973
- <val-divider [props]="{ fill: 'solid', size: 'medium', color: 'dark' }" />
20974
- }
20975
- <val-layout>
20976
- <ng-content select="[inner-container]"></ng-content>
20977
- </val-layout>
20978
- </ion-content>
20979
- <ng-content select="[outter-container]"></ng-content>
20980
- `, styles: [".description-container{padding:0 16px}\n"] }]
20981
- }], propDecorators: { props: [{
20982
- type: Input
20983
- }], onClick: [{
20984
- type: Output
20985
- }], onScrollEvent: [{
20986
- type: Output
20987
- }] } });
21135
+ /**
21136
+ * Configuracion por defecto para el sistema de skeletons.
21137
+ */
21138
+ const DEFAULT_SKELETON_CONFIG = {
21139
+ animated: true,
21140
+ defaultDelay: 0,
21141
+ defaultMinTime: 300,
21142
+ defaultListTemplate: 'list',
21143
+ defaultGridTemplate: 'grid-cards',
21144
+ };
20988
21145
 
20989
21146
  /**
20990
- * val-page-template
20991
- *
20992
- * A page template component with title, expandable description,
20993
- * content projection, and optional back navigation button.
21147
+ * Servicio para gestionar templates de skeleton y estados de carga globales.
20994
21148
  *
20995
21149
  * @example
20996
- * <val-page-template
20997
- * [props]="{
20998
- * pageTitle: 'Getting Started',
20999
- * pageDescription: 'Learn how to use our components...',
21000
- * showBackButton: true
21001
- * }"
21002
- * >
21003
- * <div extra-description>
21004
- * <p>Additional info here</p>
21005
- * </div>
21150
+ * // En un componente
21151
+ * skeleton = inject(SkeletonService);
21006
21152
  *
21007
- * <!-- Main content -->
21008
- * <my-content></my-content>
21153
+ * // Registrar template personalizado
21154
+ * skeleton.registerTemplate('my-custom', MyCustomSkeletonComponent);
21009
21155
  *
21010
- * <div extra-footer>
21011
- * <p>Footer content</p>
21012
- * </div>
21013
- * </val-page-template>
21156
+ * // Obtener componente de template
21157
+ * const component = skeleton.getTemplate('list');
21014
21158
  *
21015
- * @input props - Page template configuration
21016
- * @output onBack - Emits when back button is clicked
21159
+ * // Gestionar estados de carga globales
21160
+ * skeleton.setLoadingState('dashboard', true);
21161
+ * const isDashboardLoading = skeleton.loadingState('dashboard');
21017
21162
  */
21018
- class PageTemplateComponent {
21163
+ class SkeletonService {
21019
21164
  constructor() {
21020
- this.nav = inject(NavController);
21021
- /**
21022
- * Page template configuration.
21023
- */
21024
- this.props = {};
21025
- /**
21026
- * Emits when the back button is clicked.
21027
- */
21028
- this.onBack = new EventEmitter();
21165
+ this._config = signal(DEFAULT_SKELETON_CONFIG);
21166
+ this._templates = signal(new Map());
21167
+ this._loadingStates = signal(new Map());
21168
+ this._initialized = false;
21169
+ /** Configuracion actual (solo lectura) */
21170
+ this.config = this._config.asReadonly();
21171
+ /** Templates registrados (solo lectura) */
21172
+ this.templates = this._templates.asReadonly();
21173
+ /** Estado de carga global (cualquier estado registrado esta cargando) */
21174
+ this.isAnyLoading = computed(() => {
21175
+ const states = this._loadingStates();
21176
+ return Array.from(states.values()).some((v) => v);
21177
+ });
21178
+ /** Cantidad de templates registrados */
21179
+ this.templateCount = computed(() => this._templates().size);
21029
21180
  }
21030
21181
  /**
21031
- * Handles back navigation.
21182
+ * Configura el servicio de skeleton.
21183
+ * Llamado por provideValtechSkeleton().
21032
21184
  */
21033
- handleBack() {
21034
- this.onBack.emit();
21035
- this.nav.back();
21185
+ configure(config) {
21186
+ if (this._initialized) {
21187
+ console.warn('[SkeletonService] Service already configured. Ignoring reconfiguration.');
21188
+ return;
21189
+ }
21190
+ this._config.set({ ...DEFAULT_SKELETON_CONFIG, ...config });
21191
+ // Registrar templates personalizados de la configuracion
21192
+ config.templates?.forEach((t) => {
21193
+ this.registerTemplate(t.name, t.component, t.defaultConfig);
21194
+ });
21195
+ this._initialized = true;
21036
21196
  }
21037
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PageTemplateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
21038
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: PageTemplateComponent, isStandalone: true, selector: "val-page-template", inputs: { props: "props" }, outputs: { onBack: "onBack" }, ngImport: i0, template: `
21039
- @if (props.pageTitle) {
21040
- <ion-header [class.ion-no-border]="true">
21041
- <ion-toolbar style="--background: transparent;">
21042
- <ion-title class="page-title" size="large">{{ props.pageTitle }}</ion-title>
21043
- </ion-toolbar>
21044
- </ion-header>
21197
+ /**
21198
+ * Registra un template de skeleton personalizado.
21199
+ *
21200
+ * @param name Nombre unico del template
21201
+ * @param component Componente a usar
21202
+ * @param defaultConfig Configuracion por defecto opcional
21203
+ */
21204
+ registerTemplate(name, component, defaultConfig) {
21205
+ this._templates.update((map) => {
21206
+ const newMap = new Map(map);
21207
+ newMap.set(name, { name, component, defaultConfig });
21208
+ return newMap;
21209
+ });
21045
21210
  }
21046
- <ion-grid>
21047
- <ion-row class="ion-justify-content-center description-row">
21048
- <ion-col size="12" size-md="10" size-lg="8">
21049
- @if (props.pageDescription) {
21050
- <div class="description-container">
21051
- <val-expandable-text
21052
- [props]="{
21053
- limit: props.descriptionLimit || 180,
21211
+ /**
21212
+ * Obtiene un template de skeleton registrado.
21213
+ *
21214
+ * @param name Nombre del template
21215
+ * @returns Template registrado o undefined si no existe
21216
+ */
21217
+ getTemplate(name) {
21218
+ return this._templates().get(name);
21219
+ }
21220
+ /**
21221
+ * Verifica si un template esta registrado.
21222
+ *
21223
+ * @param name Nombre del template
21224
+ * @returns true si el template existe
21225
+ */
21226
+ hasTemplate(name) {
21227
+ return this._templates().has(name);
21228
+ }
21229
+ /**
21230
+ * Obtiene todos los nombres de templates registrados.
21231
+ *
21232
+ * @returns Array de nombres de templates
21233
+ */
21234
+ getTemplateNames() {
21235
+ return Array.from(this._templates().keys());
21236
+ }
21237
+ /**
21238
+ * Registra un estado de carga nombrado.
21239
+ * Util para indicadores de carga globales.
21240
+ *
21241
+ * @param key Identificador unico del estado
21242
+ * @param isLoading Estado de carga
21243
+ */
21244
+ setLoadingState(key, isLoading) {
21245
+ this._loadingStates.update((map) => {
21246
+ const newMap = new Map(map);
21247
+ if (isLoading) {
21248
+ newMap.set(key, true);
21249
+ }
21250
+ else {
21251
+ newMap.delete(key);
21252
+ }
21253
+ return newMap;
21254
+ });
21255
+ }
21256
+ /**
21257
+ * Obtiene el estado de carga para una clave especifica.
21258
+ *
21259
+ * @param key Identificador del estado
21260
+ * @returns Estado de carga actual
21261
+ */
21262
+ getLoadingState(key) {
21263
+ return this._loadingStates().get(key) ?? false;
21264
+ }
21265
+ /**
21266
+ * Crea un signal computado para un estado de carga especifico.
21267
+ *
21268
+ * @param key Identificador del estado
21269
+ * @returns Signal reactivo del estado de carga
21270
+ */
21271
+ loadingState(key) {
21272
+ return computed(() => this._loadingStates().get(key) ?? false);
21273
+ }
21274
+ /**
21275
+ * Obtiene todas las claves de estados de carga activos.
21276
+ *
21277
+ * @returns Array de claves con estado de carga activo
21278
+ */
21279
+ getActiveLoadingKeys() {
21280
+ const states = this._loadingStates();
21281
+ return Array.from(states.entries())
21282
+ .filter(([, isLoading]) => isLoading)
21283
+ .map(([key]) => key);
21284
+ }
21285
+ /**
21286
+ * Limpia todos los estados de carga.
21287
+ */
21288
+ clearLoadingStates() {
21289
+ this._loadingStates.set(new Map());
21290
+ }
21291
+ /**
21292
+ * Limpia un estado de carga especifico.
21293
+ *
21294
+ * @param key Identificador del estado a limpiar
21295
+ */
21296
+ clearLoadingState(key) {
21297
+ this.setLoadingState(key, false);
21298
+ }
21299
+ /**
21300
+ * Ejecuta una funcion async con tracking de estado de carga.
21301
+ *
21302
+ * @param key Identificador del estado
21303
+ * @param fn Funcion async a ejecutar
21304
+ * @returns Resultado de la funcion
21305
+ */
21306
+ async withLoading(key, fn) {
21307
+ this.setLoadingState(key, true);
21308
+ try {
21309
+ return await fn();
21310
+ }
21311
+ finally {
21312
+ this.setLoadingState(key, false);
21313
+ }
21314
+ }
21315
+ /**
21316
+ * Obtiene la configuracion de animacion por defecto.
21317
+ */
21318
+ get animated() {
21319
+ return this._config().animated ?? true;
21320
+ }
21321
+ /**
21322
+ * Obtiene el delay por defecto.
21323
+ */
21324
+ get defaultDelay() {
21325
+ return this._config().defaultDelay ?? 0;
21326
+ }
21327
+ /**
21328
+ * Obtiene el tiempo minimo por defecto.
21329
+ */
21330
+ get defaultMinTime() {
21331
+ return this._config().defaultMinTime ?? 300;
21332
+ }
21333
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SkeletonService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
21334
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SkeletonService, providedIn: 'root' }); }
21335
+ }
21336
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SkeletonService, decorators: [{
21337
+ type: Injectable,
21338
+ args: [{ providedIn: 'root' }]
21339
+ }] });
21340
+
21341
+ /**
21342
+ * Componente wrapper para listas con infinite scroll.
21343
+ *
21344
+ * @example
21345
+ * <!-- Uso basico con data source -->
21346
+ * <val-infinite-list
21347
+ * [props]="{
21348
+ * dataSource: { loadFn: loadUsers, trackBy: trackByUserId },
21349
+ * itemTemplate: userTemplate,
21350
+ * pageSize: 20,
21351
+ * threshold: '150px'
21352
+ * }"
21353
+ * ></val-infinite-list>
21354
+ *
21355
+ * <ng-template #userTemplate let-user let-index="index">
21356
+ * <val-card [props]="{ title: user.name, subtitle: user.email }">
21357
+ * <p>{{ user.bio }}</p>
21358
+ * </val-card>
21359
+ * </ng-template>
21360
+ *
21361
+ * @example
21362
+ * <!-- Con pull-to-refresh y estado vacio personalizado -->
21363
+ * <val-infinite-list
21364
+ * [props]="{
21365
+ * dataSource: { loadFn: loadMessages },
21366
+ * itemTemplate: messageTemplate,
21367
+ * direction: 'both',
21368
+ * enableRefresh: true,
21369
+ * emptyState: {
21370
+ * icon: 'chatbubbles-outline',
21371
+ * title: 'Sin mensajes',
21372
+ * message: 'Inicia una conversacion'
21373
+ * },
21374
+ * skeleton: { template: 'list', count: 5 }
21375
+ * }"
21376
+ * (refresh)="onRefresh($event)"
21377
+ * ></val-infinite-list>
21378
+ */
21379
+ class InfiniteListComponent {
21380
+ constructor() {
21381
+ this.skeletonService = inject(SkeletonService);
21382
+ this.cdr = inject(ChangeDetectorRef);
21383
+ // === Events ===
21384
+ this.loadMore = new EventEmitter();
21385
+ this.refresh = new EventEmitter();
21386
+ this.stateChange = new EventEmitter();
21387
+ this.itemsChange = new EventEmitter();
21388
+ this.errorOccurred = new EventEmitter();
21389
+ // === Reactive State ===
21390
+ this.items = signal([]);
21391
+ this.state = signal('idle');
21392
+ this.hasMoreBottom = signal(true);
21393
+ this.hasMoreTop = signal(false);
21394
+ this.error = signal(null);
21395
+ this.isInitialLoad = signal(true);
21396
+ this.currentPage = 0;
21397
+ this.currentCursor = null;
21398
+ /** Progreso de carga (0-1 si totalCount conocido) */
21399
+ this.loadProgress = computed(() => {
21400
+ if (!this.props?.dataSource?.totalCount)
21401
+ return null;
21402
+ return this.items().length / this.props.dataSource.totalCount;
21403
+ });
21404
+ /** Anuncio de estado para lectores de pantalla */
21405
+ this.statusAnnouncement = computed(() => {
21406
+ switch (this.state()) {
21407
+ case 'loading':
21408
+ return 'Cargando items...';
21409
+ case 'error':
21410
+ return `Error: ${this.error()?.message || 'Ocurrio un error'}`;
21411
+ case 'complete':
21412
+ return 'Todos los items han sido cargados';
21413
+ default:
21414
+ return '';
21415
+ }
21416
+ });
21417
+ }
21418
+ /** Props combinados con defaults */
21419
+ get mergedProps() {
21420
+ return { ...DEFAULT_INFINITE_LIST_METADATA, ...this.props };
21421
+ }
21422
+ /** Config del refresher */
21423
+ get refresherConfig() {
21424
+ return this.mergedProps.refreshConfig ?? {};
21425
+ }
21426
+ /** Componente de skeleton a usar */
21427
+ get skeletonComponent() {
21428
+ const templateName = this.mergedProps.skeleton?.template || 'list';
21429
+ const template = this.skeletonService.getTemplate(templateName);
21430
+ return template?.component ?? null;
21431
+ }
21432
+ /** Inputs para el skeleton */
21433
+ get skeletonInputs() {
21434
+ return {
21435
+ config: {
21436
+ count: this.mergedProps.skeleton?.count ?? 3,
21437
+ animated: true,
21438
+ ...this.mergedProps.skeleton?.config,
21439
+ },
21440
+ };
21441
+ }
21442
+ ngOnInit() {
21443
+ // Cargar items iniciales del dataSource si existen
21444
+ if (this.props.dataSource.items?.length) {
21445
+ this.items.set([...this.props.dataSource.items]);
21446
+ this.isInitialLoad.set(false);
21447
+ }
21448
+ // Auto-cargar si esta habilitado
21449
+ if (this.mergedProps.autoLoad && !this.items().length) {
21450
+ this.loadInitial();
21451
+ }
21452
+ }
21453
+ ngOnDestroy() {
21454
+ // Cleanup
21455
+ }
21456
+ /** Funcion de tracking para ngFor */
21457
+ trackByFn(index, item) {
21458
+ if (this.props.dataSource.trackBy) {
21459
+ return this.props.dataSource.trackBy(index, item);
21460
+ }
21461
+ return index;
21462
+ }
21463
+ /** Si debe mostrar el scroll inferior */
21464
+ shouldShowBottomScroll() {
21465
+ const dir = this.mergedProps.direction;
21466
+ return (dir === 'bottom' || dir === 'both') && this.items().length > 0;
21467
+ }
21468
+ /** Carga inicial de datos */
21469
+ async loadInitial() {
21470
+ if (!this.props.dataSource.loadFn)
21471
+ return;
21472
+ this.state.set('loading');
21473
+ this.stateChange.emit('loading');
21474
+ this.error.set(null);
21475
+ try {
21476
+ const params = {
21477
+ direction: 'bottom',
21478
+ page: 0,
21479
+ pageSize: this.mergedProps.pageSize ?? 20,
21480
+ };
21481
+ const result = await this.executeLoad(params);
21482
+ this.items.set(result.items);
21483
+ this.hasMoreBottom.set(result.hasMore);
21484
+ this.currentPage = 1;
21485
+ this.currentCursor = result.cursor;
21486
+ this.isInitialLoad.set(false);
21487
+ this.state.set('idle');
21488
+ this.stateChange.emit('idle');
21489
+ this.itemsChange.emit(this.items());
21490
+ }
21491
+ catch (err) {
21492
+ this.handleError(err);
21493
+ }
21494
+ }
21495
+ /** Cargar mas items en la parte inferior */
21496
+ async loadBottom() {
21497
+ if (!this.hasMoreBottom() || this.state() === 'loading')
21498
+ return;
21499
+ if (!this.props.dataSource.loadFn)
21500
+ return;
21501
+ this.state.set('loading');
21502
+ this.stateChange.emit('loading');
21503
+ try {
21504
+ const params = {
21505
+ direction: 'bottom',
21506
+ page: this.currentPage,
21507
+ pageSize: this.mergedProps.pageSize ?? 20,
21508
+ cursor: this.currentCursor,
21509
+ lastItem: this.items()[this.items().length - 1],
21510
+ };
21511
+ const result = await this.executeLoad(params);
21512
+ this.items.update((current) => [...current, ...result.items]);
21513
+ this.hasMoreBottom.set(result.hasMore);
21514
+ this.currentPage++;
21515
+ this.currentCursor = result.cursor;
21516
+ this.state.set(result.hasMore ? 'idle' : 'complete');
21517
+ this.stateChange.emit(result.hasMore ? 'idle' : 'complete');
21518
+ this.itemsChange.emit(this.items());
21519
+ }
21520
+ catch (err) {
21521
+ this.handleError(err);
21522
+ }
21523
+ }
21524
+ /** Cargar mas items en la parte superior */
21525
+ async loadTop() {
21526
+ if (!this.hasMoreTop() || this.state() === 'loading')
21527
+ return;
21528
+ if (!this.props.dataSource.loadFn)
21529
+ return;
21530
+ this.state.set('loading');
21531
+ try {
21532
+ const params = {
21533
+ direction: 'top',
21534
+ page: 0,
21535
+ pageSize: this.mergedProps.pageSize ?? 20,
21536
+ firstItem: this.items()[0],
21537
+ };
21538
+ const result = await this.executeLoad(params);
21539
+ this.items.update((current) => [...result.items, ...current]);
21540
+ this.hasMoreTop.set(result.hasMore);
21541
+ this.state.set('idle');
21542
+ this.itemsChange.emit(this.items());
21543
+ }
21544
+ catch (err) {
21545
+ this.handleError(err);
21546
+ }
21547
+ }
21548
+ /** Refresh - recargar desde cero */
21549
+ async refreshList() {
21550
+ this.currentPage = 0;
21551
+ this.currentCursor = null;
21552
+ this.hasMoreBottom.set(true);
21553
+ this.items.set([]);
21554
+ await this.loadInitial();
21555
+ }
21556
+ /** Reintentar despues de error */
21557
+ async retry() {
21558
+ this.error.set(null);
21559
+ if (this.items().length === 0) {
21560
+ await this.loadInitial();
21561
+ }
21562
+ else {
21563
+ await this.loadBottom();
21564
+ }
21565
+ }
21566
+ /** Reset completo */
21567
+ async reset() {
21568
+ this.items.set([]);
21569
+ this.currentPage = 0;
21570
+ this.currentCursor = null;
21571
+ this.hasMoreBottom.set(true);
21572
+ this.hasMoreTop.set(false);
21573
+ this.error.set(null);
21574
+ this.isInitialLoad.set(true);
21575
+ this.state.set('idle');
21576
+ if (this.mergedProps.autoLoad) {
21577
+ await this.loadInitial();
21578
+ }
21579
+ }
21580
+ /** Agregar items al inicio */
21581
+ prependItems(newItems) {
21582
+ this.items.update((current) => [...newItems, ...current]);
21583
+ this.itemsChange.emit(this.items());
21584
+ }
21585
+ /** Agregar items al final */
21586
+ appendItems(newItems) {
21587
+ this.items.update((current) => [...current, ...newItems]);
21588
+ this.itemsChange.emit(this.items());
21589
+ }
21590
+ /** Actualizar un item por indice */
21591
+ updateItem(index, item) {
21592
+ this.items.update((current) => {
21593
+ const updated = [...current];
21594
+ updated[index] = item;
21595
+ return updated;
21596
+ });
21597
+ this.itemsChange.emit(this.items());
21598
+ }
21599
+ /** Remover un item por indice */
21600
+ removeItem(index) {
21601
+ this.items.update((current) => current.filter((_, i) => i !== index));
21602
+ this.itemsChange.emit(this.items());
21603
+ }
21604
+ /** Handler para evento de infinite scroll */
21605
+ async onInfiniteScroll(event) {
21606
+ await this.loadBottom();
21607
+ event.target.complete();
21608
+ }
21609
+ /** Handler para evento de refresh */
21610
+ async onRefreshTriggered(event) {
21611
+ this.refresh.emit(event);
21612
+ await this.refreshList();
21613
+ event.complete();
21614
+ }
21615
+ async executeLoad(params) {
21616
+ const loadFn = this.props.dataSource.loadFn;
21617
+ const result = loadFn(params);
21618
+ if (isObservable(result)) {
21619
+ return await firstValueFrom(result);
21620
+ }
21621
+ return await result;
21622
+ }
21623
+ handleError(err) {
21624
+ this.error.set(err);
21625
+ this.state.set('error');
21626
+ this.stateChange.emit('error');
21627
+ this.errorOccurred.emit(err);
21628
+ console.error('[InfiniteList] Error loading items:', err);
21629
+ }
21630
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: InfiniteListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
21631
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: InfiniteListComponent, isStandalone: true, selector: "val-infinite-list", inputs: { props: "props" }, outputs: { loadMore: "loadMore", refresh: "refresh", stateChange: "stateChange", itemsChange: "itemsChange", errorOccurred: "errorOccurred" }, viewQueries: [{ propertyName: "infiniteScroll", first: true, predicate: IonInfiniteScroll, descendants: true }], ngImport: i0, template: `
21632
+ <!-- Pull to refresh wrapper -->
21633
+ @if (mergedProps.enableRefresh) {
21634
+ <val-refresher [props]="refresherConfig" (refresh)="onRefreshTriggered($event)">
21635
+ <ng-container *ngTemplateOutlet="listContent"></ng-container>
21636
+ </val-refresher>
21637
+ } @else {
21638
+ <ng-container *ngTemplateOutlet="listContent"></ng-container>
21639
+ }
21640
+
21641
+ <!-- Main list content template -->
21642
+ <ng-template #listContent>
21643
+ <div
21644
+ class="infinite-list-container"
21645
+ [class]="mergedProps.cssClass"
21646
+ [style.max-height]="mergedProps.maxHeight"
21647
+ [style.overflow-y]="mergedProps.maxHeight ? 'auto' : 'visible'"
21648
+ role="feed"
21649
+ [attr.aria-busy]="state() === 'loading'"
21650
+ [attr.aria-label]="mergedProps.ariaLabel"
21651
+ [attr.aria-description]="mergedProps.ariaDescription"
21652
+ >
21653
+ <!-- Loading state (initial) -->
21654
+ @if (state() === 'loading' && items().length === 0) {
21655
+ <div class="infinite-list-skeleton">
21656
+ @if (mergedProps.skeleton?.customTemplate) {
21657
+ <ng-container *ngTemplateOutlet="mergedProps.skeleton.customTemplate"></ng-container>
21658
+ } @else {
21659
+ <ng-container *ngComponentOutlet="skeletonComponent; inputs: skeletonInputs"></ng-container>
21660
+ }
21661
+ </div>
21662
+ }
21663
+
21664
+ <!-- Empty state -->
21665
+ @if (state() === 'idle' && items().length === 0 && !isInitialLoad()) {
21666
+ <div class="infinite-list-empty">
21667
+ @if (mergedProps.emptyState?.template) {
21668
+ <ng-container *ngTemplateOutlet="mergedProps.emptyState.template"></ng-container>
21669
+ } @else {
21670
+ @if (mergedProps.emptyState?.icon) {
21671
+ <ion-icon [name]="mergedProps.emptyState.icon" size="large"></ion-icon>
21672
+ }
21673
+ @if (mergedProps.emptyState?.title) {
21674
+ <h3>{{ mergedProps.emptyState.title }}</h3>
21675
+ }
21676
+ @if (mergedProps.emptyState?.message) {
21677
+ <p>{{ mergedProps.emptyState.message }}</p>
21678
+ }
21679
+ }
21680
+ </div>
21681
+ }
21682
+
21683
+ <!-- Error state -->
21684
+ @if (state() === 'error') {
21685
+ <div class="infinite-list-error">
21686
+ @if (mergedProps.errorState?.template) {
21687
+ <ng-container
21688
+ *ngTemplateOutlet="mergedProps.errorState.template; context: { error: error(), retry: retry.bind(this) }"
21689
+ ></ng-container>
21690
+ } @else {
21691
+ @if (mergedProps.errorState?.icon) {
21692
+ <ion-icon [name]="mergedProps.errorState.icon" color="danger" size="large"></ion-icon>
21693
+ } @else {
21694
+ <ion-icon name="alert-circle-outline" color="danger" size="large"></ion-icon>
21695
+ }
21696
+ <h3>{{ mergedProps.errorState?.title || 'Error' }}</h3>
21697
+ <p>{{ mergedProps.errorState?.message || error()?.message || 'Ocurrio un error' }}</p>
21698
+ @if (mergedProps.errorState?.showRetry !== false) {
21699
+ <ion-button fill="outline" (click)="retry()">
21700
+ {{ mergedProps.errorState?.retryText || 'Reintentar' }}
21701
+ </ion-button>
21702
+ }
21703
+ }
21704
+ </div>
21705
+ }
21706
+
21707
+ <!-- Items list -->
21708
+ @if (items().length > 0) {
21709
+ <div class="infinite-list-items" [class.with-dividers]="mergedProps.showDividers">
21710
+ @for (item of items(); track trackByFn($index, item); let i = $index; let first = $first; let last = $last) {
21711
+ <article
21712
+ class="infinite-list-item"
21713
+ [attr.aria-setsize]="mergedProps.dataSource.totalCount || null"
21714
+ [attr.aria-posinset]="i + 1"
21715
+ >
21716
+ <ng-container
21717
+ *ngTemplateOutlet="
21718
+ mergedProps.itemTemplate;
21719
+ context: { $implicit: item, index: i, first: first, last: last, count: items().length }
21720
+ "
21721
+ ></ng-container>
21722
+ </article>
21723
+ }
21724
+ </div>
21725
+ }
21726
+
21727
+ <!-- Bottom infinite scroll -->
21728
+ @if (shouldShowBottomScroll()) {
21729
+ @if (mergedProps.useLoadMoreButton) {
21730
+ <div class="infinite-list-load-more">
21731
+ @if (hasMoreBottom()) {
21732
+ <ion-button
21733
+ fill="outline"
21734
+ [color]="mergedProps.color"
21735
+ [disabled]="state() === 'loading'"
21736
+ (click)="loadBottom()"
21737
+ >
21738
+ @if (state() === 'loading') {
21739
+ <ion-spinner [name]="mergedProps.spinnerType" slot="start"></ion-spinner>
21740
+ }
21741
+ {{ mergedProps.loadMoreText || 'Cargar mas' }}
21742
+ </ion-button>
21743
+ } @else {
21744
+ <ion-text color="medium">{{ mergedProps.noMoreText || 'No hay mas items' }}</ion-text>
21745
+ }
21746
+ </div>
21747
+ } @else {
21748
+ <ion-infinite-scroll
21749
+ [threshold]="mergedProps.threshold"
21750
+ [disabled]="!hasMoreBottom()"
21751
+ (ionInfinite)="onInfiniteScroll($event)"
21752
+ >
21753
+ <ion-infinite-scroll-content
21754
+ [loadingSpinner]="mergedProps.spinnerType"
21755
+ [loadingText]="state() === 'loading' ? 'Cargando...' : ''"
21756
+ ></ion-infinite-scroll-content>
21757
+ </ion-infinite-scroll>
21758
+ }
21759
+ }
21760
+
21761
+ <!-- No more items indicator -->
21762
+ @if (!hasMoreBottom() && items().length > 0 && !mergedProps.useLoadMoreButton) {
21763
+ <div class="infinite-list-end">
21764
+ <ion-text color="medium">{{ mergedProps.noMoreText || 'No hay mas items' }}</ion-text>
21765
+ </div>
21766
+ }
21767
+ </div>
21768
+ </ng-template>
21769
+
21770
+ <!-- Live region for accessibility announcements -->
21771
+ <div class="sr-only" role="status" aria-live="polite" [attr.aria-atomic]="true">
21772
+ {{ statusAnnouncement() }}
21773
+ </div>
21774
+ `, isInline: true, styles: [":host{display:block}.infinite-list-container{width:100%}.infinite-list-skeleton,.infinite-list-empty,.infinite-list-error{padding:24px 16px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px}.infinite-list-empty ion-icon,.infinite-list-error ion-icon{font-size:48px;opacity:.6}.infinite-list-empty h3,.infinite-list-error h3{margin:0;font-size:18px;font-weight:600}.infinite-list-empty p,.infinite-list-error p{margin:0;color:var(--ion-color-medium);font-size:14px}.infinite-list-items{&.with-dividers .infinite-list-item:not(:last-child){border-bottom:1px solid var(--ion-color-light-shade, #d7d8da)}}.infinite-list-load-more{display:flex;justify-content:center;padding:16px}.infinite-list-end{display:flex;justify-content:center;padding:16px;font-size:14px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: IonInfiniteScroll, selector: "ion-infinite-scroll", inputs: ["disabled", "position", "threshold"] }, { kind: "component", type: IonInfiniteScrollContent, selector: "ion-infinite-scroll-content", inputs: ["loadingSpinner", "loadingText"] }, { 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: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonText, selector: "ion-text", inputs: ["color", "mode"] }, { kind: "component", type: RefresherComponent, selector: "val-refresher", inputs: ["props"], outputs: ["refresh", "pullProgressChange", "stateChange"] }] }); }
21775
+ }
21776
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: InfiniteListComponent, decorators: [{
21777
+ type: Component,
21778
+ args: [{ selector: 'val-infinite-list', standalone: true, imports: [
21779
+ CommonModule,
21780
+ IonInfiniteScroll,
21781
+ IonInfiniteScrollContent,
21782
+ IonButton,
21783
+ IonSpinner,
21784
+ IonIcon,
21785
+ IonText,
21786
+ IonList,
21787
+ IonItem,
21788
+ RefresherComponent,
21789
+ ], template: `
21790
+ <!-- Pull to refresh wrapper -->
21791
+ @if (mergedProps.enableRefresh) {
21792
+ <val-refresher [props]="refresherConfig" (refresh)="onRefreshTriggered($event)">
21793
+ <ng-container *ngTemplateOutlet="listContent"></ng-container>
21794
+ </val-refresher>
21795
+ } @else {
21796
+ <ng-container *ngTemplateOutlet="listContent"></ng-container>
21797
+ }
21798
+
21799
+ <!-- Main list content template -->
21800
+ <ng-template #listContent>
21801
+ <div
21802
+ class="infinite-list-container"
21803
+ [class]="mergedProps.cssClass"
21804
+ [style.max-height]="mergedProps.maxHeight"
21805
+ [style.overflow-y]="mergedProps.maxHeight ? 'auto' : 'visible'"
21806
+ role="feed"
21807
+ [attr.aria-busy]="state() === 'loading'"
21808
+ [attr.aria-label]="mergedProps.ariaLabel"
21809
+ [attr.aria-description]="mergedProps.ariaDescription"
21810
+ >
21811
+ <!-- Loading state (initial) -->
21812
+ @if (state() === 'loading' && items().length === 0) {
21813
+ <div class="infinite-list-skeleton">
21814
+ @if (mergedProps.skeleton?.customTemplate) {
21815
+ <ng-container *ngTemplateOutlet="mergedProps.skeleton.customTemplate"></ng-container>
21816
+ } @else {
21817
+ <ng-container *ngComponentOutlet="skeletonComponent; inputs: skeletonInputs"></ng-container>
21818
+ }
21819
+ </div>
21820
+ }
21821
+
21822
+ <!-- Empty state -->
21823
+ @if (state() === 'idle' && items().length === 0 && !isInitialLoad()) {
21824
+ <div class="infinite-list-empty">
21825
+ @if (mergedProps.emptyState?.template) {
21826
+ <ng-container *ngTemplateOutlet="mergedProps.emptyState.template"></ng-container>
21827
+ } @else {
21828
+ @if (mergedProps.emptyState?.icon) {
21829
+ <ion-icon [name]="mergedProps.emptyState.icon" size="large"></ion-icon>
21830
+ }
21831
+ @if (mergedProps.emptyState?.title) {
21832
+ <h3>{{ mergedProps.emptyState.title }}</h3>
21833
+ }
21834
+ @if (mergedProps.emptyState?.message) {
21835
+ <p>{{ mergedProps.emptyState.message }}</p>
21836
+ }
21837
+ }
21838
+ </div>
21839
+ }
21840
+
21841
+ <!-- Error state -->
21842
+ @if (state() === 'error') {
21843
+ <div class="infinite-list-error">
21844
+ @if (mergedProps.errorState?.template) {
21845
+ <ng-container
21846
+ *ngTemplateOutlet="mergedProps.errorState.template; context: { error: error(), retry: retry.bind(this) }"
21847
+ ></ng-container>
21848
+ } @else {
21849
+ @if (mergedProps.errorState?.icon) {
21850
+ <ion-icon [name]="mergedProps.errorState.icon" color="danger" size="large"></ion-icon>
21851
+ } @else {
21852
+ <ion-icon name="alert-circle-outline" color="danger" size="large"></ion-icon>
21853
+ }
21854
+ <h3>{{ mergedProps.errorState?.title || 'Error' }}</h3>
21855
+ <p>{{ mergedProps.errorState?.message || error()?.message || 'Ocurrio un error' }}</p>
21856
+ @if (mergedProps.errorState?.showRetry !== false) {
21857
+ <ion-button fill="outline" (click)="retry()">
21858
+ {{ mergedProps.errorState?.retryText || 'Reintentar' }}
21859
+ </ion-button>
21860
+ }
21861
+ }
21862
+ </div>
21863
+ }
21864
+
21865
+ <!-- Items list -->
21866
+ @if (items().length > 0) {
21867
+ <div class="infinite-list-items" [class.with-dividers]="mergedProps.showDividers">
21868
+ @for (item of items(); track trackByFn($index, item); let i = $index; let first = $first; let last = $last) {
21869
+ <article
21870
+ class="infinite-list-item"
21871
+ [attr.aria-setsize]="mergedProps.dataSource.totalCount || null"
21872
+ [attr.aria-posinset]="i + 1"
21873
+ >
21874
+ <ng-container
21875
+ *ngTemplateOutlet="
21876
+ mergedProps.itemTemplate;
21877
+ context: { $implicit: item, index: i, first: first, last: last, count: items().length }
21878
+ "
21879
+ ></ng-container>
21880
+ </article>
21881
+ }
21882
+ </div>
21883
+ }
21884
+
21885
+ <!-- Bottom infinite scroll -->
21886
+ @if (shouldShowBottomScroll()) {
21887
+ @if (mergedProps.useLoadMoreButton) {
21888
+ <div class="infinite-list-load-more">
21889
+ @if (hasMoreBottom()) {
21890
+ <ion-button
21891
+ fill="outline"
21892
+ [color]="mergedProps.color"
21893
+ [disabled]="state() === 'loading'"
21894
+ (click)="loadBottom()"
21895
+ >
21896
+ @if (state() === 'loading') {
21897
+ <ion-spinner [name]="mergedProps.spinnerType" slot="start"></ion-spinner>
21898
+ }
21899
+ {{ mergedProps.loadMoreText || 'Cargar mas' }}
21900
+ </ion-button>
21901
+ } @else {
21902
+ <ion-text color="medium">{{ mergedProps.noMoreText || 'No hay mas items' }}</ion-text>
21903
+ }
21904
+ </div>
21905
+ } @else {
21906
+ <ion-infinite-scroll
21907
+ [threshold]="mergedProps.threshold"
21908
+ [disabled]="!hasMoreBottom()"
21909
+ (ionInfinite)="onInfiniteScroll($event)"
21910
+ >
21911
+ <ion-infinite-scroll-content
21912
+ [loadingSpinner]="mergedProps.spinnerType"
21913
+ [loadingText]="state() === 'loading' ? 'Cargando...' : ''"
21914
+ ></ion-infinite-scroll-content>
21915
+ </ion-infinite-scroll>
21916
+ }
21917
+ }
21918
+
21919
+ <!-- No more items indicator -->
21920
+ @if (!hasMoreBottom() && items().length > 0 && !mergedProps.useLoadMoreButton) {
21921
+ <div class="infinite-list-end">
21922
+ <ion-text color="medium">{{ mergedProps.noMoreText || 'No hay mas items' }}</ion-text>
21923
+ </div>
21924
+ }
21925
+ </div>
21926
+ </ng-template>
21927
+
21928
+ <!-- Live region for accessibility announcements -->
21929
+ <div class="sr-only" role="status" aria-live="polite" [attr.aria-atomic]="true">
21930
+ {{ statusAnnouncement() }}
21931
+ </div>
21932
+ `, styles: [":host{display:block}.infinite-list-container{width:100%}.infinite-list-skeleton,.infinite-list-empty,.infinite-list-error{padding:24px 16px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px}.infinite-list-empty ion-icon,.infinite-list-error ion-icon{font-size:48px;opacity:.6}.infinite-list-empty h3,.infinite-list-error h3{margin:0;font-size:18px;font-weight:600}.infinite-list-empty p,.infinite-list-error p{margin:0;color:var(--ion-color-medium);font-size:14px}.infinite-list-items{&.with-dividers .infinite-list-item:not(:last-child){border-bottom:1px solid var(--ion-color-light-shade, #d7d8da)}}.infinite-list-load-more{display:flex;justify-content:center;padding:16px}.infinite-list-end{display:flex;justify-content:center;padding:16px;font-size:14px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"] }]
21933
+ }], propDecorators: { infiniteScroll: [{
21934
+ type: ViewChild,
21935
+ args: [IonInfiniteScroll]
21936
+ }], props: [{
21937
+ type: Input
21938
+ }], loadMore: [{
21939
+ type: Output
21940
+ }], refresh: [{
21941
+ type: Output
21942
+ }], stateChange: [{
21943
+ type: Output
21944
+ }], itemsChange: [{
21945
+ type: Output
21946
+ }], errorOccurred: [{
21947
+ type: Output
21948
+ }] } });
21949
+
21950
+ class LayoutComponent {
21951
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LayoutComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
21952
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: LayoutComponent, isStandalone: true, selector: "val-layout", ngImport: i0, template: `
21953
+ <div class="layout-container">
21954
+ <ng-content></ng-content>
21955
+ </div>
21956
+ `, isInline: true, styles: [":root{--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)}@media (prefers-color-scheme: dark){:root{--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)}.layout-container{margin:0 auto;padding:0;width:100%;box-sizing:border-box;margin-bottom:1rem;padding-top:.5rem}@media (max-width: 768px){.layout-container{max-width:100%}}@media (min-width: 768px){.layout-container{margin:0 auto;max-width:33.75rem;margin-bottom:1.5rem}}@media (min-width: 1200px){.layout-container{margin:0 auto;max-width:45rem}}\n"] }); }
21957
+ }
21958
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LayoutComponent, decorators: [{
21959
+ type: Component,
21960
+ args: [{ selector: 'val-layout', standalone: true, imports: [], template: `
21961
+ <div class="layout-container">
21962
+ <ng-content></ng-content>
21963
+ </div>
21964
+ `, styles: [":root{--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)}@media (prefers-color-scheme: dark){:root{--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)}.layout-container{margin:0 auto;padding:0;width:100%;box-sizing:border-box;margin-bottom:1rem;padding-top:.5rem}@media (max-width: 768px){.layout-container{max-width:100%}}@media (min-width: 768px){.layout-container{margin:0 auto;max-width:33.75rem;margin-bottom:1.5rem}}@media (min-width: 1200px){.layout-container{margin:0 auto;max-width:45rem}}\n"] }]
21965
+ }] });
21966
+
21967
+ class SimpleComponent {
21968
+ constructor() {
21969
+ this.onClick = new EventEmitter();
21970
+ this.onScrollEvent = new EventEmitter();
21971
+ this.theme = inject(ThemeService);
21972
+ }
21973
+ onClickHandler(token) {
21974
+ this.onClick.emit(token);
21975
+ }
21976
+ onScrollHandler($event) {
21977
+ this.onScrollEvent.emit(true);
21978
+ }
21979
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
21980
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SimpleComponent, isStandalone: true, selector: "val-simple", inputs: { props: "props" }, outputs: { onClick: "onClick", onScrollEvent: "onScrollEvent" }, ngImport: i0, template: `
21981
+ <val-header [props]="props.header" />
21982
+
21983
+ <ion-content
21984
+ [fullscreen]="true"
21985
+ [ngStyle]="{
21986
+ '--background': theme.IsDark ? 'var(--ion-background-color)' : props.background,
21987
+ }"
21988
+ [scrollEvents]="true"
21989
+ (ionScroll)="onScrollHandler($event)"
21990
+ >
21991
+ <ion-header collapse="condense">
21992
+ <ion-toolbar style="--background: transparent;">
21993
+ <ion-title style="padding: 0;" size="large">{{ props.pageTitle }}</ion-title>
21994
+ </ion-toolbar>
21995
+ </ion-header>
21996
+ @if (props.pageDescription) {
21997
+ <div class="description-container">
21998
+ <val-expandable-text
21999
+ [props]="{
22000
+ limit: 180,
22001
+ content: props.pageDescription,
22002
+ color: 'primary',
22003
+ expandText: 'más',
22004
+ }"
22005
+ />
22006
+ </div>
22007
+ }
22008
+ @if (props.link) {
22009
+ <val-link [props]="props.link" (onClick)="onClickHandler($event)"></val-link>
22010
+ }
22011
+ @if (props.withDivider) {
22012
+ <val-divider [props]="{ fill: 'solid', size: 'medium', color: 'dark' }" />
22013
+ }
22014
+ <val-layout>
22015
+ <ng-content select="[inner-container]"></ng-content>
22016
+ </val-layout>
22017
+ </ion-content>
22018
+ <ng-content select="[outter-container]"></ng-content>
22019
+ `, isInline: true, styles: [".description-container{padding:0 16px}\n"], dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonTitle, selector: "ion-title", inputs: ["color", "size"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: HeaderComponent, selector: "val-header", inputs: ["props"], outputs: ["onClick"] }, { kind: "component", type: LayoutComponent, selector: "val-layout" }, { kind: "component", type: DividerComponent, selector: "val-divider", inputs: ["props"] }, { kind: "component", type: LinkComponent, selector: "val-link", inputs: ["props"], outputs: ["onClick"] }, { kind: "component", type: ExpandableTextComponent, selector: "val-expandable-text", inputs: ["props"] }] }); }
22020
+ }
22021
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleComponent, decorators: [{
22022
+ type: Component,
22023
+ args: [{ selector: 'val-simple', standalone: true, imports: [
22024
+ NgStyle,
22025
+ IonHeader,
22026
+ IonToolbar,
22027
+ IonTitle,
22028
+ IonContent,
22029
+ HeaderComponent,
22030
+ LayoutComponent,
22031
+ DividerComponent,
22032
+ LinkComponent,
22033
+ ExpandableTextComponent,
22034
+ ], template: `
22035
+ <val-header [props]="props.header" />
22036
+
22037
+ <ion-content
22038
+ [fullscreen]="true"
22039
+ [ngStyle]="{
22040
+ '--background': theme.IsDark ? 'var(--ion-background-color)' : props.background,
22041
+ }"
22042
+ [scrollEvents]="true"
22043
+ (ionScroll)="onScrollHandler($event)"
22044
+ >
22045
+ <ion-header collapse="condense">
22046
+ <ion-toolbar style="--background: transparent;">
22047
+ <ion-title style="padding: 0;" size="large">{{ props.pageTitle }}</ion-title>
22048
+ </ion-toolbar>
22049
+ </ion-header>
22050
+ @if (props.pageDescription) {
22051
+ <div class="description-container">
22052
+ <val-expandable-text
22053
+ [props]="{
22054
+ limit: 180,
22055
+ content: props.pageDescription,
22056
+ color: 'primary',
22057
+ expandText: 'más',
22058
+ }"
22059
+ />
22060
+ </div>
22061
+ }
22062
+ @if (props.link) {
22063
+ <val-link [props]="props.link" (onClick)="onClickHandler($event)"></val-link>
22064
+ }
22065
+ @if (props.withDivider) {
22066
+ <val-divider [props]="{ fill: 'solid', size: 'medium', color: 'dark' }" />
22067
+ }
22068
+ <val-layout>
22069
+ <ng-content select="[inner-container]"></ng-content>
22070
+ </val-layout>
22071
+ </ion-content>
22072
+ <ng-content select="[outter-container]"></ng-content>
22073
+ `, styles: [".description-container{padding:0 16px}\n"] }]
22074
+ }], propDecorators: { props: [{
22075
+ type: Input
22076
+ }], onClick: [{
22077
+ type: Output
22078
+ }], onScrollEvent: [{
22079
+ type: Output
22080
+ }] } });
22081
+
22082
+ /**
22083
+ * val-page-template
22084
+ *
22085
+ * A page template component with title, expandable description,
22086
+ * content projection, and optional back navigation button.
22087
+ *
22088
+ * @example
22089
+ * <val-page-template
22090
+ * [props]="{
22091
+ * pageTitle: 'Getting Started',
22092
+ * pageDescription: 'Learn how to use our components...',
22093
+ * showBackButton: true
22094
+ * }"
22095
+ * >
22096
+ * <div extra-description>
22097
+ * <p>Additional info here</p>
22098
+ * </div>
22099
+ *
22100
+ * <!-- Main content -->
22101
+ * <my-content></my-content>
22102
+ *
22103
+ * <div extra-footer>
22104
+ * <p>Footer content</p>
22105
+ * </div>
22106
+ * </val-page-template>
22107
+ *
22108
+ * @input props - Page template configuration
22109
+ * @output onBack - Emits when back button is clicked
22110
+ */
22111
+ class PageTemplateComponent {
22112
+ constructor() {
22113
+ this.nav = inject(NavController);
22114
+ /**
22115
+ * Page template configuration.
22116
+ */
22117
+ this.props = {};
22118
+ /**
22119
+ * Emits when the back button is clicked.
22120
+ */
22121
+ this.onBack = new EventEmitter();
22122
+ }
22123
+ /**
22124
+ * Handles back navigation.
22125
+ */
22126
+ handleBack() {
22127
+ this.onBack.emit();
22128
+ this.nav.back();
22129
+ }
22130
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PageTemplateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
22131
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: PageTemplateComponent, isStandalone: true, selector: "val-page-template", inputs: { props: "props" }, outputs: { onBack: "onBack" }, ngImport: i0, template: `
22132
+ @if (props.pageTitle) {
22133
+ <ion-header [class.ion-no-border]="true">
22134
+ <ion-toolbar style="--background: transparent;">
22135
+ <ion-title class="page-title" size="large" style="--padding-start: 0; --padding-end: 0;">{{ props.pageTitle }}</ion-title>
22136
+ </ion-toolbar>
22137
+ </ion-header>
22138
+ }
22139
+ <ion-grid>
22140
+ <ion-row class="ion-justify-content-center description-row">
22141
+ <ion-col size="12" size-md="10" size-lg="8">
22142
+ @if (props.pageDescription) {
22143
+ <div class="description-container">
22144
+ <val-expandable-text
22145
+ [props]="{
22146
+ limit: props.descriptionLimit || 180,
21054
22147
  content: props.pageDescription,
21055
22148
  color: props.descriptionColor || 'dark',
21056
22149
  }"
@@ -21104,7 +22197,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
21104
22197
  @if (props.pageTitle) {
21105
22198
  <ion-header [class.ion-no-border]="true">
21106
22199
  <ion-toolbar style="--background: transparent;">
21107
- <ion-title class="page-title" size="large">{{ props.pageTitle }}</ion-title>
22200
+ <ion-title class="page-title" size="large" style="--padding-start: 0; --padding-end: 0;">{{ props.pageTitle }}</ion-title>
21108
22201
  </ion-toolbar>
21109
22202
  </ion-header>
21110
22203
  }
@@ -27946,913 +29039,2337 @@ class AuthService {
27946
29039
  }
27947
29040
  }
27948
29041
  /**
27949
- * Elimina el dispositivo del backend y borra el token FCM.
29042
+ * Elimina el dispositivo del backend y borra el token FCM.
29043
+ */
29044
+ async unregisterDevice() {
29045
+ if (!this.config.enableDeviceRegistration || !this.messagingService) {
29046
+ return;
29047
+ }
29048
+ try {
29049
+ const token = this.messagingService.currentToken;
29050
+ if (token) {
29051
+ // Delete from backend (fire and forget)
29052
+ this.http.request('DELETE', `${this.config.apiUrl}/v2/users/me/devices/by-token`, {
29053
+ body: { token }
29054
+ }).pipe(catchError(() => of(null))).subscribe();
29055
+ // Delete from FCM
29056
+ await this.messagingService.deleteToken();
29057
+ console.log('[ValtechAuth] Device unregistered');
29058
+ }
29059
+ }
29060
+ catch {
29061
+ // Ignorar errores en cleanup
29062
+ }
29063
+ }
29064
+ /**
29065
+ * Detecta información de la plataforma del dispositivo.
29066
+ */
29067
+ detectPlatformInfo() {
29068
+ const ua = navigator.userAgent;
29069
+ // Detectar navegador
29070
+ let browser = 'Unknown';
29071
+ if (ua.includes('Firefox')) {
29072
+ browser = 'Firefox';
29073
+ }
29074
+ else if (ua.includes('Edg')) {
29075
+ browser = 'Edge';
29076
+ }
29077
+ else if (ua.includes('Chrome')) {
29078
+ browser = 'Chrome';
29079
+ }
29080
+ else if (ua.includes('Safari')) {
29081
+ browser = 'Safari';
29082
+ }
29083
+ // Detectar OS
29084
+ let os = 'Unknown';
29085
+ if (ua.includes('Windows')) {
29086
+ os = 'Windows';
29087
+ }
29088
+ else if (ua.includes('Mac OS')) {
29089
+ os = 'macOS';
29090
+ }
29091
+ else if (ua.includes('Linux')) {
29092
+ os = 'Linux';
29093
+ }
29094
+ else if (ua.includes('Android')) {
29095
+ os = 'Android';
29096
+ }
29097
+ else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) {
29098
+ os = 'iOS';
29099
+ }
29100
+ return { platform: 'web', browser, os };
29101
+ }
29102
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$8.HttpClient }, { token: i1$1.Router }, { token: AuthStateService }, { token: TokenService }, { token: AuthStorageService }, { token: AuthSyncService }, { token: FirebaseService }, { token: OAuthService }, { token: MessagingService, optional: true }, { token: I18nService, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
29103
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, providedIn: 'root' }); }
29104
+ }
29105
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, decorators: [{
29106
+ type: Injectable,
29107
+ args: [{ providedIn: 'root' }]
29108
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
29109
+ type: Inject,
29110
+ args: [VALTECH_AUTH_CONFIG]
29111
+ }] }, { type: i1$8.HttpClient }, { type: i1$1.Router }, { type: AuthStateService }, { type: TokenService }, { type: AuthStorageService }, { type: AuthSyncService }, { type: FirebaseService }, { type: OAuthService }, { type: MessagingService, decorators: [{
29112
+ type: Optional
29113
+ }] }, { type: I18nService, decorators: [{
29114
+ type: Optional
29115
+ }] }] });
29116
+
29117
+ /**
29118
+ * Notifications Service
29119
+ *
29120
+ * Servicio para leer notificaciones desde Firestore.
29121
+ * El backend escribe las notificaciones, el frontend solo las lee y actualiza estado.
29122
+ *
29123
+ * Se auto-inicializa cuando AuthService tiene un usuario autenticado.
29124
+ * También puede inicializarse manualmente con `initialize(userId)`.
29125
+ */
29126
+ /**
29127
+ * Servicio para leer notificaciones desde Firestore.
29128
+ *
29129
+ * Se auto-inicializa cuando AuthService tiene un usuario autenticado.
29130
+ * No requiere llamar a `initialize()` manualmente si AuthService está configurado.
29131
+ *
29132
+ * @example
29133
+ * ```typescript
29134
+ * // Con AuthService configurado: auto-inicialización
29135
+ * // Solo inyectar y usar directamente
29136
+ * private notifications = inject(NotificationsService);
29137
+ *
29138
+ * notifications$ = this.notifications.getAll();
29139
+ * unreadCount$ = this.notifications.getUnreadCount();
29140
+ *
29141
+ * // Sin AuthService: inicialización manual
29142
+ * this.notifications.initialize(userId);
29143
+ * ```
29144
+ */
29145
+ class NotificationsService {
29146
+ constructor(injector, collectionFactory) {
29147
+ this.injector = injector;
29148
+ this.collectionFactory = collectionFactory;
29149
+ this.collection = null;
29150
+ this.currentUserId = null;
29151
+ // Inyección opcional - AuthService puede no estar configurado
29152
+ this.authService = null;
29153
+ // Intentar obtener AuthService de forma lazy (puede no estar configurado)
29154
+ this.setupAutoInitialization();
29155
+ }
29156
+ /**
29157
+ * Configura auto-inicialización observando el estado de AuthService.
29158
+ * Se ejecuta en el contexto del injector para poder usar effect().
29159
+ */
29160
+ setupAutoInitialization() {
29161
+ // Obtener AuthService de forma lazy
29162
+ try {
29163
+ this.authService = this.injector.get(AuthService, null);
29164
+ }
29165
+ catch {
29166
+ // AuthService no está configurado, no hay auto-inicialización
29167
+ return;
29168
+ }
29169
+ if (!this.authService) {
29170
+ return;
29171
+ }
29172
+ // Usar runInInjectionContext para poder crear el effect
29173
+ runInInjectionContext(this.injector, () => {
29174
+ effect(() => {
29175
+ const user = this.authService.user();
29176
+ if (user?.userId && this.currentUserId !== user.userId) {
29177
+ // Usuario autenticado: inicializar con su ID
29178
+ this.initialize(user.userId);
29179
+ }
29180
+ else if (!user && this.currentUserId) {
29181
+ // Logout: limpiar estado
29182
+ this.reset();
29183
+ }
29184
+ });
29185
+ });
29186
+ }
29187
+ /**
29188
+ * Inicializa el servicio para un usuario específico.
29189
+ *
29190
+ * NOTA: Se llama automáticamente si AuthService está configurado.
29191
+ * Solo usar manualmente si AuthService no está disponible o se necesita
29192
+ * un userId diferente al del usuario autenticado.
29193
+ */
29194
+ initialize(userId) {
29195
+ if (!this.collectionFactory) {
29196
+ console.warn('[Notifications] FirestoreCollectionFactory not available. Ensure provideValtechFirebase() is configured.');
29197
+ return;
29198
+ }
29199
+ this.currentUserId = userId;
29200
+ // Path relativo - FirestoreService agrega automáticamente apps/{appId}/
29201
+ // NO agregar apps/ aquí para evitar doble prefijo
29202
+ const basePath = `users/${userId}/notifications`;
29203
+ this.collection = this.collectionFactory.create(basePath, { timestamps: true });
29204
+ console.log('[Notifications] Initialized with path:', basePath);
29205
+ }
29206
+ /**
29207
+ * Verifica si el servicio está inicializado.
29208
+ */
29209
+ get isReady() {
29210
+ return this.collection !== null;
29211
+ }
29212
+ /**
29213
+ * Obtiene el ID del usuario actual.
29214
+ */
29215
+ get userId() {
29216
+ return this.currentUserId;
29217
+ }
29218
+ // ===========================================================================
29219
+ // LECTURA
29220
+ // ===========================================================================
29221
+ /**
29222
+ * Obtiene todas las notificaciones ordenadas por fecha descendente.
29223
+ * Real-time: se actualiza automáticamente cuando cambian los datos.
29224
+ */
29225
+ getAll() {
29226
+ if (!this.collection)
29227
+ return of([]);
29228
+ return this.collection.watchAll({
29229
+ orderBy: [{ field: 'createdAt', direction: 'desc' }],
29230
+ });
29231
+ }
29232
+ /**
29233
+ * Obtiene solo notificaciones no leídas.
29234
+ */
29235
+ getUnread() {
29236
+ return this.getAll().pipe(map$1(notifications => notifications.filter(n => !n.isRead)));
29237
+ }
29238
+ /**
29239
+ * Cuenta notificaciones no leídas.
29240
+ * Útil para badges en UI.
29241
+ */
29242
+ getUnreadCount() {
29243
+ return this.getUnread().pipe(map$1(n => n.length));
29244
+ }
29245
+ /**
29246
+ * Obtiene una notificación por ID.
29247
+ */
29248
+ async getById(notificationId) {
29249
+ if (!this.collection)
29250
+ return null;
29251
+ return this.collection.getById(notificationId);
29252
+ }
29253
+ // ===========================================================================
29254
+ // ACCIONES
29255
+ // ===========================================================================
29256
+ /**
29257
+ * Marca una notificación como leída.
29258
+ */
29259
+ async markAsRead(notificationId) {
29260
+ if (!this.collection)
29261
+ return;
29262
+ await this.collection.update(notificationId, { isRead: true });
29263
+ }
29264
+ /**
29265
+ * Marca todas las notificaciones como leídas.
27950
29266
  */
27951
- async unregisterDevice() {
27952
- if (!this.config.enableDeviceRegistration || !this.messagingService) {
29267
+ async markAllAsRead() {
29268
+ if (!this.collection)
27953
29269
  return;
27954
- }
27955
- try {
27956
- const token = this.messagingService.currentToken;
27957
- if (token) {
27958
- // Delete from backend (fire and forget)
27959
- this.http.request('DELETE', `${this.config.apiUrl}/v2/users/me/devices/by-token`, {
27960
- body: { token }
27961
- }).pipe(catchError(() => of(null))).subscribe();
27962
- // Delete from FCM
27963
- await this.messagingService.deleteToken();
27964
- console.log('[ValtechAuth] Device unregistered');
27965
- }
27966
- }
27967
- catch {
27968
- // Ignorar errores en cleanup
27969
- }
29270
+ const unread = await this.collection.query({
29271
+ where: [{ field: 'isRead', operator: '==', value: false }],
29272
+ });
29273
+ await Promise.all(unread.map(n => this.collection.update(n.id, { isRead: true })));
27970
29274
  }
27971
29275
  /**
27972
- * Detecta información de la plataforma del dispositivo.
29276
+ * Elimina una notificación.
27973
29277
  */
27974
- detectPlatformInfo() {
27975
- const ua = navigator.userAgent;
27976
- // Detectar navegador
27977
- let browser = 'Unknown';
27978
- if (ua.includes('Firefox')) {
27979
- browser = 'Firefox';
27980
- }
27981
- else if (ua.includes('Edg')) {
27982
- browser = 'Edge';
27983
- }
27984
- else if (ua.includes('Chrome')) {
27985
- browser = 'Chrome';
27986
- }
27987
- else if (ua.includes('Safari')) {
27988
- browser = 'Safari';
27989
- }
27990
- // Detectar OS
27991
- let os = 'Unknown';
27992
- if (ua.includes('Windows')) {
27993
- os = 'Windows';
27994
- }
27995
- else if (ua.includes('Mac OS')) {
27996
- os = 'macOS';
27997
- }
27998
- else if (ua.includes('Linux')) {
27999
- os = 'Linux';
28000
- }
28001
- else if (ua.includes('Android')) {
28002
- os = 'Android';
28003
- }
28004
- else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) {
28005
- os = 'iOS';
28006
- }
28007
- return { platform: 'web', browser, os };
29278
+ async delete(notificationId) {
29279
+ if (!this.collection)
29280
+ return;
29281
+ await this.collection.delete(notificationId);
28008
29282
  }
28009
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$8.HttpClient }, { token: i1$1.Router }, { token: AuthStateService }, { token: TokenService }, { token: AuthStorageService }, { token: AuthSyncService }, { token: FirebaseService }, { token: OAuthService }, { token: MessagingService, optional: true }, { token: I18nService, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
28010
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, providedIn: 'root' }); }
29283
+ /**
29284
+ * Elimina todas las notificaciones del usuario.
29285
+ */
29286
+ async deleteAll() {
29287
+ if (!this.collection)
29288
+ return;
29289
+ const all = await this.collection.getAll();
29290
+ await Promise.all(all.map(n => this.collection.delete(n.id)));
29291
+ }
29292
+ /**
29293
+ * Limpia el estado del servicio.
29294
+ * Útil para logout.
29295
+ */
29296
+ reset() {
29297
+ this.collection = null;
29298
+ this.currentUserId = null;
29299
+ }
29300
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsService, deps: [{ token: i0.Injector }, { token: FirestoreCollectionFactory, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
29301
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsService, providedIn: 'root' }); }
28011
29302
  }
28012
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, decorators: [{
29303
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsService, decorators: [{
28013
29304
  type: Injectable,
28014
29305
  args: [{ providedIn: 'root' }]
28015
- }], ctorParameters: () => [{ type: undefined, decorators: [{
28016
- type: Inject,
28017
- args: [VALTECH_AUTH_CONFIG]
28018
- }] }, { type: i1$8.HttpClient }, { type: i1$1.Router }, { type: AuthStateService }, { type: TokenService }, { type: AuthStorageService }, { type: AuthSyncService }, { type: FirebaseService }, { type: OAuthService }, { type: MessagingService, decorators: [{
28019
- type: Optional
28020
- }] }, { type: I18nService, decorators: [{
29306
+ }], ctorParameters: () => [{ type: i0.Injector }, { type: FirestoreCollectionFactory, decorators: [{
28021
29307
  type: Optional
28022
29308
  }] }] });
28023
29309
 
28024
29310
  /**
28025
- * Notifications Service
29311
+ * Analytics Types
29312
+ *
29313
+ * Tipos e interfaces para el servicio de Firebase Analytics (GA4).
29314
+ */
29315
+
29316
+ /**
29317
+ * Firebase Services
29318
+ *
29319
+ * Servicios reutilizables para integración con Firebase.
29320
+ *
29321
+ * @example
29322
+ * ```typescript
29323
+ * // En main.ts
29324
+ * import { provideValtechFirebase } from 'valtech-components';
29325
+ *
29326
+ * bootstrapApplication(AppComponent, {
29327
+ * providers: [
29328
+ * provideValtechFirebase({
29329
+ * firebase: environment.firebase,
29330
+ * persistence: true,
29331
+ * }),
29332
+ * ],
29333
+ * });
29334
+ *
29335
+ * // En componentes
29336
+ * import { FirebaseService, FirestoreService } from 'valtech-components';
29337
+ *
29338
+ * @Component({...})
29339
+ * export class MyComponent {
29340
+ * private firebase = inject(FirebaseService);
29341
+ * private firestore = inject(FirestoreService);
29342
+ * }
29343
+ * ```
29344
+ */
29345
+ // Tipos
29346
+
29347
+ /**
29348
+ * Guard que verifica si el usuario está autenticado.
29349
+ * Redirige a loginRoute si no está autenticado.
29350
+ *
29351
+ * @example
29352
+ * ```typescript
29353
+ * import { authGuard } from 'valtech-components';
29354
+ *
29355
+ * const routes: Routes = [
29356
+ * {
29357
+ * path: 'dashboard',
29358
+ * canActivate: [authGuard],
29359
+ * loadComponent: () => import('./dashboard.page'),
29360
+ * },
29361
+ * ];
29362
+ * ```
29363
+ */
29364
+ const authGuard = () => {
29365
+ const authService = inject(AuthService);
29366
+ const router = inject(Router);
29367
+ const config = inject(VALTECH_AUTH_CONFIG);
29368
+ if (authService.isAuthenticated()) {
29369
+ return true;
29370
+ }
29371
+ return router.createUrlTree([config.loginRoute]);
29372
+ };
29373
+ /**
29374
+ * Guard que verifica si el usuario NO está autenticado.
29375
+ * Redirige a homeRoute si ya está autenticado.
29376
+ * Útil para páginas de login/registro.
29377
+ *
29378
+ * @example
29379
+ * ```typescript
29380
+ * import { guestGuard } from 'valtech-components';
29381
+ *
29382
+ * const routes: Routes = [
29383
+ * {
29384
+ * path: 'login',
29385
+ * canActivate: [guestGuard],
29386
+ * loadComponent: () => import('./login.page'),
29387
+ * },
29388
+ * ];
29389
+ * ```
29390
+ */
29391
+ const guestGuard = () => {
29392
+ const authService = inject(AuthService);
29393
+ const router = inject(Router);
29394
+ const config = inject(VALTECH_AUTH_CONFIG);
29395
+ if (!authService.isAuthenticated()) {
29396
+ return true;
29397
+ }
29398
+ return router.createUrlTree([config.homeRoute]);
29399
+ };
29400
+ /**
29401
+ * Factory para crear guard de permisos.
29402
+ * Verifica si el usuario tiene el permiso especificado.
29403
+ *
29404
+ * @param permissions - Permiso o lista de permisos requeridos (OR)
29405
+ * @returns Guard function
29406
+ *
29407
+ * @example
29408
+ * ```typescript
29409
+ * import { authGuard, permissionGuard } from 'valtech-components';
29410
+ *
29411
+ * const routes: Routes = [
29412
+ * {
29413
+ * path: 'templates',
29414
+ * canActivate: [authGuard, permissionGuard('templates:read')],
29415
+ * loadComponent: () => import('./templates.page'),
29416
+ * },
29417
+ * {
29418
+ * path: 'admin',
29419
+ * canActivate: [authGuard, permissionGuard(['admin:*', 'super_admin'])],
29420
+ * loadComponent: () => import('./admin.page'),
29421
+ * },
29422
+ * ];
29423
+ * ```
29424
+ */
29425
+ function permissionGuard(permissions) {
29426
+ return () => {
29427
+ const authService = inject(AuthService);
29428
+ const router = inject(Router);
29429
+ const config = inject(VALTECH_AUTH_CONFIG);
29430
+ const permArray = Array.isArray(permissions) ? permissions : [permissions];
29431
+ if (authService.hasAnyPermission(permArray)) {
29432
+ return true;
29433
+ }
29434
+ console.warn(`[ValtechAuth] Permiso denegado. Requerido: ${permArray.join(' o ')}`);
29435
+ return router.createUrlTree([config.unauthorizedRoute]);
29436
+ };
29437
+ }
29438
+ /**
29439
+ * Guard que lee permisos desde route.data.
29440
+ * Permite configurar permisos directamente en la definición de rutas.
29441
+ *
29442
+ * @example
29443
+ * ```typescript
29444
+ * import { authGuard, permissionGuardFromRoute } from 'valtech-components';
29445
+ *
29446
+ * const routes: Routes = [
29447
+ * {
29448
+ * path: 'admin/users',
29449
+ * canActivate: [authGuard, permissionGuardFromRoute],
29450
+ * data: {
29451
+ * permissions: ['users:read', 'users:manage'],
29452
+ * requireAll: false // true = AND, false = OR (default)
29453
+ * },
29454
+ * loadComponent: () => import('./users.page'),
29455
+ * },
29456
+ * ];
29457
+ * ```
29458
+ */
29459
+ const permissionGuardFromRoute = (route) => {
29460
+ const authService = inject(AuthService);
29461
+ const router = inject(Router);
29462
+ const config = inject(VALTECH_AUTH_CONFIG);
29463
+ const permissions = route.data['permissions'];
29464
+ const requireAll = route.data['requireAll'];
29465
+ if (!permissions || permissions.length === 0) {
29466
+ return true;
29467
+ }
29468
+ const hasAccess = requireAll
29469
+ ? authService.hasAllPermissions(permissions)
29470
+ : authService.hasAnyPermission(permissions);
29471
+ if (hasAccess) {
29472
+ return true;
29473
+ }
29474
+ console.warn(`[ValtechAuth] Permiso denegado. Requerido: ${permissions.join(requireAll ? ' y ' : ' o ')}`);
29475
+ return router.createUrlTree([config.unauthorizedRoute]);
29476
+ };
29477
+ /**
29478
+ * Guard que verifica si el usuario es super admin.
28026
29479
  *
28027
- * Servicio para leer notificaciones desde Firestore.
28028
- * El backend escribe las notificaciones, el frontend solo las lee y actualiza estado.
29480
+ * @example
29481
+ * ```typescript
29482
+ * import { authGuard, superAdminGuard } from 'valtech-components';
28029
29483
  *
28030
- * Se auto-inicializa cuando AuthService tiene un usuario autenticado.
28031
- * También puede inicializarse manualmente con `initialize(userId)`.
29484
+ * const routes: Routes = [
29485
+ * {
29486
+ * path: 'super-admin',
29487
+ * canActivate: [authGuard, superAdminGuard],
29488
+ * loadComponent: () => import('./super-admin.page'),
29489
+ * },
29490
+ * ];
29491
+ * ```
28032
29492
  */
29493
+ const superAdminGuard = () => {
29494
+ const authService = inject(AuthService);
29495
+ const router = inject(Router);
29496
+ const config = inject(VALTECH_AUTH_CONFIG);
29497
+ if (authService.isSuperAdmin()) {
29498
+ return true;
29499
+ }
29500
+ console.warn('[ValtechAuth] Acceso de super admin requerido');
29501
+ return router.createUrlTree([config.unauthorizedRoute]);
29502
+ };
28033
29503
  /**
28034
- * Servicio para leer notificaciones desde Firestore.
29504
+ * Guard que verifica si el usuario tiene un rol específico.
28035
29505
  *
28036
- * Se auto-inicializa cuando AuthService tiene un usuario autenticado.
28037
- * No requiere llamar a `initialize()` manualmente si AuthService está configurado.
29506
+ * @param roles - Rol o lista de roles requeridos (OR)
29507
+ * @returns Guard function
28038
29508
  *
28039
29509
  * @example
28040
29510
  * ```typescript
28041
- * // Con AuthService configurado: auto-inicialización
28042
- * // Solo inyectar y usar directamente
28043
- * private notifications = inject(NotificationsService);
29511
+ * import { authGuard, roleGuard } from 'valtech-components';
28044
29512
  *
28045
- * notifications$ = this.notifications.getAll();
28046
- * unreadCount$ = this.notifications.getUnreadCount();
29513
+ * const routes: Routes = [
29514
+ * {
29515
+ * path: 'editor',
29516
+ * canActivate: [authGuard, roleGuard(['editor', 'admin'])],
29517
+ * loadComponent: () => import('./editor.page'),
29518
+ * },
29519
+ * ];
29520
+ * ```
29521
+ */
29522
+ function roleGuard(roles) {
29523
+ return () => {
29524
+ const authService = inject(AuthService);
29525
+ const router = inject(Router);
29526
+ const config = inject(VALTECH_AUTH_CONFIG);
29527
+ const roleArray = Array.isArray(roles) ? roles : [roles];
29528
+ const hasRole = roleArray.some((role) => authService.hasRole(role));
29529
+ if (hasRole) {
29530
+ return true;
29531
+ }
29532
+ console.warn(`[ValtechAuth] Rol requerido: ${roleArray.join(' o ')}`);
29533
+ return router.createUrlTree([config.unauthorizedRoute]);
29534
+ };
29535
+ }
29536
+
29537
+ /**
29538
+ * Servicio para gestión de dispositivos del usuario.
29539
+ * Permite listar, aprobar, bloquear y eliminar dispositivos registrados.
28047
29540
  *
28048
- * // Sin AuthService: inicialización manual
28049
- * this.notifications.initialize(userId);
29541
+ * @example
29542
+ * ```typescript
29543
+ * import { DeviceService } from 'valtech-components';
29544
+ *
29545
+ * @Component({...})
29546
+ * export class DevicesPage {
29547
+ * private deviceService = inject(DeviceService);
29548
+ *
29549
+ * devices = signal<DeviceInfo[]>([]);
29550
+ *
29551
+ * async ngOnInit() {
29552
+ * const devices = await firstValueFrom(this.deviceService.listDevices());
29553
+ * this.devices.set(devices);
29554
+ * }
29555
+ *
29556
+ * async blockDevice(deviceId: string) {
29557
+ * await firstValueFrom(this.deviceService.blockDevice(deviceId));
29558
+ * // Recargar lista
29559
+ * }
29560
+ * }
28050
29561
  * ```
28051
29562
  */
28052
- class NotificationsService {
28053
- constructor(injector, collectionFactory) {
28054
- this.injector = injector;
28055
- this.collectionFactory = collectionFactory;
28056
- this.collection = null;
28057
- this.currentUserId = null;
28058
- // Inyección opcional - AuthService puede no estar configurado
28059
- this.authService = null;
28060
- // Intentar obtener AuthService de forma lazy (puede no estar configurado)
28061
- this.setupAutoInitialization();
29563
+ class DeviceService {
29564
+ constructor(config, http) {
29565
+ this.config = config;
29566
+ this.http = http;
29567
+ }
29568
+ get baseUrl() {
29569
+ return `${this.config.apiUrl}/v2/users/me/devices`;
28062
29570
  }
28063
29571
  /**
28064
- * Configura auto-inicialización observando el estado de AuthService.
28065
- * Se ejecuta en el contexto del injector para poder usar effect().
29572
+ * Lista todos los dispositivos registrados del usuario.
28066
29573
  */
28067
- setupAutoInitialization() {
28068
- // Obtener AuthService de forma lazy
28069
- try {
28070
- this.authService = this.injector.get(AuthService, null);
28071
- }
28072
- catch {
28073
- // AuthService no está configurado, no hay auto-inicialización
28074
- return;
28075
- }
28076
- if (!this.authService) {
28077
- return;
28078
- }
28079
- // Usar runInInjectionContext para poder crear el effect
28080
- runInInjectionContext(this.injector, () => {
28081
- effect(() => {
28082
- const user = this.authService.user();
28083
- if (user?.userId && this.currentUserId !== user.userId) {
28084
- // Usuario autenticado: inicializar con su ID
28085
- this.initialize(user.userId);
28086
- }
28087
- else if (!user && this.currentUserId) {
28088
- // Logout: limpiar estado
28089
- this.reset();
28090
- }
28091
- });
28092
- });
29574
+ listDevices() {
29575
+ return this.http.get(this.baseUrl).pipe(map$1(response => response.devices));
28093
29576
  }
28094
29577
  /**
28095
- * Inicializa el servicio para un usuario específico.
29578
+ * Obtiene información de un dispositivo específico.
29579
+ */
29580
+ getDevice(deviceId) {
29581
+ return this.http.get(`${this.baseUrl}/${deviceId}`).pipe(map$1(response => response.device));
29582
+ }
29583
+ /**
29584
+ * Bloquea un dispositivo.
29585
+ * Revoca todas las sesiones activas de ese dispositivo.
29586
+ */
29587
+ blockDevice(deviceId) {
29588
+ return this.http.post(`${this.baseUrl}/${deviceId}/block`, {});
29589
+ }
29590
+ /**
29591
+ * Aprueba un dispositivo pendiente.
29592
+ * Cambia el estado de pending_approval a active.
29593
+ */
29594
+ approveDevice(deviceId) {
29595
+ return this.http.post(`${this.baseUrl}/${deviceId}/approve`, {});
29596
+ }
29597
+ /**
29598
+ * Elimina un dispositivo registrado.
29599
+ */
29600
+ deleteDevice(deviceId) {
29601
+ return this.http.delete(`${this.baseUrl}/${deviceId}`);
29602
+ }
29603
+ /**
29604
+ * Valida un token de acción SIN ejecutarlo.
29605
+ * Útil para mostrar confirmación al usuario antes de ejecutar.
29606
+ * Este endpoint NO requiere autenticación.
28096
29607
  *
28097
- * NOTA: Se llama automáticamente si AuthService está configurado.
28098
- * Solo usar manualmente si AuthService no está disponible o se necesita
28099
- * un userId diferente al del usuario autenticado.
29608
+ * @param token Token JWT de acción
29609
+ * @returns Información del token si es válido
29610
+ *
29611
+ * @example
29612
+ * ```typescript
29613
+ * const token = this.route.snapshot.queryParams['token'];
29614
+ * if (token) {
29615
+ * const validation = await firstValueFrom(this.deviceService.validateAction(token));
29616
+ * if (validation.valid) {
29617
+ * // Mostrar confirmación al usuario
29618
+ * console.log(`Acción: ${validation.actionType}`);
29619
+ * }
29620
+ * }
29621
+ * ```
28100
29622
  */
28101
- initialize(userId) {
28102
- if (!this.collectionFactory) {
28103
- console.warn('[Notifications] FirestoreCollectionFactory not available. Ensure provideValtechFirebase() is configured.');
28104
- return;
28105
- }
28106
- this.currentUserId = userId;
28107
- // Path relativo - FirestoreService agrega automáticamente apps/{appId}/
28108
- // NO agregar apps/ aquí para evitar doble prefijo
28109
- const basePath = `users/${userId}/notifications`;
28110
- this.collection = this.collectionFactory.create(basePath, { timestamps: true });
28111
- console.log('[Notifications] Initialized with path:', basePath);
29623
+ validateAction(token) {
29624
+ return this.http.post(`${this.config.apiUrl}/v2/actions/validate`, { token });
29625
+ }
29626
+ /**
29627
+ * Ejecuta una acción de dispositivo desde un token de email.
29628
+ * Este endpoint NO requiere autenticación.
29629
+ * El token viene en la URL del email de alerta de nuevo inicio de sesión.
29630
+ *
29631
+ * @param token Token JWT de acción (24h, un solo uso)
29632
+ *
29633
+ * @example
29634
+ * ```typescript
29635
+ * // En la página de dispositivos, al detectar ?token=xxx en la URL:
29636
+ * const token = this.route.snapshot.queryParams['token'];
29637
+ * if (token) {
29638
+ * const result = await firstValueFrom(this.deviceService.executeAction(token));
29639
+ * if (result.success) {
29640
+ * console.log(`Dispositivo bloqueado, ${result.sessionsRevoked} sesiones cerradas`);
29641
+ * }
29642
+ * }
29643
+ * ```
29644
+ */
29645
+ executeAction(token) {
29646
+ // Usa el endpoint unificado de acciones
29647
+ return this.http.post(`${this.config.apiUrl}/v2/actions/execute`, { token }).pipe(map$1(response => ({
29648
+ operationId: response.operationId,
29649
+ success: response.success,
29650
+ message: response.message,
29651
+ action: response.data?.['action'] || 'refuse',
29652
+ deviceId: response.data?.['deviceId'] || '',
29653
+ sessionsRevoked: response.data?.['sessionsRevoked'],
29654
+ })));
29655
+ }
29656
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeviceService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$8.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
29657
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeviceService, providedIn: 'root' }); }
29658
+ }
29659
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeviceService, decorators: [{
29660
+ type: Injectable,
29661
+ args: [{ providedIn: 'root' }]
29662
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
29663
+ type: Inject,
29664
+ args: [VALTECH_AUTH_CONFIG]
29665
+ }] }, { type: i1$8.HttpClient }] });
29666
+
29667
+ /**
29668
+ * Servicio para gestión de sesiones activas del usuario.
29669
+ * Permite listar y revocar sesiones.
29670
+ *
29671
+ * @example
29672
+ * ```typescript
29673
+ * import { SessionService } from 'valtech-components';
29674
+ *
29675
+ * @Component({...})
29676
+ * export class SessionsPage {
29677
+ * private sessionService = inject(SessionService);
29678
+ *
29679
+ * sessions = signal<SessionInfo[]>([]);
29680
+ *
29681
+ * async ngOnInit() {
29682
+ * const sessions = await firstValueFrom(this.sessionService.listSessions());
29683
+ * this.sessions.set(sessions);
29684
+ * }
29685
+ *
29686
+ * async revokeSession(sessionId: string) {
29687
+ * await firstValueFrom(this.sessionService.revokeSession(sessionId));
29688
+ * // Recargar lista
29689
+ * }
29690
+ *
29691
+ * async revokeAllOthers() {
29692
+ * const result = await firstValueFrom(this.sessionService.revokeAllSessions());
29693
+ * console.log(`${result.sessionsRevoked} sesiones cerradas`);
29694
+ * }
29695
+ * }
29696
+ * ```
29697
+ */
29698
+ class SessionService {
29699
+ constructor(config, http) {
29700
+ this.config = config;
29701
+ this.http = http;
28112
29702
  }
28113
- /**
28114
- * Verifica si el servicio está inicializado.
28115
- */
28116
- get isReady() {
28117
- return this.collection !== null;
29703
+ get baseUrl() {
29704
+ return `${this.config.apiUrl}/v2/users/me/sessions`;
28118
29705
  }
28119
29706
  /**
28120
- * Obtiene el ID del usuario actual.
29707
+ * Lista todas las sesiones activas del usuario.
29708
+ * La sesión actual está marcada con isCurrent=true.
28121
29709
  */
28122
- get userId() {
28123
- return this.currentUserId;
29710
+ listSessions() {
29711
+ return this.http.get(this.baseUrl).pipe(map$1(response => response.sessions));
28124
29712
  }
28125
- // ===========================================================================
28126
- // LECTURA
28127
- // ===========================================================================
28128
29713
  /**
28129
- * Obtiene todas las notificaciones ordenadas por fecha descendente.
28130
- * Real-time: se actualiza automáticamente cuando cambian los datos.
29714
+ * Revoca una sesión específica.
29715
+ * Fuerza el cierre de sesión en ese dispositivo/navegador.
28131
29716
  */
28132
- getAll() {
28133
- if (!this.collection)
28134
- return of([]);
28135
- return this.collection.watchAll({
28136
- orderBy: [{ field: 'createdAt', direction: 'desc' }],
28137
- });
29717
+ revokeSession(sessionId) {
29718
+ return this.http.delete(`${this.baseUrl}/${sessionId}`);
28138
29719
  }
28139
29720
  /**
28140
- * Obtiene solo notificaciones no leídas.
29721
+ * Revoca todas las sesiones excepto la actual.
29722
+ * Útil para "cerrar sesión en todos los dispositivos".
29723
+ *
29724
+ * @returns Número de sesiones revocadas
28141
29725
  */
28142
- getUnread() {
28143
- return this.getAll().pipe(map$1(notifications => notifications.filter(n => !n.isRead)));
29726
+ revokeAllSessions() {
29727
+ return this.http.delete(this.baseUrl);
28144
29728
  }
28145
- /**
28146
- * Cuenta notificaciones no leídas.
28147
- * Útil para badges en UI.
28148
- */
28149
- getUnreadCount() {
28150
- return this.getUnread().pipe(map$1(n => n.length));
29729
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$8.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
29730
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionService, providedIn: 'root' }); }
29731
+ }
29732
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionService, decorators: [{
29733
+ type: Injectable,
29734
+ args: [{ providedIn: 'root' }]
29735
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
29736
+ type: Inject,
29737
+ args: [VALTECH_AUTH_CONFIG]
29738
+ }] }, { type: i1$8.HttpClient }] });
29739
+
29740
+ /**
29741
+ * Componente de callback para OAuth.
29742
+ *
29743
+ * Este componente procesa la respuesta del servidor OAuth y envía
29744
+ * los tokens a la ventana padre via postMessage.
29745
+ *
29746
+ * Debe agregarse a las rutas de la aplicación:
29747
+ * ```typescript
29748
+ * // app.routes.ts
29749
+ * import { OAuthCallbackComponent } from 'valtech-components';
29750
+ *
29751
+ * export const routes: Routes = [
29752
+ * { path: 'auth/oauth/callback', component: OAuthCallbackComponent },
29753
+ * // ... otras rutas
29754
+ * ];
29755
+ * ```
29756
+ *
29757
+ * El backend redirige a esta ruta con los tokens en query params:
29758
+ * `/auth/oauth/callback?access_token=xxx&refresh_token=xxx&expires_in=900`
29759
+ *
29760
+ * O con error:
29761
+ * `/auth/oauth/callback?error=INVALID_CODE&error_description=...`
29762
+ */
29763
+ class OAuthCallbackComponent {
29764
+ constructor() {
29765
+ this.message = 'Procesando autenticación...';
28151
29766
  }
28152
- /**
28153
- * Obtiene una notificación por ID.
28154
- */
28155
- async getById(notificationId) {
28156
- if (!this.collection)
28157
- return null;
28158
- return this.collection.getById(notificationId);
29767
+ ngOnInit() {
29768
+ this.processCallback();
28159
29769
  }
28160
- // ===========================================================================
28161
- // ACCIONES
28162
- // ===========================================================================
28163
- /**
28164
- * Marca una notificación como leída.
28165
- */
28166
- async markAsRead(notificationId) {
28167
- if (!this.collection)
29770
+ processCallback() {
29771
+ const params = new URLSearchParams(window.location.search);
29772
+ // Verificar si hay error
29773
+ const error = params.get('error');
29774
+ if (error) {
29775
+ this.sendToParent({
29776
+ type: 'oauth-callback',
29777
+ error: {
29778
+ code: error,
29779
+ message: params.get('error_description') || 'Error de autenticación',
29780
+ },
29781
+ });
29782
+ this.message = 'Error de autenticación';
29783
+ this.closeAfterDelay();
28168
29784
  return;
28169
- await this.collection.update(notificationId, { isRead: true });
28170
- }
28171
- /**
28172
- * Marca todas las notificaciones como leídas.
28173
- */
28174
- async markAllAsRead() {
28175
- if (!this.collection)
29785
+ }
29786
+ // Extraer tokens
29787
+ const accessToken = params.get('access_token');
29788
+ const refreshToken = params.get('refresh_token');
29789
+ const expiresIn = params.get('expires_in');
29790
+ const firebaseToken = params.get('firebase_token');
29791
+ if (!accessToken || !refreshToken) {
29792
+ this.sendToParent({
29793
+ type: 'oauth-callback',
29794
+ error: {
29795
+ code: 'MISSING_TOKENS',
29796
+ message: 'No se recibieron los tokens de autenticación',
29797
+ },
29798
+ });
29799
+ this.message = 'Error: tokens no recibidos';
29800
+ this.closeAfterDelay();
28176
29801
  return;
28177
- const unread = await this.collection.query({
28178
- where: [{ field: 'isRead', operator: '==', value: false }],
29802
+ }
29803
+ // Extraer roles y permisos (pueden venir como JSON en base64)
29804
+ let roles;
29805
+ let permissions;
29806
+ const rolesParam = params.get('roles');
29807
+ const permissionsParam = params.get('permissions');
29808
+ if (rolesParam) {
29809
+ try {
29810
+ roles = JSON.parse(atob(rolesParam));
29811
+ }
29812
+ catch {
29813
+ roles = rolesParam.split(',');
29814
+ }
29815
+ }
29816
+ if (permissionsParam) {
29817
+ try {
29818
+ permissions = JSON.parse(atob(permissionsParam));
29819
+ }
29820
+ catch {
29821
+ permissions = permissionsParam.split(',');
29822
+ }
29823
+ }
29824
+ // Enviar tokens a la ventana padre
29825
+ const result = {
29826
+ accessToken,
29827
+ refreshToken,
29828
+ expiresIn: expiresIn ? parseInt(expiresIn, 10) : 900,
29829
+ firebaseToken: firebaseToken || undefined,
29830
+ roles,
29831
+ permissions,
29832
+ isNewUser: params.get('is_new_user') === 'true',
29833
+ linked: params.get('linked') === 'true',
29834
+ };
29835
+ this.sendToParent({
29836
+ type: 'oauth-callback',
29837
+ tokens: result,
28179
29838
  });
28180
- await Promise.all(unread.map(n => this.collection.update(n.id, { isRead: true })));
28181
- }
28182
- /**
28183
- * Elimina una notificación.
28184
- */
28185
- async delete(notificationId) {
28186
- if (!this.collection)
28187
- return;
28188
- await this.collection.delete(notificationId);
29839
+ this.message = 'Autenticación exitosa';
29840
+ this.closeAfterDelay();
28189
29841
  }
28190
- /**
28191
- * Elimina todas las notificaciones del usuario.
28192
- */
28193
- async deleteAll() {
28194
- if (!this.collection)
28195
- return;
28196
- const all = await this.collection.getAll();
28197
- await Promise.all(all.map(n => this.collection.delete(n.id)));
29842
+ sendToParent(data) {
29843
+ if (window.opener) {
29844
+ // Enviar al opener (ventana que abrió el popup)
29845
+ window.opener.postMessage(data, window.location.origin);
29846
+ }
29847
+ else if (window.parent !== window) {
29848
+ // Enviar al parent (si estamos en iframe)
29849
+ window.parent.postMessage(data, window.location.origin);
29850
+ }
28198
29851
  }
28199
- /**
28200
- * Limpia el estado del servicio.
28201
- * Útil para logout.
28202
- */
28203
- reset() {
28204
- this.collection = null;
28205
- this.currentUserId = null;
29852
+ closeAfterDelay() {
29853
+ // Dar tiempo para que el mensaje se envíe antes de cerrar
29854
+ setTimeout(() => {
29855
+ if (window.opener) {
29856
+ window.close();
29857
+ }
29858
+ }, 500);
28206
29859
  }
28207
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsService, deps: [{ token: i0.Injector }, { token: FirestoreCollectionFactory, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
28208
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsService, providedIn: 'root' }); }
29860
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: OAuthCallbackComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
29861
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: OAuthCallbackComponent, isStandalone: true, selector: "val-oauth-callback", ngImport: i0, template: `
29862
+ <div class="oauth-callback">
29863
+ <div class="oauth-callback__spinner"></div>
29864
+ <p class="oauth-callback__text">{{ message }}</p>
29865
+ </div>
29866
+ `, isInline: true, styles: [".oauth-callback{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}.oauth-callback__spinner{width:40px;height:40px;border:3px solid #f3f3f3;border-top:3px solid #3498db;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:16px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.oauth-callback__text{color:#666;font-size:14px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] }); }
28209
29867
  }
28210
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsService, decorators: [{
28211
- type: Injectable,
28212
- args: [{ providedIn: 'root' }]
28213
- }], ctorParameters: () => [{ type: i0.Injector }, { type: FirestoreCollectionFactory, decorators: [{
28214
- type: Optional
28215
- }] }] });
28216
-
28217
- /**
28218
- * Analytics Types
28219
- *
28220
- * Tipos e interfaces para el servicio de Firebase Analytics (GA4).
28221
- */
29868
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: OAuthCallbackComponent, decorators: [{
29869
+ type: Component,
29870
+ args: [{ selector: 'val-oauth-callback', standalone: true, imports: [CommonModule], template: `
29871
+ <div class="oauth-callback">
29872
+ <div class="oauth-callback__spinner"></div>
29873
+ <p class="oauth-callback__text">{{ message }}</p>
29874
+ </div>
29875
+ `, styles: [".oauth-callback{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}.oauth-callback__spinner{width:40px;height:40px;border:3px solid #f3f3f3;border-top:3px solid #3498db;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:16px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.oauth-callback__text{color:#666;font-size:14px}\n"] }]
29876
+ }] });
28222
29877
 
28223
29878
  /**
28224
- * Firebase Services
29879
+ * Valtech Auth Service
28225
29880
  *
28226
- * Servicios reutilizables para integración con Firebase.
29881
+ * Servicio de autenticación reutilizable para aplicaciones Angular.
29882
+ * Proporciona autenticación con AuthV2, MFA, sincronización entre pestañas,
29883
+ * refresh proactivo de tokens, y registro automático de dispositivos para
29884
+ * push notifications.
28227
29885
  *
28228
29886
  * @example
28229
29887
  * ```typescript
28230
29888
  * // En main.ts
28231
- * import { provideValtechFirebase } from 'valtech-components';
29889
+ * import { bootstrapApplication } from '@angular/platform-browser';
29890
+ * import { provideValtechAuth, provideValtechFirebase } from 'valtech-components';
29891
+ * import { environment } from './environments/environment';
28232
29892
  *
28233
29893
  * bootstrapApplication(AppComponent, {
28234
29894
  * providers: [
28235
- * provideValtechFirebase({
28236
- * firebase: environment.firebase,
28237
- * persistence: true,
29895
+ * provideValtechFirebase(environment.firebase),
29896
+ * provideValtechAuth({
29897
+ * apiUrl: environment.apiUrl,
29898
+ * enableFirebaseIntegration: true,
29899
+ * enableDeviceRegistration: true, // Auto-registra dispositivos para push
28238
29900
  * }),
28239
29901
  * ],
28240
29902
  * });
28241
29903
  *
29904
+ * // En app.routes.ts
29905
+ * import { authGuard, guestGuard, permissionGuard } from 'valtech-components';
29906
+ *
29907
+ * const routes: Routes = [
29908
+ * { path: 'login', canActivate: [guestGuard], loadComponent: () => import('./login.page') },
29909
+ * { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('./dashboard.page') },
29910
+ * { path: 'admin', canActivate: [authGuard, permissionGuard('admin:*')], loadComponent: () => import('./admin.page') },
29911
+ * ];
29912
+ *
28242
29913
  * // En componentes
28243
- * import { FirebaseService, FirestoreService } from 'valtech-components';
29914
+ * import { AuthService } from 'valtech-components';
28244
29915
  *
28245
29916
  * @Component({...})
28246
- * export class MyComponent {
28247
- * private firebase = inject(FirebaseService);
28248
- * private firestore = inject(FirestoreService);
28249
- * }
28250
- * ```
28251
- */
28252
- // Tipos
28253
-
28254
- /**
28255
- * Guard que verifica si el usuario está autenticado.
28256
- * Redirige a loginRoute si no está autenticado.
29917
+ * export class LoginComponent {
29918
+ * private auth = inject(AuthService);
28257
29919
  *
28258
- * @example
28259
- * ```typescript
28260
- * import { authGuard } from 'valtech-components';
29920
+ * async login() {
29921
+ * await firstValueFrom(this.auth.signin({ email, password }));
29922
+ * if (this.auth.mfaPending().required) {
29923
+ * // Mostrar UI de MFA
29924
+ * } else {
29925
+ * this.router.navigate(['/dashboard']);
29926
+ * }
29927
+ * }
28261
29928
  *
28262
- * const routes: Routes = [
28263
- * {
28264
- * path: 'dashboard',
28265
- * canActivate: [authGuard],
28266
- * loadComponent: () => import('./dashboard.page'),
28267
- * },
28268
- * ];
28269
- * ```
28270
- */
28271
- const authGuard = () => {
28272
- const authService = inject(AuthService);
28273
- const router = inject(Router);
28274
- const config = inject(VALTECH_AUTH_CONFIG);
28275
- if (authService.isAuthenticated()) {
28276
- return true;
28277
- }
28278
- return router.createUrlTree([config.loginRoute]);
28279
- };
28280
- /**
28281
- * Guard que verifica si el usuario NO está autenticado.
28282
- * Redirige a homeRoute si ya está autenticado.
28283
- * Útil para páginas de login/registro.
29929
+ * // Habilitar notificaciones push (solicita permisos + registra dispositivo)
29930
+ * async enableNotifications() {
29931
+ * const result = await this.auth.enableNotifications();
29932
+ * if (result.granted) {
29933
+ * console.log('Notificaciones habilitadas');
29934
+ * }
29935
+ * }
28284
29936
  *
28285
- * @example
28286
- * ```typescript
28287
- * import { guestGuard } from 'valtech-components';
29937
+ * // Verificar estado de permisos
29938
+ * get canReceiveNotifications(): boolean {
29939
+ * return this.auth.getNotificationPermissionState() === 'granted';
29940
+ * }
28288
29941
  *
28289
- * const routes: Routes = [
28290
- * {
28291
- * path: 'login',
28292
- * canActivate: [guestGuard],
28293
- * loadComponent: () => import('./login.page'),
28294
- * },
28295
- * ];
29942
+ * // En template: usar signals directamente
29943
+ * // {{ auth.user()?.email }}
29944
+ * // @if (auth.hasPermission('templates:edit')) { ... }
29945
+ * }
28296
29946
  * ```
28297
29947
  */
28298
- const guestGuard = () => {
28299
- const authService = inject(AuthService);
28300
- const router = inject(Router);
28301
- const config = inject(VALTECH_AUTH_CONFIG);
28302
- if (!authService.isAuthenticated()) {
28303
- return true;
28304
- }
28305
- return router.createUrlTree([config.homeRoute]);
28306
- };
29948
+ // Tipos
29949
+
28307
29950
  /**
28308
- * Factory para crear guard de permisos.
28309
- * Verifica si el usuario tiene el permiso especificado.
29951
+ * Directiva estructural simplificada para estados de carga.
28310
29952
  *
28311
- * @param permissions - Permiso o lista de permisos requeridos (OR)
28312
- * @returns Guard function
29953
+ * Soporta multiples fuentes de estado de carga:
29954
+ * - Angular Signal<boolean>
29955
+ * - RxJS Observable<boolean>
29956
+ * - Promise<any> (cargando hasta que se resuelve)
29957
+ * - boolean literal
28313
29958
  *
28314
29959
  * @example
28315
- * ```typescript
28316
- * import { authGuard, permissionGuard } from 'valtech-components';
28317
- *
28318
- * const routes: Routes = [
28319
- * {
28320
- * path: 'templates',
28321
- * canActivate: [authGuard, permissionGuard('templates:read')],
28322
- * loadComponent: () => import('./templates.page'),
28323
- * },
28324
- * {
28325
- * path: 'admin',
28326
- * canActivate: [authGuard, permissionGuard(['admin:*', 'super_admin'])],
28327
- * loadComponent: () => import('./admin.page'),
28328
- * },
28329
- * ];
28330
- * ```
28331
- */
28332
- function permissionGuard(permissions) {
28333
- return () => {
28334
- const authService = inject(AuthService);
28335
- const router = inject(Router);
28336
- const config = inject(VALTECH_AUTH_CONFIG);
28337
- const permArray = Array.isArray(permissions) ? permissions : [permissions];
28338
- if (authService.hasAnyPermission(permArray)) {
28339
- return true;
28340
- }
28341
- console.warn(`[ValtechAuth] Permiso denegado. Requerido: ${permArray.join(' o ')}`);
28342
- return router.createUrlTree([config.unauthorizedRoute]);
28343
- };
28344
- }
28345
- /**
28346
- * Guard que lee permisos desde route.data.
28347
- * Permite configurar permisos directamente en la definición de rutas.
29960
+ * <!-- Uso simple con signal -->
29961
+ * <ng-container *valLoading="isLoading(); skeleton: 'list'">
29962
+ * <app-list [items]="items()"></app-list>
29963
+ * </ng-container>
28348
29964
  *
28349
29965
  * @example
28350
- * ```typescript
28351
- * import { authGuard, permissionGuardFromRoute } from 'valtech-components';
29966
+ * <!-- Con configuracion -->
29967
+ * <ng-container *valLoading="loading$; skeleton: 'grid-cards'; count: 8">
29968
+ * <app-grid [data]="data"></app-grid>
29969
+ * </ng-container>
28352
29970
  *
28353
- * const routes: Routes = [
28354
- * {
28355
- * path: 'admin/users',
28356
- * canActivate: [authGuard, permissionGuardFromRoute],
28357
- * data: {
28358
- * permissions: ['users:read', 'users:manage'],
28359
- * requireAll: false // true = AND, false = OR (default)
28360
- * },
28361
- * loadComponent: () => import('./users.page'),
28362
- * },
28363
- * ];
28364
- * ```
29971
+ * @example
29972
+ * <!-- Con template personalizado -->
29973
+ * <ng-container *valLoading="isLoading(); skeletonTpl: customSkeleton">
29974
+ * <app-content></app-content>
29975
+ * </ng-container>
29976
+ * <ng-template #customSkeleton>
29977
+ * <val-skeleton [props]="{ type: 'card' }"></val-skeleton>
29978
+ * </ng-template>
28365
29979
  */
28366
- const permissionGuardFromRoute = (route) => {
28367
- const authService = inject(AuthService);
28368
- const router = inject(Router);
28369
- const config = inject(VALTECH_AUTH_CONFIG);
28370
- const permissions = route.data['permissions'];
28371
- const requireAll = route.data['requireAll'];
28372
- if (!permissions || permissions.length === 0) {
28373
- return true;
29980
+ class LoadingDirective {
29981
+ constructor() {
29982
+ this.templateRef = inject((TemplateRef));
29983
+ this.viewContainer = inject(ViewContainerRef);
29984
+ this.skeletonService = inject(SkeletonService);
29985
+ this.destroyRef = inject(DestroyRef);
29986
+ this._loading = signal(false);
29987
+ this.hasContentView = false;
29988
+ this.skeletonComponentRef = null;
29989
+ /** Template de skeleton a usar */
29990
+ this.skeleton = 'list';
29991
+ /** Template personalizado para skeleton */
29992
+ this.skeletonTpl = null;
29993
+ /** Cantidad de items skeleton */
29994
+ this.count = 3;
29995
+ /** Animacion habilitada */
29996
+ this.animated = true;
29997
+ /** Mostrar spinner en lugar de skeleton */
29998
+ this.spinner = false;
29999
+ effect(() => {
30000
+ const isLoading = this._loading();
30001
+ this.updateView(isLoading);
30002
+ });
28374
30003
  }
28375
- const hasAccess = requireAll
28376
- ? authService.hasAllPermissions(permissions)
28377
- : authService.hasAnyPermission(permissions);
28378
- if (hasAccess) {
28379
- return true;
30004
+ /**
30005
+ * Input principal - puede ser boolean, Signal, Observable o Promise.
30006
+ */
30007
+ set loading(source) {
30008
+ this.resolveLoadingSource(source);
28380
30009
  }
28381
- console.warn(`[ValtechAuth] Permiso denegado. Requerido: ${permissions.join(requireAll ? ' y ' : ' o ')}`);
28382
- return router.createUrlTree([config.unauthorizedRoute]);
28383
- };
28384
- /**
28385
- * Guard que verifica si el usuario es super admin.
28386
- *
28387
- * @example
28388
- * ```typescript
28389
- * import { authGuard, superAdminGuard } from 'valtech-components';
28390
- *
28391
- * const routes: Routes = [
28392
- * {
28393
- * path: 'super-admin',
28394
- * canActivate: [authGuard, superAdminGuard],
28395
- * loadComponent: () => import('./super-admin.page'),
28396
- * },
28397
- * ];
28398
- * ```
28399
- */
28400
- const superAdminGuard = () => {
28401
- const authService = inject(AuthService);
28402
- const router = inject(Router);
28403
- const config = inject(VALTECH_AUTH_CONFIG);
28404
- if (authService.isSuperAdmin()) {
28405
- return true;
30010
+ ngOnDestroy() {
30011
+ this.viewContainer.clear();
30012
+ this.skeletonComponentRef = null;
28406
30013
  }
28407
- console.warn('[ValtechAuth] Acceso de super admin requerido');
28408
- return router.createUrlTree([config.unauthorizedRoute]);
28409
- };
30014
+ resolveLoadingSource(source) {
30015
+ if (typeof source === 'boolean') {
30016
+ this._loading.set(source);
30017
+ }
30018
+ else if (isSignal(source)) {
30019
+ // Sincronizar signal con effect
30020
+ effect(() => {
30021
+ this._loading.set(source());
30022
+ }, { injector: this.viewContainer.injector });
30023
+ }
30024
+ else if (isObservable(source)) {
30025
+ // Convertir observable a signal
30026
+ const signalValue = toSignal(source, {
30027
+ initialValue: true,
30028
+ injector: this.viewContainer.injector,
30029
+ });
30030
+ effect(() => {
30031
+ this._loading.set(signalValue());
30032
+ }, { injector: this.viewContainer.injector });
30033
+ }
30034
+ else if (source instanceof Promise) {
30035
+ this._loading.set(true);
30036
+ source.finally(() => this._loading.set(false));
30037
+ }
30038
+ }
30039
+ updateView(isLoading) {
30040
+ if (isLoading) {
30041
+ this.showSkeleton();
30042
+ }
30043
+ else {
30044
+ this.showContent();
30045
+ }
30046
+ }
30047
+ showSkeleton() {
30048
+ // Limpiar contenido previo
30049
+ this.viewContainer.clear();
30050
+ this.hasContentView = false;
30051
+ if (this.skeletonTpl) {
30052
+ // Usar template personalizado
30053
+ this.viewContainer.createEmbeddedView(this.skeletonTpl);
30054
+ }
30055
+ else if (this.spinner) {
30056
+ // Mostrar spinner (usar val-content-loader si esta disponible)
30057
+ this.showSpinner();
30058
+ }
30059
+ else {
30060
+ // Usar template de skeleton registrado
30061
+ this.showSkeletonTemplate();
30062
+ }
30063
+ }
30064
+ showSkeletonTemplate() {
30065
+ const template = this.skeletonService.getTemplate(this.skeleton);
30066
+ if (template) {
30067
+ this.skeletonComponentRef = this.viewContainer.createComponent(template.component);
30068
+ // Combinar config por defecto con inputs
30069
+ const config = {
30070
+ ...template.defaultConfig,
30071
+ count: this.count,
30072
+ animated: this.animated,
30073
+ gap: this.gap,
30074
+ variant: this.variant,
30075
+ };
30076
+ this.skeletonComponentRef.instance.config = config;
30077
+ }
30078
+ else {
30079
+ // Fallback: mostrar texto de carga si no hay template
30080
+ console.warn(`[valLoading] Template '${this.skeleton}' not found. Using fallback.`);
30081
+ this.showFallback();
30082
+ }
30083
+ }
30084
+ showSpinner() {
30085
+ // Crear un div con spinner simple
30086
+ const element = document.createElement('div');
30087
+ element.className = 'val-loading-spinner';
30088
+ element.innerHTML = `
30089
+ <ion-spinner name="crescent"></ion-spinner>
30090
+ `;
30091
+ element.style.cssText = 'display: flex; justify-content: center; padding: 20px;';
30092
+ const hostElement = this.viewContainer.element.nativeElement;
30093
+ hostElement.parentElement?.insertBefore(element, hostElement);
30094
+ }
30095
+ showFallback() {
30096
+ // Fallback simple si no hay template registrado
30097
+ const element = document.createElement('div');
30098
+ element.className = 'val-loading-fallback';
30099
+ element.style.cssText =
30100
+ 'display: flex; flex-direction: column; gap: 12px; padding: 16px;';
30101
+ for (let i = 0; i < this.count; i++) {
30102
+ const skeleton = document.createElement('div');
30103
+ skeleton.style.cssText = `
30104
+ background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
30105
+ background-size: 200% 100%;
30106
+ animation: skeleton-loading 1.5s infinite;
30107
+ height: 56px;
30108
+ border-radius: 8px;
30109
+ `;
30110
+ element.appendChild(skeleton);
30111
+ }
30112
+ const hostElement = this.viewContainer.element.nativeElement;
30113
+ hostElement.parentElement?.insertBefore(element, hostElement);
30114
+ }
30115
+ showContent() {
30116
+ // Limpiar skeleton
30117
+ this.viewContainer.clear();
30118
+ this.skeletonComponentRef = null;
30119
+ // Mostrar contenido real
30120
+ if (!this.hasContentView) {
30121
+ this.viewContainer.createEmbeddedView(this.templateRef);
30122
+ this.hasContentView = true;
30123
+ }
30124
+ }
30125
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LoadingDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
30126
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.14", type: LoadingDirective, isStandalone: true, selector: "[valLoading]", inputs: { skeleton: ["valLoadingSkeleton", "skeleton"], skeletonTpl: ["valLoadingSkeletonTpl", "skeletonTpl"], count: ["valLoadingCount", "count"], animated: ["valLoadingAnimated", "animated"], gap: ["valLoadingGap", "gap"], variant: ["valLoadingVariant", "variant"], spinner: ["valLoadingSpinner", "spinner"], loading: ["valLoading", "loading"] }, ngImport: i0 }); }
30127
+ }
30128
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LoadingDirective, decorators: [{
30129
+ type: Directive,
30130
+ args: [{
30131
+ selector: '[valLoading]',
30132
+ standalone: true,
30133
+ }]
30134
+ }], ctorParameters: () => [], propDecorators: { skeleton: [{
30135
+ type: Input,
30136
+ args: ['valLoadingSkeleton']
30137
+ }], skeletonTpl: [{
30138
+ type: Input,
30139
+ args: ['valLoadingSkeletonTpl']
30140
+ }], count: [{
30141
+ type: Input,
30142
+ args: ['valLoadingCount']
30143
+ }], animated: [{
30144
+ type: Input,
30145
+ args: ['valLoadingAnimated']
30146
+ }], gap: [{
30147
+ type: Input,
30148
+ args: ['valLoadingGap']
30149
+ }], variant: [{
30150
+ type: Input,
30151
+ args: ['valLoadingVariant']
30152
+ }], spinner: [{
30153
+ type: Input,
30154
+ args: ['valLoadingSpinner']
30155
+ }], loading: [{
30156
+ type: Input,
30157
+ args: ['valLoading']
30158
+ }] } });
30159
+
28410
30160
  /**
28411
- * Guard que verifica si el usuario tiene un rol específico.
28412
- *
28413
- * @param roles - Rol o lista de roles requeridos (OR)
28414
- * @returns Guard function
30161
+ * Template de skeleton para listas.
28415
30162
  *
28416
30163
  * @example
28417
- * ```typescript
28418
- * import { authGuard, roleGuard } from 'valtech-components';
30164
+ * <val-skeleton-list [config]="{ count: 5 }"></val-skeleton-list>
28419
30165
  *
28420
- * const routes: Routes = [
28421
- * {
28422
- * path: 'editor',
28423
- * canActivate: [authGuard, roleGuard(['editor', 'admin'])],
28424
- * loadComponent: () => import('./editor.page'),
28425
- * },
28426
- * ];
28427
- * ```
30166
+ * @example
30167
+ * <val-skeleton-list [config]="{ count: 3, variant: 'avatar', gap: '16px' }"></val-skeleton-list>
28428
30168
  */
28429
- function roleGuard(roles) {
28430
- return () => {
28431
- const authService = inject(AuthService);
28432
- const router = inject(Router);
28433
- const config = inject(VALTECH_AUTH_CONFIG);
28434
- const roleArray = Array.isArray(roles) ? roles : [roles];
28435
- const hasRole = roleArray.some((role) => authService.hasRole(role));
28436
- if (hasRole) {
28437
- return true;
30169
+ class ListSkeletonComponent {
30170
+ constructor() {
30171
+ this.config = { count: 3 };
30172
+ }
30173
+ get items() {
30174
+ return Array(this.config.count ?? 3).fill(0);
30175
+ }
30176
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ListSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
30177
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ListSkeletonComponent, isStandalone: true, selector: "val-skeleton-list", inputs: { config: "config" }, ngImport: i0, template: `
30178
+ <div class="skeleton-list" [style.gap]="config.gap || '12px'" [class]="config.cssClass">
30179
+ @for (item of items; track $index) {
30180
+ @if (config.variant === 'avatar') {
30181
+ <div class="skeleton-list-item-avatar">
30182
+ <val-skeleton
30183
+ [props]="{
30184
+ type: 'avatar',
30185
+ animated: config.animated !== false
30186
+ }"
30187
+ ></val-skeleton>
30188
+ <div class="skeleton-content">
30189
+ <val-skeleton
30190
+ [props]="{
30191
+ type: 'text',
30192
+ width: '70%',
30193
+ height: '16px',
30194
+ animated: config.animated !== false
30195
+ }"
30196
+ ></val-skeleton>
30197
+ <val-skeleton
30198
+ [props]="{
30199
+ type: 'text',
30200
+ width: '50%',
30201
+ height: '14px',
30202
+ animated: config.animated !== false
30203
+ }"
30204
+ ></val-skeleton>
30205
+ </div>
30206
+ </div>
30207
+ } @else {
30208
+ <val-skeleton
30209
+ [props]="{
30210
+ type: 'list-item',
30211
+ animated: config.animated !== false
30212
+ }"
30213
+ ></val-skeleton>
28438
30214
  }
28439
- console.warn(`[ValtechAuth] Rol requerido: ${roleArray.join(' o ')}`);
28440
- return router.createUrlTree([config.unauthorizedRoute]);
28441
- };
30215
+ }
30216
+ </div>
30217
+ `, isInline: true, styles: [".skeleton-list{display:flex;flex-direction:column;width:100%}.skeleton-list-item-avatar{display:flex;align-items:center;gap:12px;padding:8px 0}.skeleton-content{display:flex;flex-direction:column;gap:6px;flex:1}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
28442
30218
  }
30219
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ListSkeletonComponent, decorators: [{
30220
+ type: Component,
30221
+ args: [{ selector: 'val-skeleton-list', standalone: true, imports: [CommonModule, SkeletonComponent], template: `
30222
+ <div class="skeleton-list" [style.gap]="config.gap || '12px'" [class]="config.cssClass">
30223
+ @for (item of items; track $index) {
30224
+ @if (config.variant === 'avatar') {
30225
+ <div class="skeleton-list-item-avatar">
30226
+ <val-skeleton
30227
+ [props]="{
30228
+ type: 'avatar',
30229
+ animated: config.animated !== false
30230
+ }"
30231
+ ></val-skeleton>
30232
+ <div class="skeleton-content">
30233
+ <val-skeleton
30234
+ [props]="{
30235
+ type: 'text',
30236
+ width: '70%',
30237
+ height: '16px',
30238
+ animated: config.animated !== false
30239
+ }"
30240
+ ></val-skeleton>
30241
+ <val-skeleton
30242
+ [props]="{
30243
+ type: 'text',
30244
+ width: '50%',
30245
+ height: '14px',
30246
+ animated: config.animated !== false
30247
+ }"
30248
+ ></val-skeleton>
30249
+ </div>
30250
+ </div>
30251
+ } @else {
30252
+ <val-skeleton
30253
+ [props]="{
30254
+ type: 'list-item',
30255
+ animated: config.animated !== false
30256
+ }"
30257
+ ></val-skeleton>
30258
+ }
30259
+ }
30260
+ </div>
30261
+ `, styles: [".skeleton-list{display:flex;flex-direction:column;width:100%}.skeleton-list-item-avatar{display:flex;align-items:center;gap:12px;padding:8px 0}.skeleton-content{display:flex;flex-direction:column;gap:6px;flex:1}\n"] }]
30262
+ }], propDecorators: { config: [{
30263
+ type: Input
30264
+ }] } });
28443
30265
 
28444
30266
  /**
28445
- * Servicio para gestión de dispositivos del usuario.
28446
- * Permite listar, aprobar, bloquear y eliminar dispositivos registrados.
30267
+ * Template de skeleton para grids de cards.
28447
30268
  *
28448
30269
  * @example
28449
- * ```typescript
28450
- * import { DeviceService } from 'valtech-components';
28451
- *
28452
- * @Component({...})
28453
- * export class DevicesPage {
28454
- * private deviceService = inject(DeviceService);
28455
- *
28456
- * devices = signal<DeviceInfo[]>([]);
28457
- *
28458
- * async ngOnInit() {
28459
- * const devices = await firstValueFrom(this.deviceService.listDevices());
28460
- * this.devices.set(devices);
28461
- * }
30270
+ * <val-skeleton-grid [config]="{ count: 6 }"></val-skeleton-grid>
28462
30271
  *
28463
- * async blockDevice(deviceId: string) {
28464
- * await firstValueFrom(this.deviceService.blockDevice(deviceId));
28465
- * // Recargar lista
28466
- * }
28467
- * }
28468
- * ```
30272
+ * @example
30273
+ * <val-skeleton-grid [config]="{ count: 8, variant: 'cards', columns: 4 }"></val-skeleton-grid>
28469
30274
  */
28470
- class DeviceService {
28471
- constructor(config, http) {
28472
- this.config = config;
28473
- this.http = http;
30275
+ class GridSkeletonComponent {
30276
+ constructor() {
30277
+ this.config = { count: 6 };
28474
30278
  }
28475
- get baseUrl() {
28476
- return `${this.config.apiUrl}/v2/users/me/devices`;
30279
+ get items() {
30280
+ return Array(this.config.count ?? 6).fill(0);
28477
30281
  }
28478
- /**
28479
- * Lista todos los dispositivos registrados del usuario.
28480
- */
28481
- listDevices() {
28482
- return this.http.get(this.baseUrl).pipe(map$1(response => response.devices));
30282
+ get gridColumns() {
30283
+ if (this.config.columns) {
30284
+ return `repeat(${this.config.columns}, 1fr)`;
30285
+ }
30286
+ return '';
28483
30287
  }
28484
- /**
28485
- * Obtiene información de un dispositivo específico.
28486
- */
28487
- getDevice(deviceId) {
28488
- return this.http.get(`${this.baseUrl}/${deviceId}`).pipe(map$1(response => response.device));
30288
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GridSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
30289
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: GridSkeletonComponent, isStandalone: true, selector: "val-skeleton-grid", inputs: { config: "config" }, ngImport: i0, template: `
30290
+ <div
30291
+ class="skeleton-grid"
30292
+ [class]="'variant-' + (config.variant || 'default') + ' ' + (config.cssClass || '')"
30293
+ [style.gap]="config.gap || '16px'"
30294
+ [style.grid-template-columns]="gridColumns"
30295
+ >
30296
+ @for (item of items; track $index) {
30297
+ @switch (config.variant) {
30298
+ @case ('cards') {
30299
+ <div class="skeleton-card">
30300
+ <val-skeleton
30301
+ [props]="{
30302
+ type: 'custom',
30303
+ width: '100%',
30304
+ height: '140px',
30305
+ borderRadius: '8px 8px 0 0',
30306
+ animated: config.animated !== false
30307
+ }"
30308
+ ></val-skeleton>
30309
+ <div class="skeleton-card-content">
30310
+ <val-skeleton
30311
+ [props]="{
30312
+ type: 'text',
30313
+ width: '80%',
30314
+ height: '18px',
30315
+ animated: config.animated !== false
30316
+ }"
30317
+ ></val-skeleton>
30318
+ <val-skeleton
30319
+ [props]="{
30320
+ type: 'text',
30321
+ width: '60%',
30322
+ height: '14px',
30323
+ animated: config.animated !== false
30324
+ }"
30325
+ ></val-skeleton>
30326
+ </div>
30327
+ </div>
30328
+ }
30329
+ @case ('compact') {
30330
+ <val-skeleton
30331
+ [props]="{
30332
+ type: 'thumbnail',
30333
+ animated: config.animated !== false
30334
+ }"
30335
+ ></val-skeleton>
30336
+ }
30337
+ @default {
30338
+ <val-skeleton
30339
+ [props]="{
30340
+ type: 'card',
30341
+ animated: config.animated !== false
30342
+ }"
30343
+ ></val-skeleton>
30344
+ }
30345
+ }
30346
+ }
30347
+ </div>
30348
+ `, isInline: true, styles: [".skeleton-grid{display:grid;width:100%;&.variant-default{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}&.variant-cards{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}&.variant-compact{grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:12px}}.skeleton-card{border-radius:8px;overflow:hidden;background:var(--ion-color-light, #f4f5f8)}.skeleton-card-content{padding:12px;display:flex;flex-direction:column;gap:8px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
30349
+ }
30350
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GridSkeletonComponent, decorators: [{
30351
+ type: Component,
30352
+ args: [{ selector: 'val-skeleton-grid', standalone: true, imports: [CommonModule, SkeletonComponent], template: `
30353
+ <div
30354
+ class="skeleton-grid"
30355
+ [class]="'variant-' + (config.variant || 'default') + ' ' + (config.cssClass || '')"
30356
+ [style.gap]="config.gap || '16px'"
30357
+ [style.grid-template-columns]="gridColumns"
30358
+ >
30359
+ @for (item of items; track $index) {
30360
+ @switch (config.variant) {
30361
+ @case ('cards') {
30362
+ <div class="skeleton-card">
30363
+ <val-skeleton
30364
+ [props]="{
30365
+ type: 'custom',
30366
+ width: '100%',
30367
+ height: '140px',
30368
+ borderRadius: '8px 8px 0 0',
30369
+ animated: config.animated !== false
30370
+ }"
30371
+ ></val-skeleton>
30372
+ <div class="skeleton-card-content">
30373
+ <val-skeleton
30374
+ [props]="{
30375
+ type: 'text',
30376
+ width: '80%',
30377
+ height: '18px',
30378
+ animated: config.animated !== false
30379
+ }"
30380
+ ></val-skeleton>
30381
+ <val-skeleton
30382
+ [props]="{
30383
+ type: 'text',
30384
+ width: '60%',
30385
+ height: '14px',
30386
+ animated: config.animated !== false
30387
+ }"
30388
+ ></val-skeleton>
30389
+ </div>
30390
+ </div>
30391
+ }
30392
+ @case ('compact') {
30393
+ <val-skeleton
30394
+ [props]="{
30395
+ type: 'thumbnail',
30396
+ animated: config.animated !== false
30397
+ }"
30398
+ ></val-skeleton>
30399
+ }
30400
+ @default {
30401
+ <val-skeleton
30402
+ [props]="{
30403
+ type: 'card',
30404
+ animated: config.animated !== false
30405
+ }"
30406
+ ></val-skeleton>
30407
+ }
30408
+ }
30409
+ }
30410
+ </div>
30411
+ `, styles: [".skeleton-grid{display:grid;width:100%;&.variant-default{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}&.variant-cards{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}&.variant-compact{grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:12px}}.skeleton-card{border-radius:8px;overflow:hidden;background:var(--ion-color-light, #f4f5f8)}.skeleton-card-content{padding:12px;display:flex;flex-direction:column;gap:8px}\n"] }]
30412
+ }], propDecorators: { config: [{
30413
+ type: Input
30414
+ }] } });
30415
+
30416
+ /**
30417
+ * Template de skeleton para formularios.
30418
+ *
30419
+ * @example
30420
+ * <val-skeleton-form [config]="{ count: 4 }"></val-skeleton-form>
30421
+ *
30422
+ * @example
30423
+ * <val-skeleton-form [config]="{ count: 3, variant: 'compact' }"></val-skeleton-form>
30424
+ */
30425
+ class FormSkeletonComponent {
30426
+ constructor() {
30427
+ this.config = { count: 3 };
28489
30428
  }
28490
- /**
28491
- * Bloquea un dispositivo.
28492
- * Revoca todas las sesiones activas de ese dispositivo.
28493
- */
28494
- blockDevice(deviceId) {
28495
- return this.http.post(`${this.baseUrl}/${deviceId}/block`, {});
30429
+ get fields() {
30430
+ return Array(this.config.count ?? 3).fill(0);
28496
30431
  }
28497
- /**
28498
- * Aprueba un dispositivo pendiente.
28499
- * Cambia el estado de pending_approval a active.
28500
- */
28501
- approveDevice(deviceId) {
28502
- return this.http.post(`${this.baseUrl}/${deviceId}/approve`, {});
30432
+ get isCompact() {
30433
+ return this.config.variant === 'compact';
28503
30434
  }
28504
- /**
28505
- * Elimina un dispositivo registrado.
28506
- */
28507
- deleteDevice(deviceId) {
28508
- return this.http.delete(`${this.baseUrl}/${deviceId}`);
30435
+ get labelWidth() {
30436
+ return this.isCompact ? '60px' : '80px';
28509
30437
  }
28510
- /**
28511
- * Valida un token de acción SIN ejecutarlo.
28512
- * Útil para mostrar confirmación al usuario antes de ejecutar.
28513
- * Este endpoint NO requiere autenticación.
28514
- *
28515
- * @param token Token JWT de acción
28516
- * @returns Información del token si es válido
28517
- *
28518
- * @example
28519
- * ```typescript
28520
- * const token = this.route.snapshot.queryParams['token'];
28521
- * if (token) {
28522
- * const validation = await firstValueFrom(this.deviceService.validateAction(token));
28523
- * if (validation.valid) {
28524
- * // Mostrar confirmación al usuario
28525
- * console.log(`Acción: ${validation.actionType}`);
28526
- * }
28527
- * }
28528
- * ```
28529
- */
28530
- validateAction(token) {
28531
- return this.http.post(`${this.config.apiUrl}/v2/actions/validate`, { token });
30438
+ get inputHeight() {
30439
+ return this.isCompact ? '38px' : '44px';
28532
30440
  }
28533
- /**
28534
- * Ejecuta una acción de dispositivo desde un token de email.
28535
- * Este endpoint NO requiere autenticación.
28536
- * El token viene en la URL del email de alerta de nuevo inicio de sesión.
28537
- *
28538
- * @param token Token JWT de acción (24h, un solo uso)
28539
- *
28540
- * @example
28541
- * ```typescript
28542
- * // En la página de dispositivos, al detectar ?token=xxx en la URL:
28543
- * const token = this.route.snapshot.queryParams['token'];
28544
- * if (token) {
28545
- * const result = await firstValueFrom(this.deviceService.executeAction(token));
28546
- * if (result.success) {
28547
- * console.log(`Dispositivo bloqueado, ${result.sessionsRevoked} sesiones cerradas`);
28548
- * }
28549
- * }
28550
- * ```
28551
- */
28552
- executeAction(token) {
28553
- // Usa el endpoint unificado de acciones
28554
- return this.http.post(`${this.config.apiUrl}/v2/actions/execute`, { token }).pipe(map$1(response => ({
28555
- operationId: response.operationId,
28556
- success: response.success,
28557
- message: response.message,
28558
- action: response.data?.['action'] || 'refuse',
28559
- deviceId: response.data?.['deviceId'] || '',
28560
- sessionsRevoked: response.data?.['sessionsRevoked'],
28561
- })));
30441
+ get buttonWidth() {
30442
+ return this.isCompact ? '120px' : '100%';
28562
30443
  }
28563
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeviceService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$8.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
28564
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeviceService, providedIn: 'root' }); }
30444
+ get buttonHeight() {
30445
+ return this.isCompact ? '38px' : '48px';
30446
+ }
30447
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FormSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
30448
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FormSkeletonComponent, isStandalone: true, selector: "val-skeleton-form", inputs: { config: "config" }, ngImport: i0, template: `
30449
+ <div
30450
+ class="skeleton-form"
30451
+ [class]="'variant-' + (config.variant || 'default') + ' ' + (config.cssClass || '')"
30452
+ >
30453
+ @for (field of fields; track $index) {
30454
+ <div class="skeleton-field">
30455
+ <!-- Label -->
30456
+ <val-skeleton
30457
+ [props]="{
30458
+ type: 'text',
30459
+ width: labelWidth,
30460
+ height: '14px',
30461
+ animated: config.animated !== false
30462
+ }"
30463
+ ></val-skeleton>
30464
+ <!-- Input -->
30465
+ <val-skeleton
30466
+ [props]="{
30467
+ type: 'custom',
30468
+ width: '100%',
30469
+ height: inputHeight,
30470
+ borderRadius: '8px',
30471
+ animated: config.animated !== false
30472
+ }"
30473
+ ></val-skeleton>
30474
+ </div>
30475
+ }
30476
+ <!-- Submit button -->
30477
+ <div class="skeleton-button">
30478
+ <val-skeleton
30479
+ [props]="{
30480
+ type: 'custom',
30481
+ width: buttonWidth,
30482
+ height: buttonHeight,
30483
+ borderRadius: '8px',
30484
+ animated: config.animated !== false
30485
+ }"
30486
+ ></val-skeleton>
30487
+ </div>
30488
+ </div>
30489
+ `, isInline: true, styles: [".skeleton-form{display:flex;flex-direction:column;width:100%;&.variant-default{gap:20px}&.variant-compact{gap:12px}}.skeleton-field{display:flex;flex-direction:column;gap:6px}.skeleton-button{margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
28565
30490
  }
28566
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeviceService, decorators: [{
28567
- type: Injectable,
28568
- args: [{ providedIn: 'root' }]
28569
- }], ctorParameters: () => [{ type: undefined, decorators: [{
28570
- type: Inject,
28571
- args: [VALTECH_AUTH_CONFIG]
28572
- }] }, { type: i1$8.HttpClient }] });
30491
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FormSkeletonComponent, decorators: [{
30492
+ type: Component,
30493
+ args: [{ selector: 'val-skeleton-form', standalone: true, imports: [CommonModule, SkeletonComponent], template: `
30494
+ <div
30495
+ class="skeleton-form"
30496
+ [class]="'variant-' + (config.variant || 'default') + ' ' + (config.cssClass || '')"
30497
+ >
30498
+ @for (field of fields; track $index) {
30499
+ <div class="skeleton-field">
30500
+ <!-- Label -->
30501
+ <val-skeleton
30502
+ [props]="{
30503
+ type: 'text',
30504
+ width: labelWidth,
30505
+ height: '14px',
30506
+ animated: config.animated !== false
30507
+ }"
30508
+ ></val-skeleton>
30509
+ <!-- Input -->
30510
+ <val-skeleton
30511
+ [props]="{
30512
+ type: 'custom',
30513
+ width: '100%',
30514
+ height: inputHeight,
30515
+ borderRadius: '8px',
30516
+ animated: config.animated !== false
30517
+ }"
30518
+ ></val-skeleton>
30519
+ </div>
30520
+ }
30521
+ <!-- Submit button -->
30522
+ <div class="skeleton-button">
30523
+ <val-skeleton
30524
+ [props]="{
30525
+ type: 'custom',
30526
+ width: buttonWidth,
30527
+ height: buttonHeight,
30528
+ borderRadius: '8px',
30529
+ animated: config.animated !== false
30530
+ }"
30531
+ ></val-skeleton>
30532
+ </div>
30533
+ </div>
30534
+ `, styles: [".skeleton-form{display:flex;flex-direction:column;width:100%;&.variant-default{gap:20px}&.variant-compact{gap:12px}}.skeleton-field{display:flex;flex-direction:column;gap:6px}.skeleton-button{margin-top:8px}\n"] }]
30535
+ }], propDecorators: { config: [{
30536
+ type: Input
30537
+ }] } });
28573
30538
 
28574
30539
  /**
28575
- * Servicio para gestión de sesiones activas del usuario.
28576
- * Permite listar y revocar sesiones.
30540
+ * Template de skeleton para perfiles de usuario.
28577
30541
  *
28578
30542
  * @example
28579
- * ```typescript
28580
- * import { SessionService } from 'valtech-components';
28581
- *
28582
- * @Component({...})
28583
- * export class SessionsPage {
28584
- * private sessionService = inject(SessionService);
30543
+ * <val-skeleton-profile></val-skeleton-profile>
28585
30544
  *
28586
- * sessions = signal<SessionInfo[]>([]);
30545
+ * @example
30546
+ * <val-skeleton-profile [config]="{ variant: 'full' }"></val-skeleton-profile>
30547
+ */
30548
+ class ProfileSkeletonComponent {
30549
+ constructor() {
30550
+ this.config = {};
30551
+ }
30552
+ get avatarSize() {
30553
+ return this.config.variant === 'full' ? '96px' : '48px';
30554
+ }
30555
+ get nameWidth() {
30556
+ return this.config.variant === 'full' ? '180px' : '160px';
30557
+ }
30558
+ get subtitleWidth() {
30559
+ return this.config.variant === 'full' ? '140px' : '120px';
30560
+ }
30561
+ get stats() {
30562
+ return [1, 2, 3];
30563
+ }
30564
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ProfileSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
30565
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ProfileSkeletonComponent, isStandalone: true, selector: "val-skeleton-profile", inputs: { config: "config" }, ngImport: i0, template: `
30566
+ <div
30567
+ class="skeleton-profile"
30568
+ [class]="'variant-' + (config.variant || 'default') + ' ' + (config.cssClass || '')"
30569
+ >
30570
+ <div class="profile-header">
30571
+ <!-- Avatar -->
30572
+ <val-skeleton
30573
+ [props]="{
30574
+ type: 'avatar',
30575
+ width: avatarSize,
30576
+ height: avatarSize,
30577
+ animated: config.animated !== false
30578
+ }"
30579
+ ></val-skeleton>
30580
+
30581
+ <div class="profile-info">
30582
+ <!-- Name -->
30583
+ <val-skeleton
30584
+ [props]="{
30585
+ type: 'text',
30586
+ width: nameWidth,
30587
+ height: '20px',
30588
+ animated: config.animated !== false
30589
+ }"
30590
+ ></val-skeleton>
30591
+ <!-- Subtitle -->
30592
+ <val-skeleton
30593
+ [props]="{
30594
+ type: 'text',
30595
+ width: subtitleWidth,
30596
+ height: '14px',
30597
+ animated: config.animated !== false
30598
+ }"
30599
+ ></val-skeleton>
30600
+ @if (config.variant === 'full') {
30601
+ <!-- Extra info -->
30602
+ <val-skeleton
30603
+ [props]="{
30604
+ type: 'text',
30605
+ width: '100px',
30606
+ height: '12px',
30607
+ animated: config.animated !== false
30608
+ }"
30609
+ ></val-skeleton>
30610
+ }
30611
+ </div>
30612
+ </div>
30613
+
30614
+ @if (config.variant === 'full') {
30615
+ <div class="profile-details">
30616
+ <val-skeleton
30617
+ [props]="{
30618
+ type: 'paragraph',
30619
+ lines: 3,
30620
+ animated: config.animated !== false
30621
+ }"
30622
+ ></val-skeleton>
30623
+
30624
+ <div class="profile-stats">
30625
+ @for (stat of stats; track $index) {
30626
+ <div class="stat-item">
30627
+ <val-skeleton
30628
+ [props]="{
30629
+ type: 'text',
30630
+ width: '40px',
30631
+ height: '24px',
30632
+ animated: config.animated !== false
30633
+ }"
30634
+ ></val-skeleton>
30635
+ <val-skeleton
30636
+ [props]="{
30637
+ type: 'text',
30638
+ width: '60px',
30639
+ height: '12px',
30640
+ animated: config.animated !== false
30641
+ }"
30642
+ ></val-skeleton>
30643
+ </div>
30644
+ }
30645
+ </div>
30646
+ </div>
30647
+ }
30648
+ </div>
30649
+ `, isInline: true, styles: [".skeleton-profile{width:100%;&.variant-default .profile-header{display:flex;align-items:center;gap:12px}&.variant-full{.profile-header{display:flex;flex-direction:column;align-items:center;gap:16px;text-align:center}.profile-info{align-items:center}.profile-details{margin-top:24px}}}.profile-info{display:flex;flex-direction:column;gap:6px}.profile-stats{display:flex;justify-content:center;gap:32px;margin-top:20px}.stat-item{display:flex;flex-direction:column;align-items:center;gap:4px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
30650
+ }
30651
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ProfileSkeletonComponent, decorators: [{
30652
+ type: Component,
30653
+ args: [{ selector: 'val-skeleton-profile', standalone: true, imports: [CommonModule, SkeletonComponent], template: `
30654
+ <div
30655
+ class="skeleton-profile"
30656
+ [class]="'variant-' + (config.variant || 'default') + ' ' + (config.cssClass || '')"
30657
+ >
30658
+ <div class="profile-header">
30659
+ <!-- Avatar -->
30660
+ <val-skeleton
30661
+ [props]="{
30662
+ type: 'avatar',
30663
+ width: avatarSize,
30664
+ height: avatarSize,
30665
+ animated: config.animated !== false
30666
+ }"
30667
+ ></val-skeleton>
30668
+
30669
+ <div class="profile-info">
30670
+ <!-- Name -->
30671
+ <val-skeleton
30672
+ [props]="{
30673
+ type: 'text',
30674
+ width: nameWidth,
30675
+ height: '20px',
30676
+ animated: config.animated !== false
30677
+ }"
30678
+ ></val-skeleton>
30679
+ <!-- Subtitle -->
30680
+ <val-skeleton
30681
+ [props]="{
30682
+ type: 'text',
30683
+ width: subtitleWidth,
30684
+ height: '14px',
30685
+ animated: config.animated !== false
30686
+ }"
30687
+ ></val-skeleton>
30688
+ @if (config.variant === 'full') {
30689
+ <!-- Extra info -->
30690
+ <val-skeleton
30691
+ [props]="{
30692
+ type: 'text',
30693
+ width: '100px',
30694
+ height: '12px',
30695
+ animated: config.animated !== false
30696
+ }"
30697
+ ></val-skeleton>
30698
+ }
30699
+ </div>
30700
+ </div>
30701
+
30702
+ @if (config.variant === 'full') {
30703
+ <div class="profile-details">
30704
+ <val-skeleton
30705
+ [props]="{
30706
+ type: 'paragraph',
30707
+ lines: 3,
30708
+ animated: config.animated !== false
30709
+ }"
30710
+ ></val-skeleton>
30711
+
30712
+ <div class="profile-stats">
30713
+ @for (stat of stats; track $index) {
30714
+ <div class="stat-item">
30715
+ <val-skeleton
30716
+ [props]="{
30717
+ type: 'text',
30718
+ width: '40px',
30719
+ height: '24px',
30720
+ animated: config.animated !== false
30721
+ }"
30722
+ ></val-skeleton>
30723
+ <val-skeleton
30724
+ [props]="{
30725
+ type: 'text',
30726
+ width: '60px',
30727
+ height: '12px',
30728
+ animated: config.animated !== false
30729
+ }"
30730
+ ></val-skeleton>
30731
+ </div>
30732
+ }
30733
+ </div>
30734
+ </div>
30735
+ }
30736
+ </div>
30737
+ `, styles: [".skeleton-profile{width:100%;&.variant-default .profile-header{display:flex;align-items:center;gap:12px}&.variant-full{.profile-header{display:flex;flex-direction:column;align-items:center;gap:16px;text-align:center}.profile-info{align-items:center}.profile-details{margin-top:24px}}}.profile-info{display:flex;flex-direction:column;gap:6px}.profile-stats{display:flex;justify-content:center;gap:32px;margin-top:20px}.stat-item{display:flex;flex-direction:column;align-items:center;gap:4px}\n"] }]
30738
+ }], propDecorators: { config: [{
30739
+ type: Input
30740
+ }] } });
30741
+
30742
+ /**
30743
+ * Template de skeleton para tablas.
28587
30744
  *
28588
- * async ngOnInit() {
28589
- * const sessions = await firstValueFrom(this.sessionService.listSessions());
28590
- * this.sessions.set(sessions);
28591
- * }
30745
+ * @example
30746
+ * <val-skeleton-table [config]="{ columns: 5, rows: 10 }"></val-skeleton-table>
30747
+ */
30748
+ class TableSkeletonComponent {
30749
+ constructor() {
30750
+ this.config = { columns: 4, rows: 5 };
30751
+ }
30752
+ get columns() {
30753
+ return Array(this.config.columns ?? 4).fill(0);
30754
+ }
30755
+ get rows() {
30756
+ return Array(this.config.rows ?? 5).fill(0);
30757
+ }
30758
+ getColumnFlex(index) {
30759
+ // Primera columna mas ancha, ultima mas angosta
30760
+ const totalCols = this.config.columns ?? 4;
30761
+ if (index === 0)
30762
+ return '1.5';
30763
+ if (index === totalCols - 1)
30764
+ return '0.8';
30765
+ return '1';
30766
+ }
30767
+ getColumnContentWidth(index) {
30768
+ // Variar anchos para aspecto natural
30769
+ const widths = ['90%', '70%', '60%', '80%', '50%'];
30770
+ return widths[index % widths.length];
30771
+ }
30772
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TableSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
30773
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TableSkeletonComponent, isStandalone: true, selector: "val-skeleton-table", inputs: { config: "config" }, ngImport: i0, template: `
30774
+ <div class="skeleton-table" [class]="config.cssClass">
30775
+ <!-- Header -->
30776
+ <div class="skeleton-table-header">
30777
+ @for (col of columns; track $index) {
30778
+ <div class="skeleton-table-cell" [style.flex]="getColumnFlex($index)">
30779
+ <val-skeleton
30780
+ [props]="{
30781
+ type: 'text',
30782
+ width: '80%',
30783
+ height: '16px',
30784
+ animated: config.animated !== false
30785
+ }"
30786
+ ></val-skeleton>
30787
+ </div>
30788
+ }
30789
+ </div>
30790
+
30791
+ <!-- Rows -->
30792
+ @for (row of rows; track $index) {
30793
+ <div class="skeleton-table-row">
30794
+ @for (col of columns; track $index) {
30795
+ <div class="skeleton-table-cell" [style.flex]="getColumnFlex($index)">
30796
+ <val-skeleton
30797
+ [props]="{
30798
+ type: 'text',
30799
+ width: getColumnContentWidth($index),
30800
+ height: '14px',
30801
+ animated: config.animated !== false
30802
+ }"
30803
+ ></val-skeleton>
30804
+ </div>
30805
+ }
30806
+ </div>
30807
+ }
30808
+ </div>
30809
+ `, isInline: true, styles: [".skeleton-table{width:100%;overflow:hidden;border-radius:8px;border:1px solid var(--ion-color-light-shade, #d7d8da)}.skeleton-table-header{display:flex;align-items:center;gap:16px;padding:14px 16px;background:var(--ion-color-light, #f4f5f8);border-bottom:1px solid var(--ion-color-light-shade, #d7d8da)}.skeleton-table-row{display:flex;align-items:center;gap:16px;padding:12px 16px;border-bottom:1px solid var(--ion-color-light-shade, #d7d8da);&:last-child{border-bottom:none}}.skeleton-table-cell{min-width:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
30810
+ }
30811
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TableSkeletonComponent, decorators: [{
30812
+ type: Component,
30813
+ args: [{ selector: 'val-skeleton-table', standalone: true, imports: [CommonModule, SkeletonComponent], template: `
30814
+ <div class="skeleton-table" [class]="config.cssClass">
30815
+ <!-- Header -->
30816
+ <div class="skeleton-table-header">
30817
+ @for (col of columns; track $index) {
30818
+ <div class="skeleton-table-cell" [style.flex]="getColumnFlex($index)">
30819
+ <val-skeleton
30820
+ [props]="{
30821
+ type: 'text',
30822
+ width: '80%',
30823
+ height: '16px',
30824
+ animated: config.animated !== false
30825
+ }"
30826
+ ></val-skeleton>
30827
+ </div>
30828
+ }
30829
+ </div>
30830
+
30831
+ <!-- Rows -->
30832
+ @for (row of rows; track $index) {
30833
+ <div class="skeleton-table-row">
30834
+ @for (col of columns; track $index) {
30835
+ <div class="skeleton-table-cell" [style.flex]="getColumnFlex($index)">
30836
+ <val-skeleton
30837
+ [props]="{
30838
+ type: 'text',
30839
+ width: getColumnContentWidth($index),
30840
+ height: '14px',
30841
+ animated: config.animated !== false
30842
+ }"
30843
+ ></val-skeleton>
30844
+ </div>
30845
+ }
30846
+ </div>
30847
+ }
30848
+ </div>
30849
+ `, styles: [".skeleton-table{width:100%;overflow:hidden;border-radius:8px;border:1px solid var(--ion-color-light-shade, #d7d8da)}.skeleton-table-header{display:flex;align-items:center;gap:16px;padding:14px 16px;background:var(--ion-color-light, #f4f5f8);border-bottom:1px solid var(--ion-color-light-shade, #d7d8da)}.skeleton-table-row{display:flex;align-items:center;gap:16px;padding:12px 16px;border-bottom:1px solid var(--ion-color-light-shade, #d7d8da);&:last-child{border-bottom:none}}.skeleton-table-cell{min-width:0}\n"] }]
30850
+ }], propDecorators: { config: [{
30851
+ type: Input
30852
+ }] } });
30853
+
30854
+ /**
30855
+ * Template de skeleton para paginas de detalle.
28592
30856
  *
28593
- * async revokeSession(sessionId: string) {
28594
- * await firstValueFrom(this.sessionService.revokeSession(sessionId));
28595
- * // Recargar lista
28596
- * }
30857
+ * @example
30858
+ * <val-skeleton-detail></val-skeleton-detail>
28597
30859
  *
28598
- * async revokeAllOthers() {
28599
- * const result = await firstValueFrom(this.sessionService.revokeAllSessions());
28600
- * console.log(`${result.sessionsRevoked} sesiones cerradas`);
28601
- * }
28602
- * }
28603
- * ```
30860
+ * @example
30861
+ * <val-skeleton-detail [config]="{ sections: 3 }"></val-skeleton-detail>
28604
30862
  */
28605
- class SessionService {
28606
- constructor(config, http) {
28607
- this.config = config;
28608
- this.http = http;
28609
- }
28610
- get baseUrl() {
28611
- return `${this.config.apiUrl}/v2/users/me/sessions`;
28612
- }
28613
- /**
28614
- * Lista todas las sesiones activas del usuario.
28615
- * La sesión actual está marcada con isCurrent=true.
28616
- */
28617
- listSessions() {
28618
- return this.http.get(this.baseUrl).pipe(map$1(response => response.sessions));
30863
+ class DetailSkeletonComponent {
30864
+ constructor() {
30865
+ this.config = { sections: 2 };
28619
30866
  }
28620
- /**
28621
- * Revoca una sesión específica.
28622
- * Fuerza el cierre de sesión en ese dispositivo/navegador.
28623
- */
28624
- revokeSession(sessionId) {
28625
- return this.http.delete(`${this.baseUrl}/${sessionId}`);
30867
+ get sections() {
30868
+ return Array(this.config.sections ?? 2).fill(0);
28626
30869
  }
28627
- /**
28628
- * Revoca todas las sesiones excepto la actual.
28629
- * Útil para "cerrar sesión en todos los dispositivos".
28630
- *
28631
- * @returns Número de sesiones revocadas
28632
- */
28633
- revokeAllSessions() {
28634
- return this.http.delete(this.baseUrl);
30870
+ get metaItems() {
30871
+ return [1, 2, 3];
28635
30872
  }
28636
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$8.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
28637
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionService, providedIn: 'root' }); }
30873
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DetailSkeletonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
30874
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: DetailSkeletonComponent, isStandalone: true, selector: "val-skeleton-detail", inputs: { config: "config" }, ngImport: i0, template: `
30875
+ <div class="skeleton-detail" [class]="config.cssClass">
30876
+ <!-- Hero/Header section -->
30877
+ <div class="skeleton-detail-hero">
30878
+ <val-skeleton
30879
+ [props]="{
30880
+ type: 'custom',
30881
+ width: '100%',
30882
+ height: '200px',
30883
+ borderRadius: '8px',
30884
+ animated: config.animated !== false
30885
+ }"
30886
+ ></val-skeleton>
30887
+ </div>
30888
+
30889
+ <!-- Title section -->
30890
+ <div class="skeleton-detail-title">
30891
+ <val-skeleton
30892
+ [props]="{
30893
+ type: 'text',
30894
+ width: '70%',
30895
+ height: '28px',
30896
+ animated: config.animated !== false
30897
+ }"
30898
+ ></val-skeleton>
30899
+ <val-skeleton
30900
+ [props]="{
30901
+ type: 'text',
30902
+ width: '40%',
30903
+ height: '16px',
30904
+ animated: config.animated !== false
30905
+ }"
30906
+ ></val-skeleton>
30907
+ </div>
30908
+
30909
+ <!-- Metadata row -->
30910
+ <div class="skeleton-detail-meta">
30911
+ @for (meta of metaItems; track $index) {
30912
+ <val-skeleton
30913
+ [props]="{
30914
+ type: 'custom',
30915
+ width: '80px',
30916
+ height: '24px',
30917
+ borderRadius: '12px',
30918
+ animated: config.animated !== false
30919
+ }"
30920
+ ></val-skeleton>
30921
+ }
30922
+ </div>
30923
+
30924
+ <!-- Content sections -->
30925
+ @for (section of sections; track $index) {
30926
+ <div class="skeleton-detail-section">
30927
+ <!-- Section title -->
30928
+ <val-skeleton
30929
+ [props]="{
30930
+ type: 'text',
30931
+ width: '30%',
30932
+ height: '20px',
30933
+ animated: config.animated !== false
30934
+ }"
30935
+ ></val-skeleton>
30936
+ <!-- Section content -->
30937
+ <val-skeleton
30938
+ [props]="{
30939
+ type: 'paragraph',
30940
+ lines: 4,
30941
+ animated: config.animated !== false
30942
+ }"
30943
+ ></val-skeleton>
30944
+ </div>
30945
+ }
30946
+
30947
+ <!-- Action buttons -->
30948
+ <div class="skeleton-detail-actions">
30949
+ <val-skeleton
30950
+ [props]="{
30951
+ type: 'custom',
30952
+ width: '120px',
30953
+ height: '44px',
30954
+ borderRadius: '8px',
30955
+ animated: config.animated !== false
30956
+ }"
30957
+ ></val-skeleton>
30958
+ <val-skeleton
30959
+ [props]="{
30960
+ type: 'custom',
30961
+ width: '120px',
30962
+ height: '44px',
30963
+ borderRadius: '8px',
30964
+ animated: config.animated !== false
30965
+ }"
30966
+ ></val-skeleton>
30967
+ </div>
30968
+ </div>
30969
+ `, isInline: true, styles: [".skeleton-detail{display:flex;flex-direction:column;gap:20px;width:100%}.skeleton-detail-hero{width:100%}.skeleton-detail-title{display:flex;flex-direction:column;gap:8px}.skeleton-detail-meta{display:flex;gap:12px;flex-wrap:wrap}.skeleton-detail-section{display:flex;flex-direction:column;gap:12px;padding-top:16px;border-top:1px solid var(--ion-color-light-shade, #d7d8da)}.skeleton-detail-actions{display:flex;gap:12px;padding-top:16px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
30970
+ }
30971
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DetailSkeletonComponent, decorators: [{
30972
+ type: Component,
30973
+ args: [{ selector: 'val-skeleton-detail', standalone: true, imports: [CommonModule, SkeletonComponent], template: `
30974
+ <div class="skeleton-detail" [class]="config.cssClass">
30975
+ <!-- Hero/Header section -->
30976
+ <div class="skeleton-detail-hero">
30977
+ <val-skeleton
30978
+ [props]="{
30979
+ type: 'custom',
30980
+ width: '100%',
30981
+ height: '200px',
30982
+ borderRadius: '8px',
30983
+ animated: config.animated !== false
30984
+ }"
30985
+ ></val-skeleton>
30986
+ </div>
30987
+
30988
+ <!-- Title section -->
30989
+ <div class="skeleton-detail-title">
30990
+ <val-skeleton
30991
+ [props]="{
30992
+ type: 'text',
30993
+ width: '70%',
30994
+ height: '28px',
30995
+ animated: config.animated !== false
30996
+ }"
30997
+ ></val-skeleton>
30998
+ <val-skeleton
30999
+ [props]="{
31000
+ type: 'text',
31001
+ width: '40%',
31002
+ height: '16px',
31003
+ animated: config.animated !== false
31004
+ }"
31005
+ ></val-skeleton>
31006
+ </div>
31007
+
31008
+ <!-- Metadata row -->
31009
+ <div class="skeleton-detail-meta">
31010
+ @for (meta of metaItems; track $index) {
31011
+ <val-skeleton
31012
+ [props]="{
31013
+ type: 'custom',
31014
+ width: '80px',
31015
+ height: '24px',
31016
+ borderRadius: '12px',
31017
+ animated: config.animated !== false
31018
+ }"
31019
+ ></val-skeleton>
31020
+ }
31021
+ </div>
31022
+
31023
+ <!-- Content sections -->
31024
+ @for (section of sections; track $index) {
31025
+ <div class="skeleton-detail-section">
31026
+ <!-- Section title -->
31027
+ <val-skeleton
31028
+ [props]="{
31029
+ type: 'text',
31030
+ width: '30%',
31031
+ height: '20px',
31032
+ animated: config.animated !== false
31033
+ }"
31034
+ ></val-skeleton>
31035
+ <!-- Section content -->
31036
+ <val-skeleton
31037
+ [props]="{
31038
+ type: 'paragraph',
31039
+ lines: 4,
31040
+ animated: config.animated !== false
31041
+ }"
31042
+ ></val-skeleton>
31043
+ </div>
31044
+ }
31045
+
31046
+ <!-- Action buttons -->
31047
+ <div class="skeleton-detail-actions">
31048
+ <val-skeleton
31049
+ [props]="{
31050
+ type: 'custom',
31051
+ width: '120px',
31052
+ height: '44px',
31053
+ borderRadius: '8px',
31054
+ animated: config.animated !== false
31055
+ }"
31056
+ ></val-skeleton>
31057
+ <val-skeleton
31058
+ [props]="{
31059
+ type: 'custom',
31060
+ width: '120px',
31061
+ height: '44px',
31062
+ borderRadius: '8px',
31063
+ animated: config.animated !== false
31064
+ }"
31065
+ ></val-skeleton>
31066
+ </div>
31067
+ </div>
31068
+ `, styles: [".skeleton-detail{display:flex;flex-direction:column;gap:20px;width:100%}.skeleton-detail-hero{width:100%}.skeleton-detail-title{display:flex;flex-direction:column;gap:8px}.skeleton-detail-meta{display:flex;gap:12px;flex-wrap:wrap}.skeleton-detail-section{display:flex;flex-direction:column;gap:12px;padding-top:16px;border-top:1px solid var(--ion-color-light-shade, #d7d8da)}.skeleton-detail-actions{display:flex;gap:12px;padding-top:16px}\n"] }]
31069
+ }], propDecorators: { config: [{
31070
+ type: Input
31071
+ }] } });
31072
+
31073
+ /**
31074
+ * Configura el sistema de skeletons para Valtech Components.
31075
+ *
31076
+ * @param config Configuracion de skeletons
31077
+ * @returns Providers para app.config.ts
31078
+ *
31079
+ * @example
31080
+ * // app.config.ts
31081
+ * import { provideValtechSkeleton } from 'valtech-components';
31082
+ *
31083
+ * export const appConfig: ApplicationConfig = {
31084
+ * providers: [
31085
+ * provideValtechSkeleton({
31086
+ * animated: true,
31087
+ * defaultDelay: 100,
31088
+ * defaultMinTime: 500,
31089
+ * templates: [
31090
+ * { name: 'custom-card', component: MyCustomCardSkeleton }
31091
+ * ]
31092
+ * }),
31093
+ * ]
31094
+ * };
31095
+ *
31096
+ * @example
31097
+ * // Uso minimo - usa configuracion por defecto
31098
+ * providers: [provideValtechSkeleton()]
31099
+ */
31100
+ function provideValtechSkeleton(config = {}) {
31101
+ const mergedConfig = { ...DEFAULT_SKELETON_CONFIG, ...config };
31102
+ return makeEnvironmentProviders([
31103
+ {
31104
+ provide: APP_INITIALIZER,
31105
+ useFactory: (skeletonService) => {
31106
+ return () => {
31107
+ // Configurar servicio
31108
+ skeletonService.configure(mergedConfig);
31109
+ // Registrar templates built-in
31110
+ skeletonService.registerTemplate('list', ListSkeletonComponent, { count: 3 });
31111
+ skeletonService.registerTemplate('list-avatar', ListSkeletonComponent, {
31112
+ count: 3,
31113
+ variant: 'avatar',
31114
+ });
31115
+ skeletonService.registerTemplate('grid', GridSkeletonComponent, { count: 4 });
31116
+ skeletonService.registerTemplate('grid-cards', GridSkeletonComponent, {
31117
+ count: 6,
31118
+ variant: 'cards',
31119
+ });
31120
+ skeletonService.registerTemplate('form', FormSkeletonComponent, { count: 3 });
31121
+ skeletonService.registerTemplate('form-compact', FormSkeletonComponent, {
31122
+ count: 2,
31123
+ variant: 'compact',
31124
+ });
31125
+ skeletonService.registerTemplate('profile', ProfileSkeletonComponent, {});
31126
+ skeletonService.registerTemplate('profile-full', ProfileSkeletonComponent, {
31127
+ variant: 'full',
31128
+ });
31129
+ skeletonService.registerTemplate('table', TableSkeletonComponent, {
31130
+ columns: 4,
31131
+ rows: 5,
31132
+ });
31133
+ skeletonService.registerTemplate('detail', DetailSkeletonComponent, { sections: 2 });
31134
+ };
31135
+ },
31136
+ deps: [SkeletonService],
31137
+ multi: true,
31138
+ },
31139
+ ]);
31140
+ }
31141
+
31142
+ // Types
31143
+
31144
+ /**
31145
+ * Estado inicial para controladores de paginacion.
31146
+ */
31147
+ function createInitialPaginationState(pageSize, initialItems = []) {
31148
+ return {
31149
+ items: initialItems,
31150
+ page: 0,
31151
+ pageSize,
31152
+ hasMore: true,
31153
+ isLoading: false,
31154
+ error: undefined,
31155
+ };
28638
31156
  }
28639
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionService, decorators: [{
28640
- type: Injectable,
28641
- args: [{ providedIn: 'root' }]
28642
- }], ctorParameters: () => [{ type: undefined, decorators: [{
28643
- type: Inject,
28644
- args: [VALTECH_AUTH_CONFIG]
28645
- }] }, { type: i1$8.HttpClient }] });
28646
31157
 
28647
31158
  /**
28648
- * Componente de callback para OAuth.
28649
- *
28650
- * Este componente procesa la respuesta del servidor OAuth y envía
28651
- * los tokens a la ventana padre via postMessage.
28652
- *
28653
- * Debe agregarse a las rutas de la aplicación:
28654
- * ```typescript
28655
- * // app.routes.ts
28656
- * import { OAuthCallbackComponent } from 'valtech-components';
28657
- *
28658
- * export const routes: Routes = [
28659
- * { path: 'auth/oauth/callback', component: OAuthCallbackComponent },
28660
- * // ... otras rutas
28661
- * ];
28662
- * ```
28663
- *
28664
- * El backend redirige a esta ruta con los tokens en query params:
28665
- * `/auth/oauth/callback?access_token=xxx&refresh_token=xxx&expires_in=900`
28666
- *
28667
- * O con error:
28668
- * `/auth/oauth/callback?error=INVALID_CODE&error_description=...`
31159
+ * Implementacion del controlador de paginacion.
28669
31160
  */
28670
- class OAuthCallbackComponent {
28671
- constructor() {
28672
- this.message = 'Procesando autenticación...';
28673
- }
28674
- ngOnInit() {
28675
- this.processCallback();
28676
- }
28677
- processCallback() {
28678
- const params = new URLSearchParams(window.location.search);
28679
- // Verificar si hay error
28680
- const error = params.get('error');
28681
- if (error) {
28682
- this.sendToParent({
28683
- type: 'oauth-callback',
28684
- error: {
28685
- code: error,
28686
- message: params.get('error_description') || 'Error de autenticación',
28687
- },
28688
- });
28689
- this.message = 'Error de autenticación';
28690
- this.closeAfterDelay();
31161
+ class PaginationControllerImpl {
31162
+ constructor(config) {
31163
+ this.config = config;
31164
+ this._state = signal(createInitialPaginationState(this.config.pageSize ?? 20, this.config.initialItems));
31165
+ this.state = this._state.asReadonly();
31166
+ this.items = computed(() => this._state().items);
31167
+ this.isLoading = computed(() => this._state().isLoading);
31168
+ this.hasMore = computed(() => this._state().hasMore);
31169
+ this.error = computed(() => this._state().error ?? null);
31170
+ this.currentPage = computed(() => this._state().page);
31171
+ this.total = computed(() => this._state().total);
31172
+ }
31173
+ async loadNext() {
31174
+ const currentState = this._state();
31175
+ if (currentState.isLoading || !currentState.hasMore)
28691
31176
  return;
31177
+ this._state.update((s) => ({ ...s, isLoading: true, error: undefined }));
31178
+ try {
31179
+ const params = {
31180
+ strategy: this.config.strategy,
31181
+ page: currentState.page,
31182
+ pageSize: currentState.pageSize,
31183
+ cursor: currentState.nextCursor,
31184
+ direction: 'forward',
31185
+ };
31186
+ const result = await this.executeLoad(params);
31187
+ this._state.update((s) => ({
31188
+ ...s,
31189
+ items: [...s.items, ...result.items],
31190
+ page: s.page + 1,
31191
+ hasMore: result.hasMore,
31192
+ total: result.total ?? s.total,
31193
+ nextCursor: result.nextCursor,
31194
+ prevCursor: result.prevCursor ?? s.prevCursor,
31195
+ isLoading: false,
31196
+ }));
28692
31197
  }
28693
- // Extraer tokens
28694
- const accessToken = params.get('access_token');
28695
- const refreshToken = params.get('refresh_token');
28696
- const expiresIn = params.get('expires_in');
28697
- const firebaseToken = params.get('firebase_token');
28698
- if (!accessToken || !refreshToken) {
28699
- this.sendToParent({
28700
- type: 'oauth-callback',
28701
- error: {
28702
- code: 'MISSING_TOKENS',
28703
- message: 'No se recibieron los tokens de autenticación',
28704
- },
28705
- });
28706
- this.message = 'Error: tokens no recibidos';
28707
- this.closeAfterDelay();
28708
- return;
31198
+ catch (err) {
31199
+ this._state.update((s) => ({
31200
+ ...s,
31201
+ isLoading: false,
31202
+ error: err,
31203
+ }));
28709
31204
  }
28710
- // Extraer roles y permisos (pueden venir como JSON en base64)
28711
- let roles;
28712
- let permissions;
28713
- const rolesParam = params.get('roles');
28714
- const permissionsParam = params.get('permissions');
28715
- if (rolesParam) {
28716
- try {
28717
- roles = JSON.parse(atob(rolesParam));
28718
- }
28719
- catch {
28720
- roles = rolesParam.split(',');
28721
- }
31205
+ }
31206
+ async loadPrevious() {
31207
+ const currentState = this._state();
31208
+ if (currentState.isLoading || currentState.page <= 0)
31209
+ return;
31210
+ this._state.update((s) => ({ ...s, isLoading: true, error: undefined }));
31211
+ try {
31212
+ const params = {
31213
+ strategy: this.config.strategy,
31214
+ page: currentState.page - 1,
31215
+ pageSize: currentState.pageSize,
31216
+ cursor: currentState.prevCursor,
31217
+ direction: 'backward',
31218
+ };
31219
+ const result = await this.executeLoad(params);
31220
+ this._state.update((s) => ({
31221
+ ...s,
31222
+ items: [...result.items, ...s.items],
31223
+ page: s.page - 1,
31224
+ prevCursor: result.prevCursor,
31225
+ isLoading: false,
31226
+ }));
28722
31227
  }
28723
- if (permissionsParam) {
28724
- try {
28725
- permissions = JSON.parse(atob(permissionsParam));
28726
- }
28727
- catch {
28728
- permissions = permissionsParam.split(',');
28729
- }
31228
+ catch (err) {
31229
+ this._state.update((s) => ({
31230
+ ...s,
31231
+ isLoading: false,
31232
+ error: err,
31233
+ }));
28730
31234
  }
28731
- // Enviar tokens a la ventana padre
28732
- const result = {
28733
- accessToken,
28734
- refreshToken,
28735
- expiresIn: expiresIn ? parseInt(expiresIn, 10) : 900,
28736
- firebaseToken: firebaseToken || undefined,
28737
- roles,
28738
- permissions,
28739
- isNewUser: params.get('is_new_user') === 'true',
28740
- linked: params.get('linked') === 'true',
28741
- };
28742
- this.sendToParent({
28743
- type: 'oauth-callback',
28744
- tokens: result,
28745
- });
28746
- this.message = 'Autenticación exitosa';
28747
- this.closeAfterDelay();
28748
31235
  }
28749
- sendToParent(data) {
28750
- if (window.opener) {
28751
- // Enviar al opener (ventana que abrió el popup)
28752
- window.opener.postMessage(data, window.location.origin);
31236
+ async refresh() {
31237
+ this._state.update((s) => ({
31238
+ ...s,
31239
+ items: [],
31240
+ page: 0,
31241
+ hasMore: true,
31242
+ nextCursor: undefined,
31243
+ prevCursor: undefined,
31244
+ isLoading: true,
31245
+ error: undefined,
31246
+ }));
31247
+ try {
31248
+ const params = {
31249
+ strategy: this.config.strategy,
31250
+ page: 0,
31251
+ pageSize: this._state().pageSize,
31252
+ direction: 'forward',
31253
+ };
31254
+ const result = await this.executeLoad(params);
31255
+ this._state.update((s) => ({
31256
+ ...s,
31257
+ items: result.items,
31258
+ page: 1,
31259
+ hasMore: result.hasMore,
31260
+ total: result.total,
31261
+ nextCursor: result.nextCursor,
31262
+ isLoading: false,
31263
+ }));
28753
31264
  }
28754
- else if (window.parent !== window) {
28755
- // Enviar al parent (si estamos en iframe)
28756
- window.parent.postMessage(data, window.location.origin);
31265
+ catch (err) {
31266
+ this._state.update((s) => ({
31267
+ ...s,
31268
+ isLoading: false,
31269
+ error: err,
31270
+ }));
28757
31271
  }
28758
31272
  }
28759
- closeAfterDelay() {
28760
- // Dar tiempo para que el mensaje se envíe antes de cerrar
28761
- setTimeout(() => {
28762
- if (window.opener) {
28763
- window.close();
28764
- }
28765
- }, 500);
31273
+ reset() {
31274
+ this._state.set(createInitialPaginationState(this.config.pageSize ?? 20, this.config.initialItems));
31275
+ }
31276
+ updateItem(predicate, updates) {
31277
+ this._state.update((s) => ({
31278
+ ...s,
31279
+ items: s.items.map((item) => (predicate(item) ? { ...item, ...updates } : item)),
31280
+ }));
31281
+ }
31282
+ removeItem(predicate) {
31283
+ this._state.update((s) => ({
31284
+ ...s,
31285
+ items: s.items.filter((item) => !predicate(item)),
31286
+ }));
31287
+ }
31288
+ prependItems(items) {
31289
+ this._state.update((s) => ({
31290
+ ...s,
31291
+ items: [...items, ...s.items],
31292
+ }));
31293
+ }
31294
+ appendItems(items) {
31295
+ this._state.update((s) => ({
31296
+ ...s,
31297
+ items: [...s.items, ...items],
31298
+ }));
31299
+ }
31300
+ async executeLoad(params) {
31301
+ const result = this.config.loadFn(params);
31302
+ if (isObservable(result)) {
31303
+ return await firstValueFrom(result);
31304
+ }
31305
+ return await result;
28766
31306
  }
28767
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: OAuthCallbackComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
28768
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: OAuthCallbackComponent, isStandalone: true, selector: "val-oauth-callback", ngImport: i0, template: `
28769
- <div class="oauth-callback">
28770
- <div class="oauth-callback__spinner"></div>
28771
- <p class="oauth-callback__text">{{ message }}</p>
28772
- </div>
28773
- `, isInline: true, styles: [".oauth-callback{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}.oauth-callback__spinner{width:40px;height:40px;border:3px solid #f3f3f3;border-top:3px solid #3498db;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:16px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.oauth-callback__text{color:#666;font-size:14px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] }); }
28774
31307
  }
28775
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: OAuthCallbackComponent, decorators: [{
28776
- type: Component,
28777
- args: [{ selector: 'val-oauth-callback', standalone: true, imports: [CommonModule], template: `
28778
- <div class="oauth-callback">
28779
- <div class="oauth-callback__spinner"></div>
28780
- <p class="oauth-callback__text">{{ message }}</p>
28781
- </div>
28782
- `, styles: [".oauth-callback{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}.oauth-callback__spinner{width:40px;height:40px;border:3px solid #f3f3f3;border-top:3px solid #3498db;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:16px}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.oauth-callback__text{color:#666;font-size:14px}\n"] }]
28783
- }] });
28784
-
28785
31308
  /**
28786
- * Valtech Auth Service
28787
- *
28788
- * Servicio de autenticación reutilizable para aplicaciones Angular.
28789
- * Proporciona autenticación con AuthV2, MFA, sincronización entre pestañas,
28790
- * refresh proactivo de tokens, y registro automático de dispositivos para
28791
- * push notifications.
31309
+ * Servicio para crear controladores de paginacion reutilizables.
28792
31310
  *
28793
31311
  * @example
28794
- * ```typescript
28795
- * // En main.ts
28796
- * import { bootstrapApplication } from '@angular/platform-browser';
28797
- * import { provideValtechAuth, provideValtechFirebase } from 'valtech-components';
28798
- * import { environment } from './environments/environment';
31312
+ * // En un componente
31313
+ * pagination = inject(PaginationService);
28799
31314
  *
28800
- * bootstrapApplication(AppComponent, {
28801
- * providers: [
28802
- * provideValtechFirebase(environment.firebase),
28803
- * provideValtechAuth({
28804
- * apiUrl: environment.apiUrl,
28805
- * enableFirebaseIntegration: true,
28806
- * enableDeviceRegistration: true, // Auto-registra dispositivos para push
28807
- * }),
28808
- * ],
31315
+ * usersController = this.pagination.createController<User>({
31316
+ * strategy: 'offset',
31317
+ * pageSize: 20,
31318
+ * loadFn: (params) => this.userService.getUsers(params.page, params.pageSize)
28809
31319
  * });
28810
31320
  *
28811
- * // En app.routes.ts
28812
- * import { authGuard, guestGuard, permissionGuard } from 'valtech-components';
28813
- *
28814
- * const routes: Routes = [
28815
- * { path: 'login', canActivate: [guestGuard], loadComponent: () => import('./login.page') },
28816
- * { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('./dashboard.page') },
28817
- * { path: 'admin', canActivate: [authGuard, permissionGuard('admin:*')], loadComponent: () => import('./admin.page') },
28818
- * ];
28819
- *
28820
- * // En componentes
28821
- * import { AuthService } from 'valtech-components';
28822
- *
28823
- * @Component({...})
28824
- * export class LoginComponent {
28825
- * private auth = inject(AuthService);
28826
- *
28827
- * async login() {
28828
- * await firstValueFrom(this.auth.signin({ email, password }));
28829
- * if (this.auth.mfaPending().required) {
28830
- * // Mostrar UI de MFA
28831
- * } else {
28832
- * this.router.navigate(['/dashboard']);
28833
- * }
28834
- * }
28835
- *
28836
- * // Habilitar notificaciones push (solicita permisos + registra dispositivo)
28837
- * async enableNotifications() {
28838
- * const result = await this.auth.enableNotifications();
28839
- * if (result.granted) {
28840
- * console.log('Notificaciones habilitadas');
28841
- * }
28842
- * }
28843
- *
28844
- * // Verificar estado de permisos
28845
- * get canReceiveNotifications(): boolean {
28846
- * return this.auth.getNotificationPermissionState() === 'granted';
28847
- * }
31321
+ * // En el template
31322
+ * @for (user of usersController.items(); track user.id) {
31323
+ * <app-user-card [user]="user"></app-user-card>
31324
+ * }
28848
31325
  *
28849
- * // En template: usar signals directamente
28850
- * // {{ auth.user()?.email }}
28851
- * // @if (auth.hasPermission('templates:edit')) { ... }
31326
+ * @if (usersController.hasMore()) {
31327
+ * <ion-button (click)="usersController.loadNext()">Cargar mas</ion-button>
28852
31328
  * }
28853
- * ```
28854
31329
  */
28855
- // Tipos
31330
+ class PaginationService {
31331
+ /**
31332
+ * Crea un controlador de paginacion para una fuente de datos.
31333
+ *
31334
+ * @param config Configuracion del controlador
31335
+ * @returns Controlador de paginacion
31336
+ */
31337
+ createController(config) {
31338
+ return new PaginationControllerImpl(config);
31339
+ }
31340
+ /**
31341
+ * Crea un controlador simple para arrays estaticos con paginacion local.
31342
+ *
31343
+ * @param items Array completo de items
31344
+ * @param pageSize Tamano de pagina
31345
+ * @returns Controlador de paginacion
31346
+ */
31347
+ createLocalController(items, pageSize = 20) {
31348
+ let currentIndex = 0;
31349
+ return this.createController({
31350
+ strategy: 'offset',
31351
+ pageSize,
31352
+ loadFn: async (params) => {
31353
+ const start = params.page * params.pageSize;
31354
+ const end = start + params.pageSize;
31355
+ const pageItems = items.slice(start, end);
31356
+ return {
31357
+ items: pageItems,
31358
+ hasMore: end < items.length,
31359
+ total: items.length,
31360
+ };
31361
+ },
31362
+ });
31363
+ }
31364
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PaginationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
31365
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PaginationService, providedIn: 'root' }); }
31366
+ }
31367
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PaginationService, decorators: [{
31368
+ type: Injectable,
31369
+ args: [{ providedIn: 'root' }]
31370
+ }] });
31371
+
31372
+ // Types
28856
31373
 
28857
31374
  /**
28858
31375
  * Ads Loader Service
@@ -29584,5 +32101,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
29584
32101
  * Generated bundle index. Do not edit.
29585
32102
  */
29586
32103
 
29587
- export { AD_SIZE_MAP, ARTICLE_SPACING, AccordionComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, ArticleBuilder, ArticleComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, BannerComponent, BaseDefault, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, CheckInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContentLoaderComponent, CountdownComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_CANCEL_BUTTON, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_EMPTY_STATE, DEFAULT_LEGEND_LABELS, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAYMENT_STATUS_COLORS, DEFAULT_PAYMENT_STATUS_LABELS, DEFAULT_PLATFORMS, DEFAULT_STATUS_COLORS, DEFAULT_STATUS_LABELS, DEFAULT_WINNER_LABELS, DataTableComponent, DateInputComponent, DateRangeInputComponent, DeviceService, DisplayComponent, DividerComponent, DownloadService, EmailInputComponent, ExpandableTextComponent, FabComponent, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FooterComponent, FooterLinksComponent, FormComponent, FormFooterComponent, FunHeaderComponent, GlowCardComponent, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, I18nService, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, Icon, IconComponent, IconService, ImageComponent, InAppBrowserService, InfoComponent, InputType, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LanguageSelectorComponent, LayeredCardComponent, LayoutComponent, LinkComponent, LinkProcessorService, LinksAccordionComponent, LinksCakeComponent, LocalStorageService, LocaleService, MODAL_SIZES, MOTION, MenuComponent, MessagingService, ModalService, MultiSelectSearchComponent, NavigationService, NoContentComponent, NotesBoxComponent, NotificationsService, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OAuthCallbackComponent, OAuthService, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PLATFORM_CONFIGS, PageContentComponent, PageTemplateComponent, PageWrapperComponent, PaginationComponent, ParticipantCardComponent, PasswordInputComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PresetService, PriceTagComponent, PrimarySolidBlockButton, PrimarySolidBlockHrefButton, PrimarySolidBlockIconButton, PrimarySolidBlockIconHrefButton, PrimarySolidDefaultRoundButton, PrimarySolidDefaultRoundHrefButton, PrimarySolidDefaultRoundIconButton, PrimarySolidDefaultRoundIconHrefButton, PrimarySolidFullButton, PrimarySolidFullHrefButton, PrimarySolidFullIconButton, PrimarySolidFullIconHrefButton, PrimarySolidLargeRoundButton, PrimarySolidLargeRoundHrefButton, PrimarySolidLargeRoundIconButton, PrimarySolidLargeRoundIconHrefButton, PrimarySolidSmallRoundButton, PrimarySolidSmallRoundHrefButton, PrimarySolidSmallRoundIconButton, PrimarySolidSmallRoundIconHrefButton, ProcessLinksPipe, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QueryBuilder, QuoteBoxComponent, RadioInputComponent, RaffleStatusCardComponent, RangeInputComponent, RatingComponent, RecapCardComponent, RightsFooterComponent, SKELETON_PRESETS, 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, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, TabbedContentComponent, TabsComponent, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TicketGridComponent, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TranslatePipe, TypedCollection, VALTECH_ADS_CONFIG, VALTECH_AUTH_CONFIG, VALTECH_DEFAULT_CONTENT, VALTECH_FIREBASE_CONFIG, WinnerDisplayComponent, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, buildPath, collections, createFirebaseConfig, createGlowCardProps, createNumberFromToField, createTitleProps, extractPathParams, getCollectionPath, getDocumentId, goToTop, guestGuard, hasEmulators, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isValidPath, joinPath, maxLength, permissionGuard, permissionGuardFromRoute, provideValtechAds, provideValtechAuth, provideValtechAuthInterceptor, provideValtechFirebase, provideValtechI18n, provideValtechPresets, query, replaceSpecialChars, resolveColor, resolveInputDefaultValue, roleGuard, storagePaths, superAdminGuard };
32104
+ export { AD_SIZE_MAP, ARTICLE_SPACING, AccordionComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, ArticleBuilder, ArticleComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, BannerComponent, BaseDefault, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, CheckInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContentLoaderComponent, CountdownComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_CANCEL_BUTTON, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_EMPTY_STATE, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_LEGEND_LABELS, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAYMENT_STATUS_COLORS, DEFAULT_PAYMENT_STATUS_LABELS, DEFAULT_PLATFORMS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DEFAULT_STATUS_COLORS, DEFAULT_STATUS_LABELS, DEFAULT_WINNER_LABELS, DataTableComponent, DateInputComponent, DateRangeInputComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DownloadService, EmailInputComponent, ExpandableTextComponent, FabComponent, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FooterComponent, FooterLinksComponent, FormComponent, FormFooterComponent, FormSkeletonComponent, FunHeaderComponent, GlowCardComponent, GridSkeletonComponent, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, I18nService, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, Icon, IconComponent, IconService, ImageComponent, InAppBrowserService, InfiniteListComponent, InfoComponent, InputType, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LanguageSelectorComponent, LayeredCardComponent, LayoutComponent, LinkComponent, LinkProcessorService, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadingDirective, LocalStorageService, LocaleService, MODAL_SIZES, MOTION, MenuComponent, MessagingService, ModalService, MultiSelectSearchComponent, NavigationService, NoContentComponent, NotesBoxComponent, NotificationsService, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OAuthCallbackComponent, OAuthService, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PLATFORM_CONFIGS, PageContentComponent, PageTemplateComponent, PageWrapperComponent, PaginationComponent, PaginationService, ParticipantCardComponent, PasswordInputComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, 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, RaffleStatusCardComponent, RangeInputComponent, RatingComponent, RecapCardComponent, RefresherComponent, RightsFooterComponent, SKELETON_PRESETS, 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, SkeletonService, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, TabbedContentComponent, TableSkeletonComponent, TabsComponent, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TicketGridComponent, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TranslatePipe, TypedCollection, VALTECH_ADS_CONFIG, VALTECH_AUTH_CONFIG, VALTECH_DEFAULT_CONTENT, VALTECH_FIREBASE_CONFIG, WinnerDisplayComponent, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, buildPath, collections, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createTitleProps, extractPathParams, getCollectionPath, getDocumentId, goToTop, guestGuard, hasEmulators, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isValidPath, joinPath, maxLength, permissionGuard, permissionGuardFromRoute, provideValtechAds, provideValtechAuth, provideValtechAuthInterceptor, provideValtechFirebase, provideValtechI18n, provideValtechPresets, provideValtechSkeleton, query, replaceSpecialChars, resolveColor, resolveInputDefaultValue, roleGuard, storagePaths, superAdminGuard };
29588
32105
  //# sourceMappingURL=valtech-components.mjs.map