valtech-components 2.0.1000 → 2.0.1002
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm2022/lib/components/organisms/permissions-view/permissions-view.component.mjs +18 -10
- package/esm2022/lib/components/organisms/permissions-view/permissions-view.i18n.mjs +3 -1
- package/esm2022/lib/components/organisms/settings-hub/settings.routes.mjs +8 -26
- package/esm2022/lib/services/org/permission-catalog.service.mjs +1 -1
- package/esm2022/lib/version.mjs +2 -2
- package/fesm2022/valtech-components.mjs +1983 -1989
- package/fesm2022/valtech-components.mjs.map +1 -1
- package/lib/components/organisms/settings-hub/settings.routes.d.ts +6 -31
- package/lib/services/org/permission-catalog.service.d.ts +1 -1
- package/lib/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { inject, InjectionToken, makeEnvironmentProviders, APP_INITIALIZER, ErrorHandler, Injectable, DestroyRef, Inject, signal, computed, PLATFORM_ID, isDevMode, Optional, runInInjectionContext, effect, Pipe, TemplateRef, ViewContainerRef, input, Directive, EventEmitter, Component, Input, Output, ChangeDetectionStrategy, HostBinding, HostListener, NgZone, ViewChild, ChangeDetectorRef, output, ContentChild, ElementRef, viewChild, untracked, Injector, isSignal, ViewEncapsulation } from '@angular/core';
|
|
3
|
-
import { BehaviorSubject, throwError, Subject, map, distinctUntilChanged, filter as filter$1, take as take$1, firstValueFrom, of, from, EMPTY, Observable, interval, debounceTime, switchMap as switchMap$1, catchError as catchError$1, takeUntil, isObservable,
|
|
3
|
+
import { BehaviorSubject, throwError, Subject, map, distinctUntilChanged, filter as filter$1, take as take$1, firstValueFrom, of, from, EMPTY, Observable, interval, debounceTime, switchMap as switchMap$1, catchError as catchError$1, takeUntil, isObservable, race, timer, forkJoin, shareReplay } from 'rxjs';
|
|
4
4
|
import { catchError, switchMap, finalize, filter, take, map as map$1, tap, distinctUntilChanged as distinctUntilChanged$1, retry, timeout, debounceTime as debounceTime$1, takeUntil as takeUntil$1 } from 'rxjs/operators';
|
|
5
5
|
import * as i1$3 from '@angular/common/http';
|
|
6
6
|
import { provideHttpClient, withInterceptors, HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
@@ -25,7 +25,7 @@ import { getApps, getApp, initializeApp as initializeApp$1 } from 'firebase/app'
|
|
|
25
25
|
import * as i2$1 from '@ionic/angular/standalone';
|
|
26
26
|
import { AlertController, 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, IonInput, IonSelect, IonSelectOption, IonPopover, IonList, IonItem, IonRadioGroup, IonRadio, IonRange, IonSearchbar, IonSegment, IonSegmentButton, IonToggle, IonRippleEffect, IonAccordion, IonAccordionGroup, IonTabBar, IonTabButton, IonBadge, IonBreadcrumb, IonBreadcrumbs, IonChip, IonNote, IonDatetimeButton, ToastController as ToastController$1, IonCol, IonRow, IonRefresher, IonRefresherContent, IonMenuButton, IonFooter, IonListHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonGrid, MenuController, IonMenu, IonMenuToggle, IonSplitPane } from '@ionic/angular/standalone';
|
|
27
27
|
import { addIcons } from 'ionicons';
|
|
28
|
-
import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, peopleOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, cloudOutline, gitBranchOutline, layersOutline, serverOutline, warningOutline, bulbOutline, lockOpenOutline, diamondOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, attachOutline, cameraOutline, closeCircleOutline, imageOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, laptopOutline, swapHorizontalOutline,
|
|
28
|
+
import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, peopleOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, cloudOutline, gitBranchOutline, layersOutline, serverOutline, warningOutline, bulbOutline, lockOpenOutline, diamondOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, attachOutline, cameraOutline, closeCircleOutline, imageOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, laptopOutline, swapHorizontalOutline, alarmOutline, chatboxEllipsesOutline, checkmarkDoneOutline, cogOutline, notificationsOffOutline, menuOutline } from 'ionicons/icons';
|
|
29
29
|
import { Browser } from '@capacitor/browser';
|
|
30
30
|
import * as i1$6 from '@angular/platform-browser';
|
|
31
31
|
import { DomSanitizer, Meta, Title } from '@angular/platform-browser';
|
|
@@ -54,7 +54,7 @@ import 'prismjs/components/prism-json';
|
|
|
54
54
|
* Current version of valtech-components.
|
|
55
55
|
* This is automatically updated during the publish process.
|
|
56
56
|
*/
|
|
57
|
-
const VERSION = '2.0.
|
|
57
|
+
const VERSION = '2.0.1002';
|
|
58
58
|
|
|
59
59
|
// Control de estado de refresco (singleton a nivel de módulo)
|
|
60
60
|
let isRefreshing = false;
|
|
@@ -50044,488 +50044,702 @@ function provideValtechOrganizationRoutes(opts) {
|
|
|
50044
50044
|
}
|
|
50045
50045
|
|
|
50046
50046
|
/**
|
|
50047
|
-
*
|
|
50048
|
-
*
|
|
50049
|
-
*
|
|
50050
|
-
* la vista usa.
|
|
50047
|
+
* Helper para montar el bloque completo de Settings en una app del factory: el
|
|
50048
|
+
* HUB de navegación (`val-settings-hub`) en el index + las vistas promovidas
|
|
50049
|
+
* (Perfil, Preferencias) + las rutas propias de la app (`extraRoutes`).
|
|
50051
50050
|
*
|
|
50052
|
-
*
|
|
50053
|
-
*
|
|
50051
|
+
* Pensado para apps nuevas (greenfield) que adoptan el set entero. showcase NO
|
|
50052
|
+
* lo usa hoy (preserva su `settings.routes.ts` por la semántica de sus muchas
|
|
50053
|
+
* secciones no promovidas — ver guía de promoción), pero el provider debe
|
|
50054
|
+
* existir y compilar para el resto de apps.
|
|
50055
|
+
*
|
|
50056
|
+
* @example
|
|
50057
|
+
* ```ts
|
|
50058
|
+
* // app.routes.ts
|
|
50059
|
+
* import { provideValtechSettingsRoutes } from 'valtech-components';
|
|
50060
|
+
*
|
|
50061
|
+
* export const routes: Routes = [
|
|
50062
|
+
* {
|
|
50063
|
+
* path: 'app',
|
|
50064
|
+
* children: [
|
|
50065
|
+
* ...provideValtechSettingsRoutes({
|
|
50066
|
+
* hubConfig: { onLogout: () => auth.logoutWithConfirmation() },
|
|
50067
|
+
* extraRoutes: [
|
|
50068
|
+
* { path: 'security', loadComponent: () => ... },
|
|
50069
|
+
* ],
|
|
50070
|
+
* }),
|
|
50071
|
+
* ],
|
|
50072
|
+
* },
|
|
50073
|
+
* ];
|
|
50074
|
+
* ```
|
|
50054
50075
|
*/
|
|
50055
|
-
|
|
50076
|
+
function provideValtechSettingsRoutes(opts) {
|
|
50077
|
+
const basePath = opts?.basePath ?? 'settings';
|
|
50078
|
+
const includeProfile = opts?.includeProfile ?? true;
|
|
50079
|
+
const includePreferences = opts?.includePreferences ?? true;
|
|
50080
|
+
const includeSecurity = opts?.includeSecurity ?? true;
|
|
50081
|
+
const includeAccount = opts?.includeAccount ?? true;
|
|
50082
|
+
const includeOrganization = opts?.includeOrganization ?? true;
|
|
50083
|
+
return [
|
|
50084
|
+
{
|
|
50085
|
+
path: basePath,
|
|
50086
|
+
children: [
|
|
50087
|
+
{
|
|
50088
|
+
path: '',
|
|
50089
|
+
component: SettingsHubComponent,
|
|
50090
|
+
data: { settingsHubConfig: opts?.hubConfig },
|
|
50091
|
+
},
|
|
50092
|
+
...(includeProfile ? provideValtechProfileRoutes({ config: opts?.profileConfig }) : []),
|
|
50093
|
+
...(includePreferences
|
|
50094
|
+
? provideValtechPreferencesRoutes({
|
|
50095
|
+
path: opts?.preferencesPath ?? 'preferences',
|
|
50096
|
+
config: opts?.preferencesConfig,
|
|
50097
|
+
})
|
|
50098
|
+
: []),
|
|
50099
|
+
...(includeSecurity ? provideValtechSecurityRoutes({ config: opts?.securityConfig }) : []),
|
|
50100
|
+
...(includeAccount ? provideValtechAccountRoutes({ config: opts?.accountConfig }) : []),
|
|
50101
|
+
...(includeOrganization
|
|
50102
|
+
? provideValtechOrganizationRoutes({ config: opts?.organizationConfig })
|
|
50103
|
+
: []),
|
|
50104
|
+
...(opts?.extraRoutes ?? []),
|
|
50105
|
+
],
|
|
50106
|
+
},
|
|
50107
|
+
];
|
|
50108
|
+
}
|
|
50109
|
+
|
|
50110
|
+
/**
|
|
50111
|
+
* Refreshable Stream
|
|
50112
|
+
*
|
|
50113
|
+
* Helper estándar para vistas que consumen datos de Firestore. Soporta DOS
|
|
50114
|
+
* modos — el caller decide cuál vía la `factory`:
|
|
50115
|
+
*
|
|
50116
|
+
* - **one-shot** (recomendado por defecto): `() => from(svc.getAllOnce())`.
|
|
50117
|
+
* Carga una vez y completa. Más barato — sin listener vivo.
|
|
50118
|
+
* - **real-time**: `() => svc.getAll()`. Listener Firestore que auto-actualiza.
|
|
50119
|
+
* Solo donde importa (inbox de notificaciones, badges).
|
|
50120
|
+
*
|
|
50121
|
+
* En ambos modos la primera suscripción se gatea por `firebaseAuthReady`
|
|
50122
|
+
* (sesión de Firebase Auth lista) — así el listener/lectura nunca se adjunta
|
|
50123
|
+
* antes de que `request.auth` esté disponible en las reglas de Firestore, lo
|
|
50124
|
+
* que cierra la ventana de `permission-denied` en cold start de PWA.
|
|
50125
|
+
*
|
|
50126
|
+
* El `retry` con backoff corto es solo red de seguridad para reconexiones
|
|
50127
|
+
* transitorias (no para cubrir cold start — eso lo cubre el gate de auth).
|
|
50128
|
+
*/
|
|
50129
|
+
/**
|
|
50130
|
+
* Crea un stream refrescable a partir de una factory de Observable.
|
|
50131
|
+
*
|
|
50132
|
+
* DEBE llamarse en un injection context (field initializer o constructor) —
|
|
50133
|
+
* usa `toSignal`/`toObservable`/`inject` internamente.
|
|
50134
|
+
*
|
|
50135
|
+
* @param factory - Función que produce el Observable a consumir. El caller
|
|
50136
|
+
* decide el modo: `() => from(svc.getAllOnce())` (one-shot) o
|
|
50137
|
+
* `() => svc.getAll()` (real-time).
|
|
50138
|
+
* @param options - Configuración opcional (fallback, retry, gate).
|
|
50139
|
+
*
|
|
50140
|
+
* @example
|
|
50141
|
+
* ```typescript
|
|
50142
|
+
* // one-shot (recomendado por defecto)
|
|
50143
|
+
* private readonly stream = createRefreshableStream(
|
|
50144
|
+
* () => from(this.svc.getAllOnce()),
|
|
50145
|
+
* );
|
|
50146
|
+
*
|
|
50147
|
+
* // real-time (inbox)
|
|
50148
|
+
* private readonly stream = createRefreshableStream(
|
|
50149
|
+
* () => this.notifs.getAll(50),
|
|
50150
|
+
* );
|
|
50151
|
+
*
|
|
50152
|
+
* readonly items = this.stream.data;
|
|
50153
|
+
* readonly loading = this.stream.loading;
|
|
50154
|
+
*
|
|
50155
|
+
* ionViewWillEnter() {
|
|
50156
|
+
* this.pageRefresh.register(() => this.stream.reload());
|
|
50157
|
+
* }
|
|
50158
|
+
* ```
|
|
50159
|
+
*/
|
|
50160
|
+
function createRefreshableStream(factory, options) {
|
|
50161
|
+
const injector = options?.injector ?? inject(Injector);
|
|
50162
|
+
const fallback = (options?.fallback ?? null);
|
|
50163
|
+
const retryCount = options?.retryCount ?? 4;
|
|
50164
|
+
const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
|
|
50165
|
+
const gateOnFirebaseAuth = options?.gateOnFirebaseAuth ?? true;
|
|
50166
|
+
const authGateTimeoutMs = options?.authGateTimeoutMs ?? 20000;
|
|
50167
|
+
const firstEmitTimeoutMs = options?.firstEmitTimeoutMs ?? 20000;
|
|
50168
|
+
// FirebaseService es opcional — sin él (apps sin Firebase) no se gatea.
|
|
50169
|
+
const firebase = gateOnFirebaseAuth ? injector.get(FirebaseService, null) : null;
|
|
50170
|
+
const _reload = signal(0);
|
|
50171
|
+
const _loading = signal(true);
|
|
50172
|
+
const _error = signal(false);
|
|
50173
|
+
// Gate de Firebase Auth: el stream no abre la primera suscripción hasta que
|
|
50174
|
+
// la sesión esté confirmada. `race` con un `timer` de seguridad — si la
|
|
50175
|
+
// sesión NUNCA se establece (token inválido, signInWithFirebase falló), el
|
|
50176
|
+
// gate igual se libera tras `authGateTimeoutMs` y la factory procede: si no
|
|
50177
|
+
// hay permiso terminará en estado `error`, jamás en skeleton infinito.
|
|
50178
|
+
// Tag los dos inputs del `race` para distinguir en logs cuál ganó: la sesión
|
|
50179
|
+
// de Firebase Auth (`auth-ready`) o el timeout de seguridad (`timeout`).
|
|
50180
|
+
const authGate$ = firebase
|
|
50181
|
+
? race(firebase.firebaseAuthReady$.pipe(map$1(() => 'auth-ready')), timer(authGateTimeoutMs).pipe(map$1(() => 'timeout'))).pipe(take(1), tap(winner => {
|
|
50182
|
+
if (winner === 'timeout') {
|
|
50183
|
+
console.warn(`[RefreshStream] auth gate resolved by TIMEOUT after ${authGateTimeoutMs}ms ` +
|
|
50184
|
+
'(Firebase Auth session never confirmed)');
|
|
50185
|
+
}
|
|
50186
|
+
else {
|
|
50187
|
+
console.log('[RefreshStream] auth gate resolved by firebaseAuthReady$ (session ready)');
|
|
50188
|
+
}
|
|
50189
|
+
}))
|
|
50190
|
+
: of(true);
|
|
50191
|
+
const data = toSignal(toObservable(_reload, { injector }).pipe(tap(() => {
|
|
50192
|
+
_loading.set(true);
|
|
50193
|
+
_error.set(false);
|
|
50194
|
+
}),
|
|
50195
|
+
// Esperar a Firebase Auth listo antes de cada (re)suscripción. Como
|
|
50196
|
+
// `firebaseAuthReady$` completa tras la primera emisión, las reloads
|
|
50197
|
+
// posteriores resuelven el gate de inmediato (auth ya está listo).
|
|
50198
|
+
switchMap(() => authGate$.pipe(switchMap(() => factory().pipe(tap(() => {
|
|
50199
|
+
_loading.set(false);
|
|
50200
|
+
_error.set(false);
|
|
50201
|
+
console.log('[RefreshStream] factory emitted first value — loading=false');
|
|
50202
|
+
}),
|
|
50203
|
+
// Backoff corto exponencial acotado — solo red de seguridad para
|
|
50204
|
+
// reconexiones transitorias, NO para cubrir cold start.
|
|
50205
|
+
retry({
|
|
50206
|
+
count: retryCount,
|
|
50207
|
+
delay: (_err, attempt) => timer(Math.min(retryBaseDelayMs * 2 ** (attempt - 1), 4000)),
|
|
50208
|
+
}),
|
|
50209
|
+
// Techo absoluto para la PRIMERA emisión. Si la factory no emite
|
|
50210
|
+
// ni valor ni error (servicio nunca inicializado → `EMPTY` →
|
|
50211
|
+
// silencio), `timeout` dispara un error → `catchError` → estado
|
|
50212
|
+
// `error`. Nunca skeleton perpetuo. Va DESPUÉS de `retry` para
|
|
50213
|
+
// que cuente el tiempo total, no por intento.
|
|
50214
|
+
timeout({ first: firstEmitTimeoutMs }), catchError(err => {
|
|
50215
|
+
_loading.set(false);
|
|
50216
|
+
_error.set(true);
|
|
50217
|
+
console.error(`[RefreshStream] factory failed/timed out → error state: ${err instanceof Error ? err.message : String(err)}`);
|
|
50218
|
+
return of(fallback);
|
|
50219
|
+
})))))), { initialValue: null, injector });
|
|
50220
|
+
return {
|
|
50221
|
+
data,
|
|
50222
|
+
loading: _loading.asReadonly(),
|
|
50223
|
+
error: _error.asReadonly(),
|
|
50224
|
+
reload: () => _reload.update(n => n + 1),
|
|
50225
|
+
};
|
|
50226
|
+
}
|
|
50227
|
+
|
|
50228
|
+
/**
|
|
50229
|
+
* Defaults i18n (es/en) embebidos en `val-notifications-view`. Auto-registrados
|
|
50230
|
+
* en el constructor del componente si el consumer no proveyó el namespace
|
|
50231
|
+
* (`Notifications` por default). SOLO incluye las keys que la vista usa.
|
|
50232
|
+
*
|
|
50233
|
+
* Portado del override `Notifications` (showcase) bajo el proceso de ADR-021.
|
|
50234
|
+
*/
|
|
50235
|
+
const NOTIFICATIONS_VIEW_I18N = {
|
|
50056
50236
|
es: {
|
|
50057
50237
|
pageTitle: 'Notificaciones',
|
|
50058
|
-
pageDescription: '
|
|
50059
|
-
|
|
50060
|
-
|
|
50061
|
-
|
|
50062
|
-
|
|
50063
|
-
|
|
50064
|
-
|
|
50065
|
-
|
|
50238
|
+
pageDescription: 'Tu bandeja de mensajes y actualizaciones',
|
|
50239
|
+
groupToday: 'Hoy',
|
|
50240
|
+
groupThisMonth: 'Este mes',
|
|
50241
|
+
groupEarlier: 'Anteriores',
|
|
50242
|
+
empty: 'Aún no tienes notificaciones',
|
|
50243
|
+
emptyHint: 'Activa las notificaciones para recibir actualizaciones, alertas y novedades importantes.',
|
|
50244
|
+
emptyCta: 'Ir a configuración',
|
|
50245
|
+
loadErrorTitle: 'No pudimos cargar tus notificaciones',
|
|
50246
|
+
loadErrorHint: 'Revisa tu conexión e inténtalo de nuevo. Tus notificaciones siguen guardadas.',
|
|
50247
|
+
offlineTitle: 'Sin conexión',
|
|
50248
|
+
offlineHint: 'No hay acceso a la red. Reconéctate e intenta de nuevo.',
|
|
50066
50249
|
retryCta: 'Reintentar',
|
|
50067
|
-
|
|
50068
|
-
|
|
50069
|
-
|
|
50070
|
-
|
|
50071
|
-
|
|
50072
|
-
|
|
50073
|
-
|
|
50074
|
-
|
|
50075
|
-
|
|
50076
|
-
|
|
50077
|
-
|
|
50078
|
-
|
|
50079
|
-
|
|
50080
|
-
|
|
50081
|
-
|
|
50082
|
-
|
|
50083
|
-
|
|
50084
|
-
|
|
50085
|
-
enableSuccess: 'Notificaciones habilitadas',
|
|
50086
|
-
disableSuccess: 'Notificaciones deshabilitadas',
|
|
50250
|
+
pushOffTitle: 'No recibirás avisos',
|
|
50251
|
+
pushOffHint: 'Puedes leer tus mensajes aquí, pero las notificaciones push están desactivadas. Actívalas para recibir avisos en tu dispositivo.',
|
|
50252
|
+
pushOffCta: 'Activar notificaciones',
|
|
50253
|
+
loading: 'Cargando…',
|
|
50254
|
+
loadMore: 'Cargar más',
|
|
50255
|
+
seeMore: 'Ver más',
|
|
50256
|
+
seeLess: 'Ver menos',
|
|
50257
|
+
open: 'Ver',
|
|
50258
|
+
markAll: 'Marcar todas como leídas',
|
|
50259
|
+
unread: 'No leída',
|
|
50260
|
+
crossApp: 'Desde {app}',
|
|
50261
|
+
timeNow: 'ahora',
|
|
50262
|
+
timeMinutes: 'hace {n} min',
|
|
50263
|
+
timeHours: 'hace {n} h',
|
|
50264
|
+
timeDays: 'hace {n} d',
|
|
50265
|
+
markedAllSuccess: 'Notificaciones marcadas como leídas.',
|
|
50266
|
+
markedAllError: 'No se pudo marcar todas como leídas.',
|
|
50267
|
+
openError: 'No se pudo abrir la notificación.',
|
|
50087
50268
|
},
|
|
50088
50269
|
en: {
|
|
50089
50270
|
pageTitle: 'Notifications',
|
|
50090
|
-
pageDescription: '
|
|
50091
|
-
|
|
50092
|
-
|
|
50093
|
-
|
|
50094
|
-
|
|
50095
|
-
|
|
50096
|
-
|
|
50271
|
+
pageDescription: 'Your inbox of messages and updates',
|
|
50272
|
+
groupToday: 'Today',
|
|
50273
|
+
groupThisMonth: 'This month',
|
|
50274
|
+
groupEarlier: 'Earlier',
|
|
50275
|
+
empty: 'No notifications yet',
|
|
50276
|
+
emptyHint: 'Enable notifications to receive updates, alerts and important news.',
|
|
50277
|
+
emptyCta: 'Go to settings',
|
|
50278
|
+
loadErrorTitle: "We couldn't load your notifications",
|
|
50279
|
+
loadErrorHint: 'Check your connection and try again. Your notifications are still saved.',
|
|
50280
|
+
offlineTitle: 'No connection',
|
|
50281
|
+
offlineHint: 'No network access. Reconnect and try again.',
|
|
50097
50282
|
retryCta: 'Retry',
|
|
50098
|
-
|
|
50099
|
-
|
|
50100
|
-
|
|
50101
|
-
|
|
50102
|
-
|
|
50103
|
-
|
|
50104
|
-
|
|
50105
|
-
|
|
50106
|
-
|
|
50107
|
-
|
|
50108
|
-
|
|
50109
|
-
|
|
50110
|
-
|
|
50111
|
-
|
|
50112
|
-
|
|
50113
|
-
|
|
50283
|
+
pushOffTitle: "You won't get alerts",
|
|
50284
|
+
pushOffHint: 'You can read your messages here, but push notifications are off. Turn them on to get alerts on your device.',
|
|
50285
|
+
pushOffCta: 'Enable notifications',
|
|
50286
|
+
loading: 'Loading…',
|
|
50287
|
+
loadMore: 'Load more',
|
|
50288
|
+
seeMore: 'See more',
|
|
50289
|
+
seeLess: 'See less',
|
|
50290
|
+
open: 'Open',
|
|
50291
|
+
markAll: 'Mark all as read',
|
|
50292
|
+
unread: 'Unread',
|
|
50293
|
+
crossApp: 'From {app}',
|
|
50294
|
+
timeNow: 'now',
|
|
50295
|
+
timeMinutes: '{n} min ago',
|
|
50296
|
+
timeHours: '{n} h ago',
|
|
50297
|
+
timeDays: '{n} d ago',
|
|
50298
|
+
markedAllSuccess: 'Notifications marked as read.',
|
|
50299
|
+
markedAllError: 'Could not mark all as read.',
|
|
50300
|
+
openError: 'Could not open notification.',
|
|
50114
50301
|
},
|
|
50115
50302
|
};
|
|
50116
50303
|
|
|
50117
50304
|
addIcons({
|
|
50118
|
-
|
|
50119
|
-
|
|
50120
|
-
|
|
50121
|
-
|
|
50305
|
+
alarmOutline,
|
|
50306
|
+
chatboxEllipsesOutline,
|
|
50307
|
+
chatbubbleOutline,
|
|
50308
|
+
checkmarkCircleOutline,
|
|
50309
|
+
checkmarkDoneOutline,
|
|
50310
|
+
cloudOfflineOutline,
|
|
50311
|
+
cogOutline,
|
|
50312
|
+
documentTextOutline,
|
|
50122
50313
|
notificationsOffOutline,
|
|
50123
50314
|
notificationsOutline,
|
|
50124
|
-
|
|
50315
|
+
openOutline,
|
|
50316
|
+
personOutline,
|
|
50125
50317
|
});
|
|
50126
|
-
const DEFAULT_NAMESPACE$2 = '
|
|
50318
|
+
const DEFAULT_NAMESPACE$2 = 'Notifications';
|
|
50319
|
+
const DEFAULT_SETTINGS_PATH = '/app/settings/notifications';
|
|
50320
|
+
const DEFAULT_PAGE_SIZE = 10;
|
|
50321
|
+
const DEFAULT_MAX_LOAD = 50;
|
|
50127
50322
|
/**
|
|
50128
|
-
* `val-
|
|
50129
|
-
* (
|
|
50130
|
-
* proceso de ADR-021.
|
|
50323
|
+
* `val-notifications-view` — inbox de notificaciones full-feature autocontenido
|
|
50324
|
+
* (organism). Promovido desde `showcase` bajo el proceso de ADR-021.
|
|
50131
50325
|
*
|
|
50132
|
-
*
|
|
50133
|
-
*
|
|
50326
|
+
* Separado de `/app/settings/notifications` (que controla permiso FCM + master
|
|
50327
|
+
* toggle). Aquí el user lee/abre/marca-leídas sus mensajes.
|
|
50134
50328
|
*
|
|
50135
|
-
*
|
|
50136
|
-
*
|
|
50137
|
-
* `
|
|
50138
|
-
*
|
|
50139
|
-
*
|
|
50329
|
+
* - **Stream real-time** vía `NotificationsService.getAll(maxLoad)` envuelto en
|
|
50330
|
+
* `createRefreshableStream` (gateado por `firebaseAuthReady`, retry corto,
|
|
50331
|
+
* `firstEmitTimeoutMs: 15000`). Estados: loading (skeleton), error
|
|
50332
|
+
* (`val-empty-state` + reintentar), empty (`val-empty-state` + CTA), data
|
|
50333
|
+
* (lista agrupada Hoy / Este mes / Anteriores).
|
|
50334
|
+
* - **Abrir** una notif → `NotificationActionService.open()` (markRead +
|
|
50335
|
+
* switch-org + navegar/handoff cross-app). Hook `onNotificationOpen` opcional.
|
|
50336
|
+
* - **Marcar todas como leídas** (gateado por `enableMarkAll`).
|
|
50337
|
+
* - **CTA push-off** cuando el permiso del navegador no está concedido (gateado
|
|
50338
|
+
* por `showPushOffCta`); navega a `settingsNotificationsPath`.
|
|
50339
|
+
* - **Paginación soft** — slice local (`pageSize`, +`pageSize` por click) sobre el
|
|
50340
|
+
* cache del stream; el tope de fetch es `maxLoad`.
|
|
50341
|
+
* - **i18n auto-registrado** es/en bajo `i18nNamespace` (default `Notifications`).
|
|
50140
50342
|
*
|
|
50141
|
-
* NO renderiza ion-content — vive dentro de val-page-wrapper. `
|
|
50142
|
-
*
|
|
50343
|
+
* NO renderiza ion-content — vive dentro de val-page-wrapper. `Router` y
|
|
50344
|
+
* `ActivatedRoute` se inyectan `{ optional: true }` → funciona sin router
|
|
50345
|
+
* (embebida / Storybook). El forward-compat icon resolver lee `notif.payload.icon`
|
|
50346
|
+
* si existe (`{ type, value, color? }`), cae a un mapa por `notif.type`, luego a
|
|
50347
|
+
* `notifications-outline`.
|
|
50143
50348
|
*/
|
|
50144
|
-
class
|
|
50349
|
+
class NotificationsViewComponent {
|
|
50145
50350
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
50146
50351
|
get ns() {
|
|
50147
50352
|
return this.resolvedConfig().i18nNamespace;
|
|
50148
50353
|
}
|
|
50149
|
-
|
|
50150
|
-
|
|
50151
|
-
|
|
50354
|
+
/** Paso de paginación soft (revela +N por "cargar más"). */
|
|
50355
|
+
get pageStep() {
|
|
50356
|
+
return this.resolvedConfig().pageSize;
|
|
50357
|
+
}
|
|
50152
50358
|
constructor() {
|
|
50153
50359
|
this.nav = inject(NavigationService);
|
|
50154
50360
|
this.i18n = inject(I18nService);
|
|
50361
|
+
this.notifs = inject(NotificationsService);
|
|
50362
|
+
this.action = inject(NotificationActionService);
|
|
50155
50363
|
this.toast = inject(ToastService);
|
|
50364
|
+
this.errors = inject(ValtechErrorService);
|
|
50156
50365
|
this.auth = inject(AuthService);
|
|
50157
|
-
this.messaging = inject(MessagingService);
|
|
50158
50366
|
this.pageRefresh = inject(PageRefreshService);
|
|
50159
|
-
this.prefs = inject(PreferencesService);
|
|
50160
50367
|
// `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
|
|
50161
|
-
// router (solo lo usa para leer el route data de config como fallback).
|
|
50368
|
+
// router (solo lo usa para leer el route data de config como fallback). La
|
|
50369
|
+
// navegación va por `NavigationService` (wrapper opcional sobre `Router`).
|
|
50162
50370
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
50163
50371
|
this.resolvedConfig = computed(() => {
|
|
50164
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
50165
|
-
{});
|
|
50372
|
+
const fromRoute = (this.route?.snapshot.data['notificationsConfig'] ?? {});
|
|
50166
50373
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
50167
50374
|
return {
|
|
50168
|
-
|
|
50375
|
+
showPushOffCta: merged.showPushOffCta ?? true,
|
|
50376
|
+
enableMarkAll: merged.enableMarkAll ?? true,
|
|
50377
|
+
settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
|
|
50169
50378
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$2,
|
|
50170
|
-
|
|
50171
|
-
|
|
50379
|
+
pageSize: merged.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
50380
|
+
maxLoad: merged.maxLoad ?? DEFAULT_MAX_LOAD,
|
|
50381
|
+
onNotificationOpen: merged.onNotificationOpen,
|
|
50172
50382
|
};
|
|
50173
50383
|
});
|
|
50174
|
-
|
|
50175
|
-
this.
|
|
50176
|
-
|
|
50177
|
-
this.
|
|
50178
|
-
/** True mientras se re-obtiene el token FCM en background (no afecta la UI). */
|
|
50179
|
-
this._repairingToken = false;
|
|
50180
|
-
/** Toggle del expand de detalles técnicos. */
|
|
50181
|
-
this.showDetails = signal(false);
|
|
50182
|
-
this.permission = signal('default');
|
|
50183
|
-
this.isSupported = signal(false);
|
|
50184
|
-
this.currentToken = signal(null);
|
|
50185
|
-
this.needsIOSInstall = signal(this.detectNeedsIOSInstall());
|
|
50186
|
-
/** FormControl del toggle (sync-eado con `isEnabled` via effect). */
|
|
50187
|
-
this.toggleControl = new FormControl(false, {
|
|
50188
|
-
nonNullable: true,
|
|
50189
|
-
});
|
|
50190
|
-
/** Handler del listener `focus` — referencia guardada para poder removerlo. */
|
|
50191
|
-
this.onWindowFocus = () => this.scheduleBrowserRefresh();
|
|
50192
|
-
/** Timer del debounce del refresh disparado por `focus`. */
|
|
50193
|
-
this.focusDebounceTimer = null;
|
|
50194
|
-
/** Ventana de debounce (ms) para el refresh disparado por `focus`. */
|
|
50195
|
-
this.FOCUS_DEBOUNCE_MS = 500;
|
|
50196
|
-
// ---------------------------------------------------------------------------
|
|
50197
|
-
// Estado derivado
|
|
50198
|
-
// ---------------------------------------------------------------------------
|
|
50199
|
-
this.pageTitle = computed(() => this.tt('pageTitle'));
|
|
50200
|
-
this.pageDescription = computed(() => this.tt('pageDescription'));
|
|
50384
|
+
this._expanded = signal(new Set());
|
|
50385
|
+
this._pageSize = signal(this.resolvedConfig().pageSize);
|
|
50386
|
+
this._bulkBusy = signal(false);
|
|
50387
|
+
this._loadingMore = signal(false);
|
|
50201
50388
|
/**
|
|
50202
|
-
*
|
|
50389
|
+
* Push activado = permiso del navegador en `granted`. Si NO está activado,
|
|
50390
|
+
* el user puede leer el inbox (Firestore) pero NO recibirá push en su
|
|
50391
|
+
* dispositivo → mostramos una card que lo invita a ir a configuración.
|
|
50392
|
+
* Se evalúa en cada montaje de la vista.
|
|
50393
|
+
*/
|
|
50394
|
+
this.pushEnabled = signal(this.auth.getNotificationPermissionState() === 'granted');
|
|
50395
|
+
/**
|
|
50396
|
+
* Stream firestore real-time vía el helper estándar `createRefreshableStream`
|
|
50397
|
+
* (modo real-time: factory devuelve un listener vivo `notifs.getAll(maxLoad)`).
|
|
50203
50398
|
*
|
|
50204
|
-
*
|
|
50205
|
-
*
|
|
50206
|
-
*
|
|
50207
|
-
*
|
|
50208
|
-
* background (ver `repairTokenIfNeeded`), no un cambio del toggle.
|
|
50399
|
+
* El helper gatea la primera suscripción por `firebaseAuthReady`, aplica un
|
|
50400
|
+
* `retry` corto con backoff y, tras agotar reintentos, emite el fallback (`[]`)
|
|
50401
|
+
* y marca `error` — estado de error en UI, no skeleton infinito. `reload()`
|
|
50402
|
+
* re-suscribe el stream completo (lo usa el pull-to-refresh).
|
|
50209
50403
|
*/
|
|
50210
|
-
this.
|
|
50211
|
-
|
|
50212
|
-
|
|
50213
|
-
|
|
50214
|
-
|
|
50215
|
-
|
|
50216
|
-
|
|
50217
|
-
|
|
50218
|
-
return 'unsupported';
|
|
50219
|
-
}
|
|
50220
|
-
const busy = this._busy();
|
|
50221
|
-
if (busy === 'enabling')
|
|
50222
|
-
return 'enabling';
|
|
50223
|
-
if (busy === 'disabling')
|
|
50224
|
-
return 'disabling';
|
|
50225
|
-
if (this.isEnabled())
|
|
50226
|
-
return 'enabled';
|
|
50227
|
-
if (this.permission() === 'denied')
|
|
50228
|
-
return 'blocked';
|
|
50229
|
-
if (this._lastFailed())
|
|
50230
|
-
return 'error';
|
|
50231
|
-
return 'idle';
|
|
50232
|
-
});
|
|
50233
|
-
this.busyLabel = computed(() => {
|
|
50234
|
-
const s = this.state();
|
|
50235
|
-
if (s === 'enabling')
|
|
50236
|
-
return this.tt('enabling');
|
|
50237
|
-
if (s === 'disabling')
|
|
50238
|
-
return this.tt('disabling');
|
|
50239
|
-
return null;
|
|
50404
|
+
this._stream = createRefreshableStream(() => this.notifs.getAll(this.resolvedConfig().maxLoad), {
|
|
50405
|
+
fallback: [],
|
|
50406
|
+
// `notifs.getAll()` YA gatea internamente por `firebaseAuthReady`. Si el
|
|
50407
|
+
// stream también gateara, serían DOS esperas de auth apiladas.
|
|
50408
|
+
gateOnFirebaseAuth: false,
|
|
50409
|
+
// Cold start de PWA en iOS puede pasar de 8s en redes lentas; con 15s
|
|
50410
|
+
// evitamos el falso estado de error.
|
|
50411
|
+
firstEmitTimeoutMs: 15000,
|
|
50240
50412
|
});
|
|
50241
|
-
|
|
50242
|
-
|
|
50243
|
-
|
|
50244
|
-
|
|
50245
|
-
|
|
50246
|
-
|
|
50247
|
-
|
|
50248
|
-
|
|
50249
|
-
|
|
50250
|
-
|
|
50413
|
+
/** True si el stream falló tras agotar reintentos → muestra estado de error. */
|
|
50414
|
+
this.streamError = this._stream.error;
|
|
50415
|
+
this.loading = computed(() => this._stream.data() === null);
|
|
50416
|
+
/**
|
|
50417
|
+
* Notifs reales mostradas. Filtramos docs sin título Y sin body — son ruido
|
|
50418
|
+
* (típicamente sentinel/system writes pre-schema con title/body opcionales).
|
|
50419
|
+
*/
|
|
50420
|
+
this.allNotifs = computed(() => (this._stream.data() ?? []).filter(n => !!(n.title && n.title.trim()) || !!(n.body && n.body.trim())));
|
|
50421
|
+
this.hasMore = computed(() => this.allNotifs().length > this._pageSize());
|
|
50422
|
+
this.showMarkAll = computed(() => this.resolvedConfig().enableMarkAll && !this.loading() && this.allNotifs().some(n => !n.isRead));
|
|
50423
|
+
/** Sub-conjunto paginado, agrupado por período. */
|
|
50424
|
+
this.groups = computed(() => {
|
|
50425
|
+
const items = this.allNotifs().slice(0, this._pageSize());
|
|
50426
|
+
const now = new Date();
|
|
50427
|
+
const buckets = {
|
|
50428
|
+
today: [],
|
|
50429
|
+
thisMonth: [],
|
|
50430
|
+
earlier: [],
|
|
50431
|
+
};
|
|
50432
|
+
for (const n of items) {
|
|
50433
|
+
buckets[this.bucketOf(n, now)].push(n);
|
|
50251
50434
|
}
|
|
50435
|
+
const out = [];
|
|
50436
|
+
if (buckets.today.length) {
|
|
50437
|
+
out.push({ key: 'today', label: this.tt('groupToday'), items: buckets.today });
|
|
50438
|
+
}
|
|
50439
|
+
if (buckets.thisMonth.length) {
|
|
50440
|
+
out.push({
|
|
50441
|
+
key: 'thisMonth',
|
|
50442
|
+
label: this.tt('groupThisMonth'),
|
|
50443
|
+
items: buckets.thisMonth,
|
|
50444
|
+
});
|
|
50445
|
+
}
|
|
50446
|
+
if (buckets.earlier.length) {
|
|
50447
|
+
out.push({ key: 'earlier', label: this.tt('groupEarlier'), items: buckets.earlier });
|
|
50448
|
+
}
|
|
50449
|
+
return out;
|
|
50252
50450
|
});
|
|
50253
|
-
this.
|
|
50254
|
-
|
|
50255
|
-
|
|
50256
|
-
|
|
50257
|
-
|
|
50258
|
-
|
|
50259
|
-
|
|
50451
|
+
this.pageTitle = computed(() => this.tt('pageTitle'));
|
|
50452
|
+
this.pageDescription = computed(() => this.tt('pageDescription'));
|
|
50453
|
+
// CTA "activa las notificaciones push" — visible cuando pushEnabled() es false.
|
|
50454
|
+
this.ctaTitle = computed(() => this.tt('pushOffTitle'));
|
|
50455
|
+
this.ctaHint = computed(() => this.tt('pushOffHint'));
|
|
50456
|
+
this.ctaButtonProps = computed(() => ({
|
|
50457
|
+
text: this.tt('pushOffCta'),
|
|
50458
|
+
color: 'dark',
|
|
50459
|
+
fill: 'outline',
|
|
50460
|
+
size: 'small',
|
|
50461
|
+
shape: 'round',
|
|
50462
|
+
type: 'button',
|
|
50463
|
+
}));
|
|
50464
|
+
/**
|
|
50465
|
+
* Estado de error de carga: convierte el error del stream en
|
|
50466
|
+
* `EmptyStateMetadata` via el helper canónico `createErrorStateProps`.
|
|
50467
|
+
* El helper decide variante (`offline` vs `error`) por `isNetwork`.
|
|
50468
|
+
* Null cuando el stream no ha fallado.
|
|
50469
|
+
*/
|
|
50470
|
+
this.errorState = computed(() => {
|
|
50471
|
+
this.i18n.lang();
|
|
50472
|
+
const err = this.streamError();
|
|
50473
|
+
if (!err)
|
|
50474
|
+
return null;
|
|
50475
|
+
return createErrorStateProps(err, {
|
|
50476
|
+
title: {
|
|
50477
|
+
offline: this.tt('offlineTitle'),
|
|
50478
|
+
error: this.tt('loadErrorTitle'),
|
|
50479
|
+
},
|
|
50480
|
+
description: {
|
|
50481
|
+
offline: this.tt('offlineHint'),
|
|
50482
|
+
error: interpretError(err).message || this.tt('loadErrorHint'),
|
|
50483
|
+
},
|
|
50484
|
+
retryLabel: this.tt('retryCta'),
|
|
50485
|
+
onRetry: () => this._stream.reload(),
|
|
50486
|
+
retrying: this.loading(),
|
|
50487
|
+
});
|
|
50260
50488
|
});
|
|
50261
|
-
|
|
50262
|
-
|
|
50263
|
-
|
|
50264
|
-
|
|
50265
|
-
|
|
50489
|
+
/**
|
|
50490
|
+
* Estado vacío (lista real = 0 notificaciones, sin error de carga).
|
|
50491
|
+
* Se construye manualmente con `variant: 'empty'` — sin helper de error.
|
|
50492
|
+
*/
|
|
50493
|
+
this.emptyStateProps = computed(() => {
|
|
50494
|
+
this.i18n.lang();
|
|
50495
|
+
return {
|
|
50496
|
+
variant: 'empty',
|
|
50497
|
+
icon: 'notifications-off-outline',
|
|
50498
|
+
title: this.tt('empty'),
|
|
50499
|
+
description: this.tt('emptyHint'),
|
|
50500
|
+
action: {
|
|
50501
|
+
label: this.tt('emptyCta'),
|
|
50502
|
+
handler: () => this.onGoToSettings(),
|
|
50503
|
+
},
|
|
50504
|
+
};
|
|
50266
50505
|
});
|
|
50267
|
-
this.
|
|
50268
|
-
|
|
50269
|
-
|
|
50270
|
-
|
|
50271
|
-
this.state() === 'unsupported' ||
|
|
50272
|
-
this.state() === 'needs-install' ||
|
|
50273
|
-
this.state() === 'blocked',
|
|
50274
|
-
}));
|
|
50275
|
-
this.retryButtonProps = computed(() => ({
|
|
50276
|
-
text: this.tt('retryCta'),
|
|
50506
|
+
this.seeMore = computed(() => this.tt('seeMore'));
|
|
50507
|
+
this.seeLess = computed(() => this.tt('seeLess'));
|
|
50508
|
+
this.markAllButtonProps = computed(() => ({
|
|
50509
|
+
text: this.tt('markAll'),
|
|
50277
50510
|
color: 'dark',
|
|
50278
50511
|
fill: 'outline',
|
|
50512
|
+
size: 'small',
|
|
50513
|
+
shape: 'round',
|
|
50514
|
+
type: 'button',
|
|
50515
|
+
icon: { name: 'checkmark-done-outline', slot: 'start' },
|
|
50516
|
+
state: this._bulkBusy() ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
50517
|
+
}));
|
|
50518
|
+
this.loadMoreButtonProps = computed(() => ({
|
|
50519
|
+
text: this.tt('loadMore'),
|
|
50520
|
+
color: 'dark',
|
|
50521
|
+
fill: 'clear',
|
|
50279
50522
|
size: 'default',
|
|
50280
50523
|
shape: 'round',
|
|
50281
50524
|
type: 'button',
|
|
50282
|
-
|
|
50283
|
-
state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
50525
|
+
state: this._loadingMore() ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
50284
50526
|
}));
|
|
50285
|
-
this.tokenPreview = computed(() => {
|
|
50286
|
-
const t = this.currentToken();
|
|
50287
|
-
if (!t)
|
|
50288
|
-
return null;
|
|
50289
|
-
return `${t.slice(0, 22)}…${t.slice(-6)}`;
|
|
50290
|
-
});
|
|
50291
50527
|
const ns = this.ns;
|
|
50292
50528
|
if (!this.i18n.hasNamespace(ns)) {
|
|
50293
|
-
this.i18n.registerContent(ns,
|
|
50529
|
+
this.i18n.registerContent(ns, NOTIFICATIONS_VIEW_I18N);
|
|
50294
50530
|
}
|
|
50295
50531
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
50296
|
-
void this.refreshBrowserState();
|
|
50297
50532
|
// Re-aplicar header en cambio de idioma.
|
|
50298
50533
|
effect(() => {
|
|
50299
50534
|
this.i18n.lang();
|
|
50300
50535
|
this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
|
|
50301
50536
|
}, { allowSignalWrites: true });
|
|
50302
|
-
// Sync FormControl ←→ estado efectivo. Cuando el estado cambia (cambio de
|
|
50303
|
-
// permission por el browser, o snapshot Firestore de `master`), el toggle se
|
|
50304
|
-
// re-alinea con la realidad.
|
|
50305
|
-
//
|
|
50306
|
-
// IMPORTANTE: se ignoran los cambios mientras hay una operación en curso
|
|
50307
|
-
// (`_busy() !== null`). Durante `onEnable`/`onDisable` las señales fuente se
|
|
50308
|
-
// actualizan en momentos distintos y producirían estados intermedios →
|
|
50309
|
-
// flicker visible. El toggle se re-alinea UNA sola vez en el `finally`.
|
|
50310
|
-
effect(() => {
|
|
50311
|
-
const eff = this.isEnabled();
|
|
50312
|
-
if (this._busy() !== null)
|
|
50313
|
-
return;
|
|
50314
|
-
if (this.toggleControl.value !== eff) {
|
|
50315
|
-
this.toggleControl.setValue(eff, { emitEvent: false });
|
|
50316
|
-
}
|
|
50317
|
-
}, { allowSignalWrites: true });
|
|
50318
|
-
// Interceptar click del user: el toggle ya cambió visualmente, ejecutar
|
|
50319
|
-
// la acción correspondiente.
|
|
50320
|
-
this.toggleControl.valueChanges.subscribe(v => {
|
|
50321
|
-
if (this._busy() !== null)
|
|
50322
|
-
return;
|
|
50323
|
-
if (v && !this.isEnabled()) {
|
|
50324
|
-
void this.onEnable();
|
|
50325
|
-
}
|
|
50326
|
-
else if (!v && this.isEnabled()) {
|
|
50327
|
-
void this.onDisable();
|
|
50328
|
-
}
|
|
50329
|
-
});
|
|
50330
|
-
// Detectar regreso a la pestaña — el user pudo haber cambiado permisos
|
|
50331
|
-
// desde la config del browser. El refresh va con debounce para no
|
|
50332
|
-
// dispararse en ráfagas (en PWA mobile `focus` salta con cada cambio de
|
|
50333
|
-
// app / aparición del teclado). El listener se remueve en ngOnDestroy.
|
|
50334
|
-
if (typeof window !== 'undefined') {
|
|
50335
|
-
window.addEventListener('focus', this.onWindowFocus);
|
|
50336
|
-
}
|
|
50337
50537
|
}
|
|
50338
50538
|
/**
|
|
50339
|
-
* Registra el pull-to-refresh estándar.
|
|
50340
|
-
*
|
|
50341
|
-
*
|
|
50342
|
-
*
|
|
50343
|
-
* Se usa `ngOnInit`/`ngOnDestroy` (NO `ionViewWillEnter`) porque
|
|
50344
|
-
* `val-page-wrapper` usa `<router-outlet>` plano — los hooks `ionView*` solo
|
|
50345
|
-
* disparan dentro de `<ion-router-outlet>`.
|
|
50539
|
+
* Registra el pull-to-refresh estándar. Usamos `ngOnInit`/`ngOnDestroy`
|
|
50540
|
+
* (NO `ionViewWillEnter`) porque `val-page-wrapper` usa `<router-outlet>`
|
|
50541
|
+
* plano — los hooks `ionView*` solo disparan dentro de `<ion-router-outlet>`.
|
|
50346
50542
|
*/
|
|
50347
50543
|
ngOnInit() {
|
|
50348
|
-
this.pageRefresh.register(() => this.
|
|
50544
|
+
this.pageRefresh.register(() => this.reload());
|
|
50349
50545
|
}
|
|
50546
|
+
/** Quita el handler al destruir la vista — el refresher desaparece. */
|
|
50350
50547
|
ngOnDestroy() {
|
|
50351
50548
|
this.pageRefresh.unregister();
|
|
50352
|
-
if (typeof window !== 'undefined') {
|
|
50353
|
-
window.removeEventListener('focus', this.onWindowFocus);
|
|
50354
|
-
}
|
|
50355
|
-
if (this.focusDebounceTimer !== null) {
|
|
50356
|
-
clearTimeout(this.focusDebounceTimer);
|
|
50357
|
-
this.focusDebounceTimer = null;
|
|
50358
|
-
}
|
|
50359
50549
|
}
|
|
50360
|
-
/**
|
|
50361
|
-
|
|
50362
|
-
|
|
50363
|
-
|
|
50364
|
-
|
|
50365
|
-
|
|
50366
|
-
|
|
50367
|
-
|
|
50368
|
-
}, this.FOCUS_DEBOUNCE_MS);
|
|
50550
|
+
/**
|
|
50551
|
+
* Refresh manual: re-suscribe el stream Firestore (listener nuevo) vía el
|
|
50552
|
+
* helper. Resuelve tras un margen breve — el stream es real-time y re-emite
|
|
50553
|
+
* en milisegundos; el delay sólo mantiene el spinner visible lo justo.
|
|
50554
|
+
*/
|
|
50555
|
+
async reload() {
|
|
50556
|
+
this._stream.reload();
|
|
50557
|
+
await new Promise(r => setTimeout(r, 700));
|
|
50369
50558
|
}
|
|
50370
50559
|
// ---------------------------------------------------------------------------
|
|
50371
|
-
//
|
|
50560
|
+
// Per-item helpers (puros — usables desde template).
|
|
50372
50561
|
// ---------------------------------------------------------------------------
|
|
50562
|
+
openButtonProps() {
|
|
50563
|
+
return {
|
|
50564
|
+
text: this.tt('open'),
|
|
50565
|
+
color: 'dark',
|
|
50566
|
+
fill: 'clear',
|
|
50567
|
+
size: 'small',
|
|
50568
|
+
shape: 'round',
|
|
50569
|
+
type: 'button',
|
|
50570
|
+
icon: { name: 'open-outline', slot: 'end' },
|
|
50571
|
+
};
|
|
50572
|
+
}
|
|
50573
|
+
isExpanded(id) {
|
|
50574
|
+
return this._expanded().has(id);
|
|
50575
|
+
}
|
|
50576
|
+
toggleExpand(id) {
|
|
50577
|
+
const next = new Set(this._expanded());
|
|
50578
|
+
if (next.has(id))
|
|
50579
|
+
next.delete(id);
|
|
50580
|
+
else
|
|
50581
|
+
next.add(id);
|
|
50582
|
+
this._expanded.set(next);
|
|
50583
|
+
}
|
|
50584
|
+
isLong(body) {
|
|
50585
|
+
if (!body)
|
|
50586
|
+
return false;
|
|
50587
|
+
return body.length > 110 || body.includes('\n');
|
|
50588
|
+
}
|
|
50373
50589
|
/**
|
|
50374
|
-
*
|
|
50375
|
-
*
|
|
50376
|
-
*
|
|
50377
|
-
*
|
|
50378
|
-
* registro del device en el backend se pasa como callback (`registerDevice`)
|
|
50379
|
-
* porque la lib no puede depender de `AuthService` sin crear un ciclo.
|
|
50380
|
-
*
|
|
50381
|
-
* `enable()` no hace throw: resuelve con un `EnablePushResult` descriptivo.
|
|
50590
|
+
* Icon resolver con orden de fallback:
|
|
50591
|
+
* 1. notif.payload.icon (forward-compat, sin breaking change en schema)
|
|
50592
|
+
* 2. mapa por notif.type
|
|
50593
|
+
* 3. default notifications-outline
|
|
50382
50594
|
*/
|
|
50383
|
-
|
|
50384
|
-
|
|
50385
|
-
|
|
50386
|
-
|
|
50387
|
-
|
|
50388
|
-
registerDevice: token => this.auth.registerDevice(token).then(r => r.registered),
|
|
50389
|
-
});
|
|
50390
|
-
if (result.status === 'enabled') {
|
|
50391
|
-
// Persistir flag master si aún no estaba activo.
|
|
50392
|
-
if (!this.prefs.notificationsMaster()) {
|
|
50393
|
-
await this.prefs.setNotificationsMaster(true);
|
|
50394
|
-
}
|
|
50395
|
-
this.toast.show({
|
|
50396
|
-
message: this.tt('enableSuccess'),
|
|
50397
|
-
duration: 2000,
|
|
50398
|
-
color: 'dark',
|
|
50399
|
-
position: 'top',
|
|
50400
|
-
});
|
|
50401
|
-
this.resolvedConfig().onPushEnabled?.();
|
|
50402
|
-
}
|
|
50403
|
-
else if (result.status === 'timeout' && result.reloaded) {
|
|
50404
|
-
// La lib disparó el auto-reload — la página está por recargarse.
|
|
50405
|
-
return;
|
|
50595
|
+
resolveIcon(n) {
|
|
50596
|
+
const hint = n.payload?.['icon'];
|
|
50597
|
+
if (hint?.value) {
|
|
50598
|
+
if (hint.type === 'image' || hint.type === 'avatar') {
|
|
50599
|
+
return { kind: hint.type, src: hint.value };
|
|
50406
50600
|
}
|
|
50407
|
-
|
|
50408
|
-
|
|
50409
|
-
|
|
50410
|
-
|
|
50411
|
-
|
|
50412
|
-
|
|
50413
|
-
|
|
50414
|
-
|
|
50415
|
-
|
|
50601
|
+
// Ignoramos hint.color — todos los iconos van en dark por convención UX.
|
|
50602
|
+
return { kind: 'ionicon', name: hint.value, color: 'dark' };
|
|
50603
|
+
}
|
|
50604
|
+
const t = n.type;
|
|
50605
|
+
const name = (() => {
|
|
50606
|
+
switch (t) {
|
|
50607
|
+
case 'approval':
|
|
50608
|
+
return 'checkmark-circle-outline';
|
|
50609
|
+
case 'chat':
|
|
50610
|
+
case 'chatbot':
|
|
50611
|
+
return 'chatbubble-outline';
|
|
50612
|
+
case 'comment':
|
|
50613
|
+
return 'chatbox-ellipses-outline';
|
|
50614
|
+
case 'reminder':
|
|
50615
|
+
return 'alarm-outline';
|
|
50616
|
+
case 'system':
|
|
50617
|
+
return 'cog-outline';
|
|
50618
|
+
case 'user':
|
|
50619
|
+
case 'mention':
|
|
50620
|
+
return 'person-outline';
|
|
50621
|
+
case 'document':
|
|
50622
|
+
return 'document-text-outline';
|
|
50623
|
+
default:
|
|
50624
|
+
return 'notifications-outline';
|
|
50416
50625
|
}
|
|
50626
|
+
})();
|
|
50627
|
+
return { kind: 'ionicon', name, color: 'dark' };
|
|
50628
|
+
}
|
|
50629
|
+
ioniconName(n) {
|
|
50630
|
+
const r = this.resolveIcon(n);
|
|
50631
|
+
return r.kind === 'ionicon' ? r.name : '';
|
|
50632
|
+
}
|
|
50633
|
+
ioniconColor(n) {
|
|
50634
|
+
const r = this.resolveIcon(n);
|
|
50635
|
+
return r.kind === 'ionicon' ? r.color : undefined;
|
|
50636
|
+
}
|
|
50637
|
+
imageSrc(n) {
|
|
50638
|
+
const r = this.resolveIcon(n);
|
|
50639
|
+
return r.kind === 'image' || r.kind === 'avatar' ? r.src : '';
|
|
50640
|
+
}
|
|
50641
|
+
ionColor(token) {
|
|
50642
|
+
if (!token)
|
|
50643
|
+
return 'var(--ion-color-primary)';
|
|
50644
|
+
return `var(--ion-color-${token})`;
|
|
50645
|
+
}
|
|
50646
|
+
relativeTime(n) {
|
|
50647
|
+
const d = n.createdAt instanceof Date ? n.createdAt : null;
|
|
50648
|
+
if (!d)
|
|
50649
|
+
return '';
|
|
50650
|
+
const diffMs = Date.now() - d.getTime();
|
|
50651
|
+
const min = Math.floor(diffMs / 60000);
|
|
50652
|
+
if (min < 1)
|
|
50653
|
+
return this.tt('timeNow');
|
|
50654
|
+
if (min < 60)
|
|
50655
|
+
return this.tt('timeMinutes').replace('{n}', String(min));
|
|
50656
|
+
const h = Math.floor(min / 60);
|
|
50657
|
+
if (h < 24)
|
|
50658
|
+
return this.tt('timeHours').replace('{n}', String(h));
|
|
50659
|
+
const days = Math.floor(h / 24);
|
|
50660
|
+
return this.tt('timeDays').replace('{n}', String(days));
|
|
50661
|
+
}
|
|
50662
|
+
/** Devuelve label "Desde {appName}" si la notif viene de otra app, sino null. */
|
|
50663
|
+
showCrossAppBadge(n) {
|
|
50664
|
+
const currentApp = 'showcase';
|
|
50665
|
+
if (!n.appId || n.appId === currentApp)
|
|
50666
|
+
return null;
|
|
50667
|
+
return this.tt('crossApp').replace('{app}', n.appId);
|
|
50668
|
+
}
|
|
50669
|
+
// ---------------------------------------------------------------------------
|
|
50670
|
+
// Actions
|
|
50671
|
+
// ---------------------------------------------------------------------------
|
|
50672
|
+
async onOpen(n) {
|
|
50673
|
+
try {
|
|
50674
|
+
const result = await this.action.open(n);
|
|
50675
|
+
this.resolvedConfig().onNotificationOpen?.(n, result);
|
|
50417
50676
|
}
|
|
50418
|
-
catch {
|
|
50419
|
-
|
|
50420
|
-
|
|
50421
|
-
|
|
50422
|
-
|
|
50423
|
-
duration: 3000,
|
|
50424
|
-
color: 'dark',
|
|
50425
|
-
position: 'top',
|
|
50677
|
+
catch (err) {
|
|
50678
|
+
this.errors.handle(err, {
|
|
50679
|
+
context: 'notifications.open',
|
|
50680
|
+
fallbackKey: 'openError',
|
|
50681
|
+
i18nNamespace: this.ns,
|
|
50426
50682
|
});
|
|
50427
50683
|
}
|
|
50428
|
-
finally {
|
|
50429
|
-
// Único refresh final: re-alinea el toggle UNA sola vez con el estado real.
|
|
50430
|
-
await this.refreshBrowserState();
|
|
50431
|
-
this._busy.set(null);
|
|
50432
|
-
}
|
|
50433
50684
|
}
|
|
50434
|
-
async
|
|
50435
|
-
this.
|
|
50436
|
-
|
|
50685
|
+
async onMarkAll() {
|
|
50686
|
+
if (this._bulkBusy())
|
|
50687
|
+
return;
|
|
50688
|
+
this._bulkBusy.set(true);
|
|
50437
50689
|
try {
|
|
50438
|
-
await this.
|
|
50439
|
-
if (this.prefs.notificationsMaster()) {
|
|
50440
|
-
await this.prefs.setNotificationsMaster(false);
|
|
50441
|
-
}
|
|
50690
|
+
await this.notifs.markAllAsRead();
|
|
50442
50691
|
this.toast.show({
|
|
50443
|
-
message: this.tt('
|
|
50444
|
-
duration: 2000,
|
|
50692
|
+
message: this.tt('markedAllSuccess'),
|
|
50445
50693
|
color: 'dark',
|
|
50694
|
+
duration: 2500,
|
|
50446
50695
|
position: 'top',
|
|
50447
50696
|
});
|
|
50448
|
-
this.resolvedConfig().onPushDisabled?.();
|
|
50449
50697
|
}
|
|
50450
|
-
catch {
|
|
50451
|
-
this.
|
|
50698
|
+
catch (err) {
|
|
50699
|
+
this.errors.handle(err, {
|
|
50700
|
+
context: 'notifications.markAll',
|
|
50701
|
+
fallbackKey: 'markedAllError',
|
|
50702
|
+
i18nNamespace: this.ns,
|
|
50703
|
+
});
|
|
50452
50704
|
}
|
|
50453
50705
|
finally {
|
|
50454
|
-
|
|
50455
|
-
this._busy.set(null);
|
|
50706
|
+
this._bulkBusy.set(false);
|
|
50456
50707
|
}
|
|
50457
50708
|
}
|
|
50458
|
-
|
|
50459
|
-
|
|
50460
|
-
|
|
50461
|
-
|
|
50709
|
+
onLoadMore() {
|
|
50710
|
+
if (this._loadingMore())
|
|
50711
|
+
return;
|
|
50712
|
+
this._loadingMore.set(true);
|
|
50713
|
+
// Slice viene del cache local — no hay fetch real, pero un breve spinner
|
|
50714
|
+
// confirma al user que el click registró y suaviza la aparición de items.
|
|
50715
|
+
setTimeout(() => {
|
|
50716
|
+
this._pageSize.update(n => n + this.pageStep);
|
|
50717
|
+
this._loadingMore.set(false);
|
|
50718
|
+
}, 250);
|
|
50462
50719
|
}
|
|
50463
|
-
|
|
50464
|
-
this.
|
|
50720
|
+
onGoToSettings() {
|
|
50721
|
+
this.nav.navigateByUrl(this.resolvedConfig().settingsNotificationsPath);
|
|
50465
50722
|
}
|
|
50466
50723
|
// ---------------------------------------------------------------------------
|
|
50467
|
-
//
|
|
50724
|
+
// Grouping helpers
|
|
50468
50725
|
// ---------------------------------------------------------------------------
|
|
50469
|
-
|
|
50470
|
-
|
|
50471
|
-
|
|
50472
|
-
|
|
50473
|
-
|
|
50474
|
-
|
|
50475
|
-
/**
|
|
50476
|
-
* Repara el token FCM en background.
|
|
50477
|
-
*
|
|
50478
|
-
* Si el usuario tiene push activo (`master && granted`) pero no hay token en
|
|
50479
|
-
* memoria — caso típico tras un cold start de la PWA — re-obtenemos el token
|
|
50480
|
-
* y re-registramos el device sin tocar el toggle ni mostrar un error
|
|
50481
|
-
* alarmista. El token es un detalle de implementación reparable, no parte del
|
|
50482
|
-
* estado de UI (ver `isEnabled`).
|
|
50483
|
-
*/
|
|
50484
|
-
async repairTokenIfNeeded() {
|
|
50485
|
-
if (this._busy() !== null)
|
|
50486
|
-
return;
|
|
50487
|
-
if (this._repairingToken)
|
|
50488
|
-
return;
|
|
50489
|
-
if (!this.isEnabled())
|
|
50490
|
-
return;
|
|
50491
|
-
if (this.currentToken())
|
|
50492
|
-
return;
|
|
50493
|
-
this._repairingToken = true;
|
|
50494
|
-
try {
|
|
50495
|
-
const token = await this.messaging.getToken();
|
|
50496
|
-
if (token) {
|
|
50497
|
-
this.currentToken.set(token);
|
|
50498
|
-
await this.auth.registerDevice(token);
|
|
50499
|
-
}
|
|
50500
|
-
}
|
|
50501
|
-
catch {
|
|
50502
|
-
// Reparación best-effort: no degradamos el toggle ni mostramos error.
|
|
50503
|
-
}
|
|
50504
|
-
finally {
|
|
50505
|
-
this._repairingToken = false;
|
|
50726
|
+
bucketOf(n, now) {
|
|
50727
|
+
const d = n.createdAt instanceof Date ? n.createdAt : null;
|
|
50728
|
+
if (!d)
|
|
50729
|
+
return 'earlier';
|
|
50730
|
+
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
|
|
50731
|
+
return 'today';
|
|
50506
50732
|
}
|
|
50507
|
-
|
|
50508
|
-
|
|
50509
|
-
* iOS browser sin estar instalado como PWA — push web no disponible.
|
|
50510
|
-
* Aplica a Safari y a Edge/Chrome/Firefox en iOS (todos WebKit por mandato Apple).
|
|
50511
|
-
*/
|
|
50512
|
-
detectNeedsIOSInstall() {
|
|
50513
|
-
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
50514
|
-
return false;
|
|
50733
|
+
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
|
|
50734
|
+
return 'thisMonth';
|
|
50515
50735
|
}
|
|
50516
|
-
|
|
50517
|
-
const isIOS = /iPad|iPhone|iPod/.test(ua);
|
|
50518
|
-
if (!isIOS)
|
|
50519
|
-
return false;
|
|
50520
|
-
const isStandalone = window.matchMedia?.('(display-mode: standalone)').matches ||
|
|
50521
|
-
navigator.standalone === true;
|
|
50522
|
-
return !isStandalone;
|
|
50736
|
+
return 'earlier';
|
|
50523
50737
|
}
|
|
50524
50738
|
tt(key) {
|
|
50525
50739
|
return this.i18n.t(key, this.ns);
|
|
50526
50740
|
}
|
|
50527
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
50528
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type:
|
|
50741
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
50742
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationsViewComponent, isStandalone: true, selector: "val-notifications-view", inputs: { config: "config" }, ngImport: i0, template: `
|
|
50529
50743
|
<div class="page">
|
|
50530
50744
|
<header class="page-header">
|
|
50531
50745
|
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
@@ -50537,112 +50751,135 @@ class NotificationPreferencesViewComponent {
|
|
|
50537
50751
|
content: pageDescription(),
|
|
50538
50752
|
}"
|
|
50539
50753
|
/>
|
|
50754
|
+
@if (showMarkAll()) {
|
|
50755
|
+
<div class="row-actions">
|
|
50756
|
+
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
50757
|
+
</div>
|
|
50758
|
+
}
|
|
50540
50759
|
</header>
|
|
50541
50760
|
|
|
50542
|
-
@if (
|
|
50543
|
-
<section class="
|
|
50544
|
-
<
|
|
50545
|
-
|
|
50546
|
-
|
|
50547
|
-
|
|
50548
|
-
|
|
50549
|
-
|
|
50550
|
-
|
|
50551
|
-
|
|
50552
|
-
|
|
50761
|
+
@if (resolvedConfig().showPushOffCta && !pushEnabled()) {
|
|
50762
|
+
<section class="notif-cta">
|
|
50763
|
+
<ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
|
|
50764
|
+
<div class="notif-cta__body">
|
|
50765
|
+
<val-text
|
|
50766
|
+
[props]="{
|
|
50767
|
+
content: ctaTitle(),
|
|
50768
|
+
size: 'medium',
|
|
50769
|
+
color: 'dark',
|
|
50770
|
+
bold: true,
|
|
50771
|
+
}"
|
|
50772
|
+
/>
|
|
50553
50773
|
<val-text
|
|
50554
50774
|
[props]="{
|
|
50775
|
+
content: ctaHint(),
|
|
50555
50776
|
size: 'small',
|
|
50556
50777
|
color: 'medium',
|
|
50557
50778
|
bold: false,
|
|
50558
|
-
content: tt('iosInstallHint'),
|
|
50559
50779
|
}"
|
|
50560
50780
|
/>
|
|
50561
|
-
<
|
|
50562
|
-
<
|
|
50563
|
-
|
|
50564
|
-
<li>{{ tt('iosInstallStep3') }}</li>
|
|
50565
|
-
<li>{{ tt('iosInstallStep4') }}</li>
|
|
50566
|
-
</ol>
|
|
50781
|
+
<div class="row-actions">
|
|
50782
|
+
<val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
|
|
50783
|
+
</div>
|
|
50567
50784
|
</div>
|
|
50568
50785
|
</section>
|
|
50569
50786
|
}
|
|
50570
50787
|
|
|
50571
|
-
|
|
50572
|
-
<
|
|
50573
|
-
|
|
50574
|
-
|
|
50575
|
-
|
|
50576
|
-
|
|
50788
|
+
@if (loading()) {
|
|
50789
|
+
<val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
|
|
50790
|
+
} @else if (errorState()) {
|
|
50791
|
+
<val-empty-state [props]="errorState()!" />
|
|
50792
|
+
} @else if (allNotifs().length === 0) {
|
|
50793
|
+
<val-empty-state [props]="emptyStateProps()" />
|
|
50794
|
+
} @else {
|
|
50795
|
+
@for (group of groups(); track group.key) {
|
|
50796
|
+
<section class="group">
|
|
50577
50797
|
<val-title
|
|
50578
50798
|
[props]="{
|
|
50579
50799
|
size: 'medium',
|
|
50580
50800
|
color: 'dark',
|
|
50581
50801
|
bold: true,
|
|
50582
|
-
content:
|
|
50802
|
+
content: group.label,
|
|
50583
50803
|
}"
|
|
50584
50804
|
/>
|
|
50585
|
-
<
|
|
50586
|
-
|
|
50587
|
-
|
|
50588
|
-
|
|
50589
|
-
|
|
50590
|
-
|
|
50591
|
-
|
|
50592
|
-
|
|
50593
|
-
|
|
50594
|
-
|
|
50595
|
-
|
|
50596
|
-
|
|
50597
|
-
|
|
50598
|
-
|
|
50599
|
-
|
|
50600
|
-
|
|
50805
|
+
<ul class="notif-list">
|
|
50806
|
+
@for (n of group.items; track n.id) {
|
|
50807
|
+
<li class="notif" [class.notif--unread]="!n.isRead">
|
|
50808
|
+
<div class="notif__icon">
|
|
50809
|
+
@switch (resolveIcon(n).kind) {
|
|
50810
|
+
@case ('ionicon') {
|
|
50811
|
+
<ion-icon
|
|
50812
|
+
[name]="ioniconName(n)"
|
|
50813
|
+
[style.color]="ionColor(ioniconColor(n))"
|
|
50814
|
+
aria-hidden="true"
|
|
50815
|
+
/>
|
|
50816
|
+
}
|
|
50817
|
+
@case ('image') {
|
|
50818
|
+
<img [src]="imageSrc(n)" alt="" loading="lazy" />
|
|
50819
|
+
}
|
|
50820
|
+
@case ('avatar') {
|
|
50821
|
+
<img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
|
|
50822
|
+
}
|
|
50823
|
+
}
|
|
50824
|
+
</div>
|
|
50601
50825
|
|
|
50602
|
-
|
|
50603
|
-
|
|
50604
|
-
|
|
50605
|
-
|
|
50606
|
-
|
|
50826
|
+
<div class="notif__body">
|
|
50827
|
+
<div class="notif__head">
|
|
50828
|
+
<val-text
|
|
50829
|
+
[props]="{
|
|
50830
|
+
content: n.title || '—',
|
|
50831
|
+
size: 'medium',
|
|
50832
|
+
color: 'dark',
|
|
50833
|
+
bold: true,
|
|
50834
|
+
}"
|
|
50835
|
+
/>
|
|
50836
|
+
<span class="notif__time">{{ relativeTime(n) }}</span>
|
|
50837
|
+
</div>
|
|
50838
|
+
@if (n.body) {
|
|
50839
|
+
<p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
|
|
50840
|
+
{{ n.body }}
|
|
50841
|
+
</p>
|
|
50842
|
+
@if (isLong(n.body)) {
|
|
50843
|
+
<button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
|
|
50844
|
+
{{ isExpanded(n.id) ? seeLess() : seeMore() }}
|
|
50845
|
+
</button>
|
|
50846
|
+
}
|
|
50847
|
+
}
|
|
50848
|
+
@if (showCrossAppBadge(n); as appName) {
|
|
50849
|
+
<span class="notif__app">{{ appName }}</span>
|
|
50850
|
+
}
|
|
50851
|
+
@if (n.actionRoute) {
|
|
50852
|
+
<div class="row-actions row-actions--end">
|
|
50853
|
+
<val-button [props]="openButtonProps()" (click)="onOpen(n)" />
|
|
50854
|
+
</div>
|
|
50855
|
+
}
|
|
50856
|
+
</div>
|
|
50857
|
+
</li>
|
|
50858
|
+
}
|
|
50859
|
+
</ul>
|
|
50860
|
+
</section>
|
|
50607
50861
|
}
|
|
50608
50862
|
|
|
50609
|
-
@if (
|
|
50610
|
-
<div class="row-actions">
|
|
50611
|
-
<val-button [props]="
|
|
50863
|
+
@if (hasMore()) {
|
|
50864
|
+
<div class="row-actions row-actions--center">
|
|
50865
|
+
<val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
|
|
50612
50866
|
</div>
|
|
50613
50867
|
}
|
|
50614
|
-
</section>
|
|
50615
|
-
|
|
50616
|
-
@if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
|
|
50617
|
-
<section class="settings-section">
|
|
50618
|
-
<button class="details-toggle" type="button" (click)="toggleDetails()">
|
|
50619
|
-
<span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
|
|
50620
|
-
<ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
|
|
50621
|
-
</button>
|
|
50622
|
-
@if (showDetails()) {
|
|
50623
|
-
<dl class="details">
|
|
50624
|
-
<div class="details__row">
|
|
50625
|
-
<dt>{{ tt('detailsToken') }}</dt>
|
|
50626
|
-
<dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
|
|
50627
|
-
</div>
|
|
50628
|
-
<div class="details__row">
|
|
50629
|
-
<dt>{{ tt('diagnosticsPermission') }}</dt>
|
|
50630
|
-
<dd>{{ permission() }}</dd>
|
|
50631
|
-
</div>
|
|
50632
|
-
<div class="details__row">
|
|
50633
|
-
<dt>{{ tt('diagnosticsSupported') }}</dt>
|
|
50634
|
-
<dd>{{ isSupported() ? '✓' : '✗' }}</dd>
|
|
50635
|
-
</div>
|
|
50636
|
-
</dl>
|
|
50637
|
-
}
|
|
50638
|
-
</section>
|
|
50639
50868
|
}
|
|
50640
50869
|
</div>
|
|
50641
|
-
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
50870
|
+
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:20px}.row-actions--end{display:flex;justify-content:flex-end;margin-top:8px}.notif-cta{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;margin-bottom:16px;border:1px solid var(--ion-color-light-shade, rgba(0, 0, 0, .08));border-radius:14px;background:var(--ion-color-light)}.notif-cta__icon{font-size:24px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0;margin-top:2px}.notif-cta__body{display:flex;flex-direction:column;gap:2px}.group{padding:12px 0}.group+.group{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.notif-list{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:10px}.notif{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:12px;border-radius:12px;background:transparent;transition:background .12s ease}.notif--unread{background:var(--ion-color-light, rgba(0, 0, 0, .04))}.notif__icon{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ion-color-light, rgba(0, 0, 0, .06));overflow:hidden}.notif__icon ion-icon{font-size:22px}.notif__icon img{width:100%;height:100%;object-fit:cover}.notif__avatar{border-radius:50%}.notif__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif__head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.notif__time{font-size:12px;color:var(--ion-color-medium, #92949c);flex-shrink:0}.notif__text{margin:0;font-size:14px;line-height:1.4;color:var(--ion-color-dark, #1a1a1a);white-space:pre-wrap}.notif__text--clamped{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.notif__expand{background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-dark, #1a1a1a);cursor:pointer;align-self:flex-start;text-decoration:underline}.notif__app{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--ion-color-medium, #92949c);margin-top:2px}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }] }); }
|
|
50642
50871
|
}
|
|
50643
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
50872
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, decorators: [{
|
|
50644
50873
|
type: Component,
|
|
50645
|
-
args: [{ selector: 'val-
|
|
50874
|
+
args: [{ selector: 'val-notifications-view', standalone: true, imports: [
|
|
50875
|
+
IonIcon,
|
|
50876
|
+
DisplayComponent,
|
|
50877
|
+
TitleComponent,
|
|
50878
|
+
TextComponent,
|
|
50879
|
+
ButtonComponent,
|
|
50880
|
+
SkeletonLayoutComponent,
|
|
50881
|
+
EmptyStateComponent,
|
|
50882
|
+
], template: `
|
|
50646
50883
|
<div class="page">
|
|
50647
50884
|
<header class="page-header">
|
|
50648
50885
|
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
@@ -50654,1820 +50891,1577 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
50654
50891
|
content: pageDescription(),
|
|
50655
50892
|
}"
|
|
50656
50893
|
/>
|
|
50894
|
+
@if (showMarkAll()) {
|
|
50895
|
+
<div class="row-actions">
|
|
50896
|
+
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
50897
|
+
</div>
|
|
50898
|
+
}
|
|
50657
50899
|
</header>
|
|
50658
50900
|
|
|
50659
|
-
@if (
|
|
50660
|
-
<section class="
|
|
50661
|
-
<
|
|
50662
|
-
|
|
50663
|
-
|
|
50664
|
-
|
|
50665
|
-
|
|
50666
|
-
|
|
50667
|
-
|
|
50668
|
-
|
|
50669
|
-
|
|
50901
|
+
@if (resolvedConfig().showPushOffCta && !pushEnabled()) {
|
|
50902
|
+
<section class="notif-cta">
|
|
50903
|
+
<ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
|
|
50904
|
+
<div class="notif-cta__body">
|
|
50905
|
+
<val-text
|
|
50906
|
+
[props]="{
|
|
50907
|
+
content: ctaTitle(),
|
|
50908
|
+
size: 'medium',
|
|
50909
|
+
color: 'dark',
|
|
50910
|
+
bold: true,
|
|
50911
|
+
}"
|
|
50912
|
+
/>
|
|
50670
50913
|
<val-text
|
|
50671
50914
|
[props]="{
|
|
50915
|
+
content: ctaHint(),
|
|
50672
50916
|
size: 'small',
|
|
50673
50917
|
color: 'medium',
|
|
50674
50918
|
bold: false,
|
|
50675
|
-
content: tt('iosInstallHint'),
|
|
50676
50919
|
}"
|
|
50677
50920
|
/>
|
|
50678
|
-
<
|
|
50679
|
-
<
|
|
50680
|
-
|
|
50681
|
-
<li>{{ tt('iosInstallStep3') }}</li>
|
|
50682
|
-
<li>{{ tt('iosInstallStep4') }}</li>
|
|
50683
|
-
</ol>
|
|
50921
|
+
<div class="row-actions">
|
|
50922
|
+
<val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
|
|
50923
|
+
</div>
|
|
50684
50924
|
</div>
|
|
50685
50925
|
</section>
|
|
50686
50926
|
}
|
|
50687
50927
|
|
|
50688
|
-
|
|
50689
|
-
<
|
|
50690
|
-
|
|
50691
|
-
|
|
50692
|
-
|
|
50693
|
-
|
|
50928
|
+
@if (loading()) {
|
|
50929
|
+
<val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
|
|
50930
|
+
} @else if (errorState()) {
|
|
50931
|
+
<val-empty-state [props]="errorState()!" />
|
|
50932
|
+
} @else if (allNotifs().length === 0) {
|
|
50933
|
+
<val-empty-state [props]="emptyStateProps()" />
|
|
50934
|
+
} @else {
|
|
50935
|
+
@for (group of groups(); track group.key) {
|
|
50936
|
+
<section class="group">
|
|
50694
50937
|
<val-title
|
|
50695
50938
|
[props]="{
|
|
50696
50939
|
size: 'medium',
|
|
50697
50940
|
color: 'dark',
|
|
50698
50941
|
bold: true,
|
|
50699
|
-
content:
|
|
50700
|
-
}"
|
|
50701
|
-
/>
|
|
50702
|
-
<val-text
|
|
50703
|
-
[props]="{
|
|
50704
|
-
size: 'small',
|
|
50705
|
-
color: 'medium',
|
|
50706
|
-
bold: false,
|
|
50707
|
-
content: tt('simpleDescription'),
|
|
50942
|
+
content: group.label,
|
|
50708
50943
|
}"
|
|
50709
50944
|
/>
|
|
50710
|
-
|
|
50711
|
-
|
|
50712
|
-
|
|
50713
|
-
|
|
50714
|
-
|
|
50715
|
-
|
|
50716
|
-
|
|
50717
|
-
|
|
50945
|
+
<ul class="notif-list">
|
|
50946
|
+
@for (n of group.items; track n.id) {
|
|
50947
|
+
<li class="notif" [class.notif--unread]="!n.isRead">
|
|
50948
|
+
<div class="notif__icon">
|
|
50949
|
+
@switch (resolveIcon(n).kind) {
|
|
50950
|
+
@case ('ionicon') {
|
|
50951
|
+
<ion-icon
|
|
50952
|
+
[name]="ioniconName(n)"
|
|
50953
|
+
[style.color]="ionColor(ioniconColor(n))"
|
|
50954
|
+
aria-hidden="true"
|
|
50955
|
+
/>
|
|
50956
|
+
}
|
|
50957
|
+
@case ('image') {
|
|
50958
|
+
<img [src]="imageSrc(n)" alt="" loading="lazy" />
|
|
50959
|
+
}
|
|
50960
|
+
@case ('avatar') {
|
|
50961
|
+
<img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
|
|
50962
|
+
}
|
|
50963
|
+
}
|
|
50964
|
+
</div>
|
|
50718
50965
|
|
|
50719
|
-
|
|
50720
|
-
|
|
50721
|
-
|
|
50722
|
-
|
|
50723
|
-
|
|
50966
|
+
<div class="notif__body">
|
|
50967
|
+
<div class="notif__head">
|
|
50968
|
+
<val-text
|
|
50969
|
+
[props]="{
|
|
50970
|
+
content: n.title || '—',
|
|
50971
|
+
size: 'medium',
|
|
50972
|
+
color: 'dark',
|
|
50973
|
+
bold: true,
|
|
50974
|
+
}"
|
|
50975
|
+
/>
|
|
50976
|
+
<span class="notif__time">{{ relativeTime(n) }}</span>
|
|
50977
|
+
</div>
|
|
50978
|
+
@if (n.body) {
|
|
50979
|
+
<p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
|
|
50980
|
+
{{ n.body }}
|
|
50981
|
+
</p>
|
|
50982
|
+
@if (isLong(n.body)) {
|
|
50983
|
+
<button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
|
|
50984
|
+
{{ isExpanded(n.id) ? seeLess() : seeMore() }}
|
|
50985
|
+
</button>
|
|
50986
|
+
}
|
|
50987
|
+
}
|
|
50988
|
+
@if (showCrossAppBadge(n); as appName) {
|
|
50989
|
+
<span class="notif__app">{{ appName }}</span>
|
|
50990
|
+
}
|
|
50991
|
+
@if (n.actionRoute) {
|
|
50992
|
+
<div class="row-actions row-actions--end">
|
|
50993
|
+
<val-button [props]="openButtonProps()" (click)="onOpen(n)" />
|
|
50994
|
+
</div>
|
|
50995
|
+
}
|
|
50996
|
+
</div>
|
|
50997
|
+
</li>
|
|
50998
|
+
}
|
|
50999
|
+
</ul>
|
|
51000
|
+
</section>
|
|
50724
51001
|
}
|
|
50725
51002
|
|
|
50726
|
-
@if (
|
|
50727
|
-
<div class="row-actions">
|
|
50728
|
-
<val-button [props]="
|
|
51003
|
+
@if (hasMore()) {
|
|
51004
|
+
<div class="row-actions row-actions--center">
|
|
51005
|
+
<val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
|
|
50729
51006
|
</div>
|
|
50730
51007
|
}
|
|
50731
|
-
</section>
|
|
50732
|
-
|
|
50733
|
-
@if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
|
|
50734
|
-
<section class="settings-section">
|
|
50735
|
-
<button class="details-toggle" type="button" (click)="toggleDetails()">
|
|
50736
|
-
<span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
|
|
50737
|
-
<ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
|
|
50738
|
-
</button>
|
|
50739
|
-
@if (showDetails()) {
|
|
50740
|
-
<dl class="details">
|
|
50741
|
-
<div class="details__row">
|
|
50742
|
-
<dt>{{ tt('detailsToken') }}</dt>
|
|
50743
|
-
<dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
|
|
50744
|
-
</div>
|
|
50745
|
-
<div class="details__row">
|
|
50746
|
-
<dt>{{ tt('diagnosticsPermission') }}</dt>
|
|
50747
|
-
<dd>{{ permission() }}</dd>
|
|
50748
|
-
</div>
|
|
50749
|
-
<div class="details__row">
|
|
50750
|
-
<dt>{{ tt('diagnosticsSupported') }}</dt>
|
|
50751
|
-
<dd>{{ isSupported() ? '✓' : '✗' }}</dd>
|
|
50752
|
-
</div>
|
|
50753
|
-
</dl>
|
|
50754
|
-
}
|
|
50755
|
-
</section>
|
|
50756
51008
|
}
|
|
50757
51009
|
</div>
|
|
50758
|
-
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
51010
|
+
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:20px}.row-actions--end{display:flex;justify-content:flex-end;margin-top:8px}.notif-cta{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;margin-bottom:16px;border:1px solid var(--ion-color-light-shade, rgba(0, 0, 0, .08));border-radius:14px;background:var(--ion-color-light)}.notif-cta__icon{font-size:24px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0;margin-top:2px}.notif-cta__body{display:flex;flex-direction:column;gap:2px}.group{padding:12px 0}.group+.group{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.notif-list{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:10px}.notif{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:12px;border-radius:12px;background:transparent;transition:background .12s ease}.notif--unread{background:var(--ion-color-light, rgba(0, 0, 0, .04))}.notif__icon{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ion-color-light, rgba(0, 0, 0, .06));overflow:hidden}.notif__icon ion-icon{font-size:22px}.notif__icon img{width:100%;height:100%;object-fit:cover}.notif__avatar{border-radius:50%}.notif__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif__head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.notif__time{font-size:12px;color:var(--ion-color-medium, #92949c);flex-shrink:0}.notif__text{margin:0;font-size:14px;line-height:1.4;color:var(--ion-color-dark, #1a1a1a);white-space:pre-wrap}.notif__text--clamped{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.notif__expand{background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-dark, #1a1a1a);cursor:pointer;align-self:flex-start;text-decoration:underline}.notif__app{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--ion-color-medium, #92949c);margin-top:2px}\n"] }]
|
|
50759
51011
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
50760
51012
|
type: Input
|
|
50761
51013
|
}] } });
|
|
50762
51014
|
|
|
50763
51015
|
/**
|
|
50764
|
-
* Helper para montar
|
|
50765
|
-
*
|
|
50766
|
-
* `
|
|
50767
|
-
*
|
|
50768
|
-
*
|
|
50769
|
-
* No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
|
|
51016
|
+
* Helper para montar el inbox de notificaciones (`val-notifications-view`) como
|
|
51017
|
+
* ruta en una app del factory. El `config` se pasa por route `data`
|
|
51018
|
+
* (`notificationsConfig`) y el componente lo lee como fallback de su
|
|
51019
|
+
* `@Input() config`.
|
|
50770
51020
|
*
|
|
50771
51021
|
* @example
|
|
50772
51022
|
* ```ts
|
|
50773
|
-
* // app.routes.ts
|
|
50774
|
-
* export const
|
|
50775
|
-
* ...
|
|
51023
|
+
* // app.routes.ts
|
|
51024
|
+
* export const appRoutes: Routes = [
|
|
51025
|
+
* ...provideValtechNotificationsRoutes(), // → /app/notifications
|
|
51026
|
+
* { path: 'settings', loadChildren: () => ... },
|
|
50776
51027
|
* ];
|
|
50777
51028
|
*
|
|
50778
51029
|
* // con config acotada:
|
|
50779
|
-
* ...
|
|
50780
|
-
* ...
|
|
51030
|
+
* ...provideValtechNotificationsRoutes({ config: { enableMarkAll: false } }),
|
|
51031
|
+
* ...provideValtechNotificationsRoutes({
|
|
51032
|
+
* config: { settingsNotificationsPath: '/app/preferences/push' },
|
|
51033
|
+
* }),
|
|
50781
51034
|
* ```
|
|
50782
51035
|
*/
|
|
50783
|
-
function
|
|
51036
|
+
function provideValtechNotificationsRoutes(opts) {
|
|
50784
51037
|
return [
|
|
50785
51038
|
{
|
|
50786
51039
|
path: opts?.path ?? 'notifications',
|
|
50787
|
-
component:
|
|
50788
|
-
data: {
|
|
51040
|
+
component: NotificationsViewComponent,
|
|
51041
|
+
data: { notificationsConfig: opts?.config },
|
|
50789
51042
|
},
|
|
50790
51043
|
];
|
|
50791
51044
|
}
|
|
50792
51045
|
|
|
50793
51046
|
/**
|
|
50794
|
-
*
|
|
50795
|
-
*
|
|
50796
|
-
*
|
|
50797
|
-
*
|
|
50798
|
-
*
|
|
50799
|
-
* Promovido desde `showcase` a la lib bajo el proceso de ADR-021. Usa
|
|
50800
|
-
* `VALTECH_AUTH_CONFIG.apiUrl` (como `OrgService`) en vez del `environment` de la
|
|
50801
|
-
* app — la lib no importa el environment del consumer.
|
|
50802
|
-
*/
|
|
50803
|
-
class PermissionCatalogService {
|
|
50804
|
-
constructor(config, http) {
|
|
50805
|
-
this.config = config;
|
|
50806
|
-
this.http = http;
|
|
50807
|
-
}
|
|
50808
|
-
getCatalog(orgId) {
|
|
50809
|
-
return this.http.get(`${this.config.apiUrl}/org/${orgId}/permissions-catalog`);
|
|
50810
|
-
}
|
|
50811
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$3.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
50812
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, providedIn: 'root' }); }
|
|
50813
|
-
}
|
|
50814
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, decorators: [{
|
|
50815
|
-
type: Injectable,
|
|
50816
|
-
args: [{ providedIn: 'root' }]
|
|
50817
|
-
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
50818
|
-
type: Inject,
|
|
50819
|
-
args: [VALTECH_AUTH_CONFIG]
|
|
50820
|
-
}] }, { type: i1$3.HttpClient }] });
|
|
50821
|
-
|
|
50822
|
-
/**
|
|
50823
|
-
* Defaults i18n (es/en) embebidos en `val-permissions-view`. Auto-registrados en
|
|
50824
|
-
* el constructor del componente si el consumer no proveyó el namespace
|
|
50825
|
-
* (`Settings.Permissions` por default). SOLO incluye las keys que la vista usa.
|
|
51047
|
+
* Defaults i18n (es/en) embebidos en `val-notification-preferences-view`.
|
|
51048
|
+
* Auto-registrados en el constructor del componente si el consumer no proveyó
|
|
51049
|
+
* el namespace (`Settings.Notifications` por default). SOLO incluye las keys que
|
|
51050
|
+
* la vista usa.
|
|
50826
51051
|
*
|
|
50827
|
-
* Portado
|
|
51052
|
+
* Portado del override `Settings.Notifications` (showcase) bajo el proceso de
|
|
51053
|
+
* ADR-021.
|
|
50828
51054
|
*/
|
|
50829
|
-
const
|
|
51055
|
+
const NOTIFICATION_PREFERENCES_VIEW_I18N = {
|
|
50830
51056
|
es: {
|
|
50831
|
-
pageTitle: '
|
|
50832
|
-
pageDescription: '
|
|
50833
|
-
|
|
50834
|
-
|
|
50835
|
-
|
|
50836
|
-
|
|
50837
|
-
|
|
50838
|
-
|
|
50839
|
-
|
|
50840
|
-
|
|
50841
|
-
|
|
50842
|
-
|
|
50843
|
-
|
|
50844
|
-
//
|
|
50845
|
-
|
|
50846
|
-
|
|
50847
|
-
|
|
50848
|
-
|
|
50849
|
-
|
|
50850
|
-
|
|
50851
|
-
|
|
50852
|
-
|
|
50853
|
-
|
|
50854
|
-
|
|
50855
|
-
|
|
50856
|
-
|
|
50857
|
-
|
|
50858
|
-
|
|
50859
|
-
|
|
50860
|
-
|
|
50861
|
-
'permApi-keys': 'API keys',
|
|
50862
|
-
'permEmail-logs': 'Logs de correo',
|
|
50863
|
-
'permSms-logs': 'Logs de SMS',
|
|
50864
|
-
permDocuments: 'Documentos',
|
|
50865
|
-
permMedia: 'Media',
|
|
50866
|
-
permTemplates: 'Plantillas',
|
|
50867
|
-
permRequests: 'Solicitudes',
|
|
50868
|
-
permBingo: 'Bingo',
|
|
50869
|
-
// Acciones
|
|
50870
|
-
permRead: 'Leer',
|
|
50871
|
-
permWrite: 'Escribir',
|
|
50872
|
-
permCreate: 'Crear',
|
|
50873
|
-
permDelete: 'Eliminar',
|
|
50874
|
-
permManage: 'Gestionar',
|
|
50875
|
-
permEdit: 'Editar',
|
|
50876
|
-
permSell: 'Vender',
|
|
50877
|
-
permCheckin: 'Canjear',
|
|
50878
|
-
permSettle: 'Rendir',
|
|
50879
|
-
permExport: 'Exportar',
|
|
50880
|
-
permOperate: 'Operar',
|
|
50881
|
-
permAll: 'Todo',
|
|
50882
|
-
// Loading / error
|
|
50883
|
-
errorTitle: 'Error al cargar permisos',
|
|
50884
|
-
retry: 'Reintentar',
|
|
51057
|
+
pageTitle: 'Notificaciones',
|
|
51058
|
+
pageDescription: 'Configura como quieres recibir notificaciones',
|
|
51059
|
+
// Control principal (UX simplificado)
|
|
51060
|
+
simpleTitle: 'Notificaciones push en este dispositivo',
|
|
51061
|
+
simpleDescription: 'Recibe alertas en tiempo real incluso con la app cerrada.',
|
|
51062
|
+
enabling: 'Activando…',
|
|
51063
|
+
disabling: 'Desactivando…',
|
|
51064
|
+
enabled: 'Activadas',
|
|
51065
|
+
errorGeneric: 'No pudimos completar la configuración. Verifica los permisos del navegador y reintenta.',
|
|
51066
|
+
retryCta: 'Reintentar',
|
|
51067
|
+
// Mensajes contextuales
|
|
51068
|
+
permissionUnsupported: 'Tu navegador no soporta notificaciones push.',
|
|
51069
|
+
permissionDeniedInfo: 'Tu navegador esta bloqueando las notificaciones. Para habilitarlas, ve a la configuracion del sitio y permitelas manualmente.',
|
|
51070
|
+
// iOS PWA install
|
|
51071
|
+
iosInstallTitle: 'En iOS, instala la app para recibir notificaciones',
|
|
51072
|
+
iosInstallHint: 'Las notificaciones push en iOS (iPhone/iPad) solo funcionan cuando la app está instalada en la pantalla de inicio. Edge, Chrome y Firefox en iOS comparten esta limitación.',
|
|
51073
|
+
iosInstallStep1: 'Abre este sitio en Safari (no en Chrome/Edge).',
|
|
51074
|
+
iosInstallStep2: 'Toca el botón Compartir (□ con flecha hacia arriba).',
|
|
51075
|
+
iosInstallStep3: "Elige 'Añadir a pantalla de inicio'.",
|
|
51076
|
+
iosInstallStep4: 'Abre la app desde el ícono recién creado y vuelve aquí.',
|
|
51077
|
+
// Detalles técnicos (expandible)
|
|
51078
|
+
detailsShow: 'Ver detalles técnicos',
|
|
51079
|
+
detailsHide: 'Ocultar detalles',
|
|
51080
|
+
detailsToken: 'Token',
|
|
51081
|
+
detailsNone: 'Sin registrar',
|
|
51082
|
+
diagnosticsPermission: 'Permiso del navegador',
|
|
51083
|
+
diagnosticsSupported: 'Soporte del navegador',
|
|
51084
|
+
// Toasts
|
|
51085
|
+
enableSuccess: 'Notificaciones habilitadas',
|
|
51086
|
+
disableSuccess: 'Notificaciones deshabilitadas',
|
|
50885
51087
|
},
|
|
50886
51088
|
en: {
|
|
50887
|
-
pageTitle: '
|
|
50888
|
-
pageDescription: '
|
|
50889
|
-
|
|
50890
|
-
|
|
50891
|
-
|
|
50892
|
-
|
|
50893
|
-
|
|
50894
|
-
|
|
50895
|
-
|
|
50896
|
-
|
|
50897
|
-
|
|
50898
|
-
|
|
50899
|
-
|
|
50900
|
-
|
|
50901
|
-
|
|
50902
|
-
|
|
50903
|
-
|
|
50904
|
-
|
|
50905
|
-
|
|
50906
|
-
|
|
50907
|
-
|
|
50908
|
-
|
|
50909
|
-
|
|
50910
|
-
|
|
50911
|
-
|
|
50912
|
-
permApps: 'Apps',
|
|
50913
|
-
'permApi-keys': 'API keys',
|
|
50914
|
-
'permEmail-logs': 'Email logs',
|
|
50915
|
-
'permSms-logs': 'SMS logs',
|
|
50916
|
-
permDocuments: 'Documents',
|
|
50917
|
-
permMedia: 'Media',
|
|
50918
|
-
permTemplates: 'Templates',
|
|
50919
|
-
permRequests: 'Requests',
|
|
50920
|
-
permBingo: 'Bingo',
|
|
50921
|
-
permRead: 'Read',
|
|
50922
|
-
permWrite: 'Write',
|
|
50923
|
-
permCreate: 'Create',
|
|
50924
|
-
permDelete: 'Delete',
|
|
50925
|
-
permManage: 'Manage',
|
|
50926
|
-
permEdit: 'Edit',
|
|
50927
|
-
permSell: 'Sell',
|
|
50928
|
-
permCheckin: 'Check-in',
|
|
50929
|
-
permSettle: 'Settle',
|
|
50930
|
-
permExport: 'Export',
|
|
50931
|
-
permOperate: 'Operate',
|
|
50932
|
-
permAll: 'All',
|
|
50933
|
-
errorTitle: 'Error loading permissions',
|
|
50934
|
-
retry: 'Retry',
|
|
51089
|
+
pageTitle: 'Notifications',
|
|
51090
|
+
pageDescription: 'Configure how you want to receive notifications',
|
|
51091
|
+
simpleTitle: 'Push notifications on this device',
|
|
51092
|
+
simpleDescription: 'Get real-time alerts even when the app is closed.',
|
|
51093
|
+
enabling: 'Enabling…',
|
|
51094
|
+
disabling: 'Disabling…',
|
|
51095
|
+
enabled: 'Enabled',
|
|
51096
|
+
errorGeneric: "We couldn't complete the setup. Check your browser permissions and retry.",
|
|
51097
|
+
retryCta: 'Retry',
|
|
51098
|
+
permissionUnsupported: 'Your browser does not support push notifications.',
|
|
51099
|
+
permissionDeniedInfo: 'Your browser is blocking notifications. To enable them, go to site settings and allow them manually.',
|
|
51100
|
+
iosInstallTitle: 'On iOS, install the app to receive notifications',
|
|
51101
|
+
iosInstallHint: 'Push notifications on iOS (iPhone/iPad) only work when the app is installed on the Home Screen. Edge, Chrome and Firefox on iOS share this limitation.',
|
|
51102
|
+
iosInstallStep1: 'Open this site in Safari (not Chrome/Edge).',
|
|
51103
|
+
iosInstallStep2: 'Tap the Share button (□ with an up arrow).',
|
|
51104
|
+
iosInstallStep3: "Choose 'Add to Home Screen'.",
|
|
51105
|
+
iosInstallStep4: 'Open the app from the new icon and come back here.',
|
|
51106
|
+
detailsShow: 'View technical details',
|
|
51107
|
+
detailsHide: 'Hide details',
|
|
51108
|
+
detailsToken: 'Token',
|
|
51109
|
+
detailsNone: 'Not registered',
|
|
51110
|
+
diagnosticsPermission: 'Browser permission',
|
|
51111
|
+
diagnosticsSupported: 'Browser support',
|
|
51112
|
+
enableSuccess: 'Notifications enabled',
|
|
51113
|
+
disableSuccess: 'Notifications disabled',
|
|
50935
51114
|
},
|
|
50936
51115
|
};
|
|
50937
51116
|
|
|
50938
|
-
|
|
51117
|
+
addIcons({
|
|
51118
|
+
alertCircleOutline,
|
|
51119
|
+
chevronDownOutline,
|
|
51120
|
+
chevronUpOutline,
|
|
51121
|
+
closeCircleOutline,
|
|
51122
|
+
notificationsOffOutline,
|
|
51123
|
+
notificationsOutline,
|
|
51124
|
+
refreshOutline,
|
|
51125
|
+
});
|
|
51126
|
+
const DEFAULT_NAMESPACE$1 = 'Settings.Notifications';
|
|
50939
51127
|
/**
|
|
50940
|
-
* `val-
|
|
50941
|
-
*
|
|
50942
|
-
*
|
|
50943
|
-
* catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
|
|
50944
|
-
* Promovida desde `showcase` bajo el proceso de ADR-021.
|
|
51128
|
+
* `val-notification-preferences-view` — **preferencias de notificaciones push**
|
|
51129
|
+
* (FCM) full-feature autocontenida (organism). Promovida desde `showcase` bajo el
|
|
51130
|
+
* proceso de ADR-021.
|
|
50945
51131
|
*
|
|
50946
|
-
*
|
|
50947
|
-
*
|
|
50948
|
-
* puede verla es responsabilidad del guard de ruta del consumer; no hay gating
|
|
50949
|
-
* fino interno ni se expone como config.
|
|
51132
|
+
* Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
|
|
51133
|
+
* desactiva** las notificaciones push en este dispositivo.
|
|
50950
51134
|
*
|
|
50951
|
-
*
|
|
50952
|
-
*
|
|
50953
|
-
*
|
|
51135
|
+
* UX simplificado: UN solo toggle que ejecuta toda la secuencia (pedir permiso →
|
|
51136
|
+
* SW ready → getToken → registrar en backend → set master pref) vía
|
|
51137
|
+
* `MessagingService.enable()`. El registro del device en el backend va como
|
|
51138
|
+
* callback (la lib no depende de `AuthService` para evitar ciclos). Mensajes
|
|
51139
|
+
* contextuales y botón "Reintentar" aparecen solo cuando aplican.
|
|
50954
51140
|
*
|
|
50955
51141
|
* NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
|
|
50956
|
-
* inyecta `{ optional: true }`
|
|
50957
|
-
* sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
|
|
50958
|
-
* `Settings.Permissions`).
|
|
51142
|
+
* inyecta `{ optional: true }` → funciona sin router (embebida / Storybook).
|
|
50959
51143
|
*/
|
|
50960
|
-
class
|
|
51144
|
+
class NotificationPreferencesViewComponent {
|
|
50961
51145
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
50962
51146
|
get ns() {
|
|
50963
51147
|
return this.resolvedConfig().i18nNamespace;
|
|
50964
51148
|
}
|
|
51149
|
+
// ---------------------------------------------------------------------------
|
|
51150
|
+
// Lifecycle
|
|
51151
|
+
// ---------------------------------------------------------------------------
|
|
50965
51152
|
constructor() {
|
|
51153
|
+
this.nav = inject(NavigationService);
|
|
50966
51154
|
this.i18n = inject(I18nService);
|
|
50967
|
-
this.
|
|
50968
|
-
this.catalog = inject(PermissionCatalogService);
|
|
51155
|
+
this.toast = inject(ToastService);
|
|
50969
51156
|
this.auth = inject(AuthService);
|
|
50970
|
-
this.
|
|
51157
|
+
this.messaging = inject(MessagingService);
|
|
51158
|
+
this.pageRefresh = inject(PageRefreshService);
|
|
51159
|
+
this.prefs = inject(PreferencesService);
|
|
51160
|
+
// `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
|
|
51161
|
+
// router (solo lo usa para leer el route data de config como fallback).
|
|
50971
51162
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
50972
51163
|
this.resolvedConfig = computed(() => {
|
|
50973
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
51164
|
+
const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
|
|
51165
|
+
{});
|
|
50974
51166
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
50975
51167
|
return {
|
|
50976
|
-
|
|
50977
|
-
showCustomRoles: merged.showCustomRoles ?? true,
|
|
51168
|
+
showDetails: merged.showDetails ?? true,
|
|
50978
51169
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$1,
|
|
51170
|
+
onPushEnabled: merged.onPushEnabled,
|
|
51171
|
+
onPushDisabled: merged.onPushDisabled,
|
|
50979
51172
|
};
|
|
50980
51173
|
});
|
|
50981
|
-
|
|
50982
|
-
this.
|
|
50983
|
-
|
|
50984
|
-
this.
|
|
50985
|
-
|
|
50986
|
-
this.
|
|
50987
|
-
|
|
50988
|
-
|
|
51174
|
+
/** Operación en curso (gobierna spinner del toggle + label "Activando…"). */
|
|
51175
|
+
this._busy = signal(null);
|
|
51176
|
+
/** True si el último intento falló — gobierna estado `error` + retry button. */
|
|
51177
|
+
this._lastFailed = signal(false);
|
|
51178
|
+
/** True mientras se re-obtiene el token FCM en background (no afecta la UI). */
|
|
51179
|
+
this._repairingToken = false;
|
|
51180
|
+
/** Toggle del expand de detalles técnicos. */
|
|
51181
|
+
this.showDetails = signal(false);
|
|
51182
|
+
this.permission = signal('default');
|
|
51183
|
+
this.isSupported = signal(false);
|
|
51184
|
+
this.currentToken = signal(null);
|
|
51185
|
+
this.needsIOSInstall = signal(this.detectNeedsIOSInstall());
|
|
51186
|
+
/** FormControl del toggle (sync-eado con `isEnabled` via effect). */
|
|
51187
|
+
this.toggleControl = new FormControl(false, {
|
|
51188
|
+
nonNullable: true,
|
|
51189
|
+
});
|
|
51190
|
+
/** Handler del listener `focus` — referencia guardada para poder removerlo. */
|
|
51191
|
+
this.onWindowFocus = () => this.scheduleBrowserRefresh();
|
|
51192
|
+
/** Timer del debounce del refresh disparado por `focus`. */
|
|
51193
|
+
this.focusDebounceTimer = null;
|
|
51194
|
+
/** Ventana de debounce (ms) para el refresh disparado por `focus`. */
|
|
51195
|
+
this.FOCUS_DEBOUNCE_MS = 500;
|
|
51196
|
+
// ---------------------------------------------------------------------------
|
|
51197
|
+
// Estado derivado
|
|
51198
|
+
// ---------------------------------------------------------------------------
|
|
51199
|
+
this.pageTitle = computed(() => this.tt('pageTitle'));
|
|
51200
|
+
this.pageDescription = computed(() => this.tt('pageDescription'));
|
|
51201
|
+
/**
|
|
51202
|
+
* Estado efectivo del toggle: "el usuario tiene push activo".
|
|
51203
|
+
*
|
|
51204
|
+
* Se deriva SOLO de la decisión del usuario (`master`) y del permiso del SO
|
|
51205
|
+
* (`permission`). El token FCM NO forma parte del estado de UI: vive en
|
|
51206
|
+
* memoria, se pierde en cada recarga/suspensión de la PWA y produciría flicker.
|
|
51207
|
+
* Si falta token con `master && granted`, es un detalle reparable en
|
|
51208
|
+
* background (ver `repairTokenIfNeeded`), no un cambio del toggle.
|
|
51209
|
+
*/
|
|
51210
|
+
this.isEnabled = computed(() => this.prefs.notificationsMaster() && this.permission() === 'granted');
|
|
51211
|
+
this.state = computed(() => {
|
|
51212
|
+
if (this.needsIOSInstall())
|
|
51213
|
+
return 'needs-install';
|
|
51214
|
+
if (this.permission() === 'unsupported' || !this.isSupported()) {
|
|
51215
|
+
// iOS standalone aún puede dar isSupported false temprano antes de init —
|
|
51216
|
+
// solo bloqueamos si el browser explícitamente no soporta APIs.
|
|
51217
|
+
if (this.permission() === 'unsupported')
|
|
51218
|
+
return 'unsupported';
|
|
51219
|
+
}
|
|
51220
|
+
const busy = this._busy();
|
|
51221
|
+
if (busy === 'enabling')
|
|
51222
|
+
return 'enabling';
|
|
51223
|
+
if (busy === 'disabling')
|
|
51224
|
+
return 'disabling';
|
|
51225
|
+
if (this.isEnabled())
|
|
51226
|
+
return 'enabled';
|
|
51227
|
+
if (this.permission() === 'denied')
|
|
51228
|
+
return 'blocked';
|
|
51229
|
+
if (this._lastFailed())
|
|
51230
|
+
return 'error';
|
|
51231
|
+
return 'idle';
|
|
51232
|
+
});
|
|
51233
|
+
this.busyLabel = computed(() => {
|
|
51234
|
+
const s = this.state();
|
|
51235
|
+
if (s === 'enabling')
|
|
51236
|
+
return this.tt('enabling');
|
|
51237
|
+
if (s === 'disabling')
|
|
51238
|
+
return this.tt('disabling');
|
|
51239
|
+
return null;
|
|
51240
|
+
});
|
|
51241
|
+
this.contextMessage = computed(() => {
|
|
51242
|
+
switch (this.state()) {
|
|
51243
|
+
case 'unsupported':
|
|
51244
|
+
return this.tt('permissionUnsupported');
|
|
51245
|
+
case 'blocked':
|
|
51246
|
+
return this.tt('permissionDeniedInfo');
|
|
51247
|
+
case 'error':
|
|
51248
|
+
return this.tt('errorGeneric');
|
|
51249
|
+
default:
|
|
51250
|
+
return null;
|
|
51251
|
+
}
|
|
50989
51252
|
});
|
|
51253
|
+
this.alertVariant = computed(() => {
|
|
51254
|
+
const s = this.state();
|
|
51255
|
+
if (s === 'error' || s === 'blocked')
|
|
51256
|
+
return 'warning';
|
|
51257
|
+
if (s === 'unsupported')
|
|
51258
|
+
return 'danger';
|
|
51259
|
+
return 'info';
|
|
51260
|
+
});
|
|
51261
|
+
this.alertIcon = computed(() => {
|
|
51262
|
+
const s = this.state();
|
|
51263
|
+
if (s === 'unsupported')
|
|
51264
|
+
return 'close-circle-outline';
|
|
51265
|
+
return 'alert-circle-outline';
|
|
51266
|
+
});
|
|
51267
|
+
this.toggleProps = computed(() => ({
|
|
51268
|
+
control: this.toggleControl,
|
|
51269
|
+
color: 'primary',
|
|
51270
|
+
disabled: this._busy() !== null ||
|
|
51271
|
+
this.state() === 'unsupported' ||
|
|
51272
|
+
this.state() === 'needs-install' ||
|
|
51273
|
+
this.state() === 'blocked',
|
|
51274
|
+
}));
|
|
50990
51275
|
this.retryButtonProps = computed(() => ({
|
|
50991
|
-
text: this.tt('
|
|
51276
|
+
text: this.tt('retryCta'),
|
|
50992
51277
|
color: 'dark',
|
|
50993
51278
|
fill: 'outline',
|
|
50994
51279
|
size: 'default',
|
|
51280
|
+
shape: 'round',
|
|
50995
51281
|
type: 'button',
|
|
50996
|
-
|
|
51282
|
+
icon: { name: 'refresh-outline', slot: 'start' },
|
|
51283
|
+
state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
50997
51284
|
}));
|
|
51285
|
+
this.tokenPreview = computed(() => {
|
|
51286
|
+
const t = this.currentToken();
|
|
51287
|
+
if (!t)
|
|
51288
|
+
return null;
|
|
51289
|
+
return `${t.slice(0, 22)}…${t.slice(-6)}`;
|
|
51290
|
+
});
|
|
50998
51291
|
const ns = this.ns;
|
|
50999
51292
|
if (!this.i18n.hasNamespace(ns)) {
|
|
51000
|
-
this.i18n.registerContent(ns,
|
|
51293
|
+
this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
|
|
51001
51294
|
}
|
|
51002
51295
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
51003
|
-
this.
|
|
51004
|
-
|
|
51005
|
-
|
|
51006
|
-
|
|
51007
|
-
|
|
51008
|
-
|
|
51009
|
-
|
|
51010
|
-
|
|
51011
|
-
|
|
51012
|
-
|
|
51013
|
-
|
|
51014
|
-
|
|
51015
|
-
|
|
51016
|
-
|
|
51017
|
-
|
|
51018
|
-
|
|
51019
|
-
|
|
51020
|
-
|
|
51021
|
-
|
|
51022
|
-
|
|
51023
|
-
|
|
51024
|
-
|
|
51025
|
-
|
|
51026
|
-
|
|
51027
|
-
|
|
51028
|
-
|
|
51029
|
-
|
|
51030
|
-
|
|
51031
|
-
this.
|
|
51032
|
-
|
|
51033
|
-
|
|
51296
|
+
void this.refreshBrowserState();
|
|
51297
|
+
// Re-aplicar header en cambio de idioma.
|
|
51298
|
+
effect(() => {
|
|
51299
|
+
this.i18n.lang();
|
|
51300
|
+
this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
|
|
51301
|
+
}, { allowSignalWrites: true });
|
|
51302
|
+
// Sync FormControl ←→ estado efectivo. Cuando el estado cambia (cambio de
|
|
51303
|
+
// permission por el browser, o snapshot Firestore de `master`), el toggle se
|
|
51304
|
+
// re-alinea con la realidad.
|
|
51305
|
+
//
|
|
51306
|
+
// IMPORTANTE: se ignoran los cambios mientras hay una operación en curso
|
|
51307
|
+
// (`_busy() !== null`). Durante `onEnable`/`onDisable` las señales fuente se
|
|
51308
|
+
// actualizan en momentos distintos y producirían estados intermedios →
|
|
51309
|
+
// flicker visible. El toggle se re-alinea UNA sola vez en el `finally`.
|
|
51310
|
+
effect(() => {
|
|
51311
|
+
const eff = this.isEnabled();
|
|
51312
|
+
if (this._busy() !== null)
|
|
51313
|
+
return;
|
|
51314
|
+
if (this.toggleControl.value !== eff) {
|
|
51315
|
+
this.toggleControl.setValue(eff, { emitEvent: false });
|
|
51316
|
+
}
|
|
51317
|
+
}, { allowSignalWrites: true });
|
|
51318
|
+
// Interceptar click del user: el toggle ya cambió visualmente, ejecutar
|
|
51319
|
+
// la acción correspondiente.
|
|
51320
|
+
this.toggleControl.valueChanges.subscribe(v => {
|
|
51321
|
+
if (this._busy() !== null)
|
|
51322
|
+
return;
|
|
51323
|
+
if (v && !this.isEnabled()) {
|
|
51324
|
+
void this.onEnable();
|
|
51325
|
+
}
|
|
51326
|
+
else if (!v && this.isEnabled()) {
|
|
51327
|
+
void this.onDisable();
|
|
51328
|
+
}
|
|
51034
51329
|
});
|
|
51330
|
+
// Detectar regreso a la pestaña — el user pudo haber cambiado permisos
|
|
51331
|
+
// desde la config del browser. El refresh va con debounce para no
|
|
51332
|
+
// dispararse en ráfagas (en PWA mobile `focus` salta con cada cambio de
|
|
51333
|
+
// app / aparición del teclado). El listener se remueve en ngOnDestroy.
|
|
51334
|
+
if (typeof window !== 'undefined') {
|
|
51335
|
+
window.addEventListener('focus', this.onWindowFocus);
|
|
51336
|
+
}
|
|
51035
51337
|
}
|
|
51036
|
-
|
|
51037
|
-
|
|
51038
|
-
|
|
51039
|
-
|
|
51040
|
-
|
|
51041
|
-
|
|
51042
|
-
|
|
51043
|
-
|
|
51044
|
-
|
|
51045
|
-
|
|
51046
|
-
|
|
51047
|
-
|
|
51048
|
-
|
|
51049
|
-
|
|
51050
|
-
|
|
51051
|
-
|
|
51052
|
-
|
|
51338
|
+
/**
|
|
51339
|
+
* Registra el pull-to-refresh estándar. Aunque esta vista no muestra una
|
|
51340
|
+
* lista remota, sí refleja estado mutable del browser (permiso, token,
|
|
51341
|
+
* soporte FCM) que puede cambiar fuera de la app — el refresh re-evalúa todo.
|
|
51342
|
+
*
|
|
51343
|
+
* Se usa `ngOnInit`/`ngOnDestroy` (NO `ionViewWillEnter`) porque
|
|
51344
|
+
* `val-page-wrapper` usa `<router-outlet>` plano — los hooks `ionView*` solo
|
|
51345
|
+
* disparan dentro de `<ion-router-outlet>`.
|
|
51346
|
+
*/
|
|
51347
|
+
ngOnInit() {
|
|
51348
|
+
this.pageRefresh.register(() => this.refreshBrowserState());
|
|
51349
|
+
}
|
|
51350
|
+
ngOnDestroy() {
|
|
51351
|
+
this.pageRefresh.unregister();
|
|
51352
|
+
if (typeof window !== 'undefined') {
|
|
51353
|
+
window.removeEventListener('focus', this.onWindowFocus);
|
|
51354
|
+
}
|
|
51355
|
+
if (this.focusDebounceTimer !== null) {
|
|
51356
|
+
clearTimeout(this.focusDebounceTimer);
|
|
51357
|
+
this.focusDebounceTimer = null;
|
|
51358
|
+
}
|
|
51359
|
+
}
|
|
51360
|
+
/** Refresca el estado del browser con debounce — evita ráfagas de `focus`. */
|
|
51361
|
+
scheduleBrowserRefresh() {
|
|
51362
|
+
if (this.focusDebounceTimer !== null) {
|
|
51363
|
+
clearTimeout(this.focusDebounceTimer);
|
|
51364
|
+
}
|
|
51365
|
+
this.focusDebounceTimer = setTimeout(() => {
|
|
51366
|
+
this.focusDebounceTimer = null;
|
|
51367
|
+
void this.refreshBrowserState();
|
|
51368
|
+
}, this.FOCUS_DEBOUNCE_MS);
|
|
51369
|
+
}
|
|
51370
|
+
// ---------------------------------------------------------------------------
|
|
51371
|
+
// Actions
|
|
51372
|
+
// ---------------------------------------------------------------------------
|
|
51373
|
+
/**
|
|
51374
|
+
* Secuencia completa de activación.
|
|
51375
|
+
*
|
|
51376
|
+
* Toda la mecánica (permission → SW ready → getToken con retry → watchdog de
|
|
51377
|
+
* auto-reload) vive en `MessagingService.enable()` de la lib. El paso de
|
|
51378
|
+
* registro del device en el backend se pasa como callback (`registerDevice`)
|
|
51379
|
+
* porque la lib no puede depender de `AuthService` sin crear un ciclo.
|
|
51380
|
+
*
|
|
51381
|
+
* `enable()` no hace throw: resuelve con un `EnablePushResult` descriptivo.
|
|
51382
|
+
*/
|
|
51383
|
+
async onEnable() {
|
|
51384
|
+
this._busy.set('enabling');
|
|
51385
|
+
this._lastFailed.set(false);
|
|
51386
|
+
try {
|
|
51387
|
+
const result = await this.messaging.enable({
|
|
51388
|
+
registerDevice: token => this.auth.registerDevice(token).then(r => r.registered),
|
|
51389
|
+
});
|
|
51390
|
+
if (result.status === 'enabled') {
|
|
51391
|
+
// Persistir flag master si aún no estaba activo.
|
|
51392
|
+
if (!this.prefs.notificationsMaster()) {
|
|
51393
|
+
await this.prefs.setNotificationsMaster(true);
|
|
51053
51394
|
}
|
|
51395
|
+
this.toast.show({
|
|
51396
|
+
message: this.tt('enableSuccess'),
|
|
51397
|
+
duration: 2000,
|
|
51398
|
+
color: 'dark',
|
|
51399
|
+
position: 'top',
|
|
51400
|
+
});
|
|
51401
|
+
this.resolvedConfig().onPushEnabled?.();
|
|
51402
|
+
}
|
|
51403
|
+
else if (result.status === 'timeout' && result.reloaded) {
|
|
51404
|
+
// La lib disparó el auto-reload — la página está por recargarse.
|
|
51405
|
+
return;
|
|
51054
51406
|
}
|
|
51055
51407
|
else {
|
|
51056
|
-
//
|
|
51057
|
-
|
|
51408
|
+
// denied / unsupported / error / timeout sin reload → fallo recuperable.
|
|
51409
|
+
this._lastFailed.set(true);
|
|
51410
|
+
this.toast.show({
|
|
51411
|
+
message: this.tt('errorGeneric'),
|
|
51412
|
+
duration: 3000,
|
|
51413
|
+
color: 'dark',
|
|
51414
|
+
position: 'top',
|
|
51415
|
+
});
|
|
51058
51416
|
}
|
|
51059
51417
|
}
|
|
51060
|
-
|
|
51061
|
-
|
|
51062
|
-
|
|
51063
|
-
|
|
51064
|
-
|
|
51065
|
-
|
|
51066
|
-
|
|
51067
|
-
|
|
51068
|
-
perms: platformPerms,
|
|
51418
|
+
catch {
|
|
51419
|
+
// `enable()` no hace throw, pero el callback de registro sí puede.
|
|
51420
|
+
this._lastFailed.set(true);
|
|
51421
|
+
this.toast.show({
|
|
51422
|
+
message: this.tt('errorGeneric'),
|
|
51423
|
+
duration: 3000,
|
|
51424
|
+
color: 'dark',
|
|
51425
|
+
position: 'top',
|
|
51069
51426
|
});
|
|
51070
51427
|
}
|
|
51071
|
-
|
|
51072
|
-
|
|
51073
|
-
|
|
51074
|
-
|
|
51075
|
-
|
|
51428
|
+
finally {
|
|
51429
|
+
// Único refresh final: re-alinea el toggle UNA sola vez con el estado real.
|
|
51430
|
+
await this.refreshBrowserState();
|
|
51431
|
+
this._busy.set(null);
|
|
51432
|
+
}
|
|
51433
|
+
}
|
|
51434
|
+
async onDisable() {
|
|
51435
|
+
this._busy.set('disabling');
|
|
51436
|
+
this._lastFailed.set(false);
|
|
51437
|
+
try {
|
|
51438
|
+
await this.auth.disableNotifications();
|
|
51439
|
+
if (this.prefs.notificationsMaster()) {
|
|
51440
|
+
await this.prefs.setNotificationsMaster(false);
|
|
51441
|
+
}
|
|
51442
|
+
this.toast.show({
|
|
51443
|
+
message: this.tt('disableSuccess'),
|
|
51444
|
+
duration: 2000,
|
|
51445
|
+
color: 'dark',
|
|
51446
|
+
position: 'top',
|
|
51076
51447
|
});
|
|
51448
|
+
this.resolvedConfig().onPushDisabled?.();
|
|
51449
|
+
}
|
|
51450
|
+
catch {
|
|
51451
|
+
this._lastFailed.set(true);
|
|
51452
|
+
}
|
|
51453
|
+
finally {
|
|
51454
|
+
await this.refreshBrowserState();
|
|
51455
|
+
this._busy.set(null);
|
|
51077
51456
|
}
|
|
51078
|
-
return {
|
|
51079
|
-
id: role.id,
|
|
51080
|
-
name: role.name,
|
|
51081
|
-
description: role.description,
|
|
51082
|
-
isSystem: role.isSystem,
|
|
51083
|
-
groups,
|
|
51084
|
-
hasPerms: groups.length > 0,
|
|
51085
|
-
};
|
|
51086
51457
|
}
|
|
51087
|
-
|
|
51088
|
-
|
|
51458
|
+
/** Reintentar la secuencia completa de activación, sin importar el estado actual. */
|
|
51459
|
+
async onRetry() {
|
|
51460
|
+
this._lastFailed.set(false);
|
|
51461
|
+
await this.onEnable();
|
|
51089
51462
|
}
|
|
51090
|
-
|
|
51091
|
-
|
|
51092
|
-
const label = this.tt(key);
|
|
51093
|
-
return label === key ? appId : label;
|
|
51463
|
+
toggleDetails() {
|
|
51464
|
+
this.showDetails.update(v => !v);
|
|
51094
51465
|
}
|
|
51095
|
-
|
|
51096
|
-
|
|
51097
|
-
|
|
51098
|
-
|
|
51099
|
-
|
|
51100
|
-
|
|
51101
|
-
|
|
51102
|
-
|
|
51103
|
-
bingo_taquilla: 'roleBingoTaquilla',
|
|
51104
|
-
bingo_operator: 'roleBingoOperator',
|
|
51105
|
-
};
|
|
51106
|
-
return this.tt(map[name] ?? name);
|
|
51466
|
+
// ---------------------------------------------------------------------------
|
|
51467
|
+
// Privados
|
|
51468
|
+
// ---------------------------------------------------------------------------
|
|
51469
|
+
async refreshBrowserState() {
|
|
51470
|
+
this.isSupported.set(await this.messaging.isSupported());
|
|
51471
|
+
this.permission.set(this.auth.getNotificationPermissionState());
|
|
51472
|
+
this.currentToken.set(this.messaging.currentToken ?? null);
|
|
51473
|
+
void this.repairTokenIfNeeded();
|
|
51107
51474
|
}
|
|
51108
|
-
|
|
51109
|
-
|
|
51110
|
-
|
|
51111
|
-
|
|
51112
|
-
|
|
51113
|
-
|
|
51114
|
-
|
|
51115
|
-
|
|
51475
|
+
/**
|
|
51476
|
+
* Repara el token FCM en background.
|
|
51477
|
+
*
|
|
51478
|
+
* Si el usuario tiene push activo (`master && granted`) pero no hay token en
|
|
51479
|
+
* memoria — caso típico tras un cold start de la PWA — re-obtenemos el token
|
|
51480
|
+
* y re-registramos el device sin tocar el toggle ni mostrar un error
|
|
51481
|
+
* alarmista. El token es un detalle de implementación reparable, no parte del
|
|
51482
|
+
* estado de UI (ver `isEnabled`).
|
|
51483
|
+
*/
|
|
51484
|
+
async repairTokenIfNeeded() {
|
|
51485
|
+
if (this._busy() !== null)
|
|
51486
|
+
return;
|
|
51487
|
+
if (this._repairingToken)
|
|
51488
|
+
return;
|
|
51489
|
+
if (!this.isEnabled())
|
|
51490
|
+
return;
|
|
51491
|
+
if (this.currentToken())
|
|
51492
|
+
return;
|
|
51493
|
+
this._repairingToken = true;
|
|
51494
|
+
try {
|
|
51495
|
+
const token = await this.messaging.getToken();
|
|
51496
|
+
if (token) {
|
|
51497
|
+
this.currentToken.set(token);
|
|
51498
|
+
await this.auth.registerDevice(token);
|
|
51499
|
+
}
|
|
51500
|
+
}
|
|
51501
|
+
catch {
|
|
51502
|
+
// Reparación best-effort: no degradamos el toggle ni mostramos error.
|
|
51503
|
+
}
|
|
51504
|
+
finally {
|
|
51505
|
+
this._repairingToken = false;
|
|
51506
|
+
}
|
|
51116
51507
|
}
|
|
51117
|
-
|
|
51118
|
-
|
|
51508
|
+
/**
|
|
51509
|
+
* iOS browser sin estar instalado como PWA — push web no disponible.
|
|
51510
|
+
* Aplica a Safari y a Edge/Chrome/Firefox en iOS (todos WebKit por mandato Apple).
|
|
51511
|
+
*/
|
|
51512
|
+
detectNeedsIOSInstall() {
|
|
51513
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
51514
|
+
return false;
|
|
51515
|
+
}
|
|
51516
|
+
const ua = navigator.userAgent || '';
|
|
51517
|
+
const isIOS = /iPad|iPhone|iPod/.test(ua);
|
|
51518
|
+
if (!isIOS)
|
|
51519
|
+
return false;
|
|
51520
|
+
const isStandalone = window.matchMedia?.('(display-mode: standalone)').matches ||
|
|
51521
|
+
navigator.standalone === true;
|
|
51522
|
+
return !isStandalone;
|
|
51523
|
+
}
|
|
51524
|
+
tt(key) {
|
|
51525
|
+
return this.i18n.t(key, this.ns);
|
|
51526
|
+
}
|
|
51527
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
51528
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationPreferencesViewComponent, isStandalone: true, selector: "val-notification-preferences-view", inputs: { config: "config" }, ngImport: i0, template: `
|
|
51119
51529
|
<div class="page">
|
|
51120
51530
|
<header class="page-header">
|
|
51121
|
-
<val-display [props]="{ size: 'small', color: 'dark', content:
|
|
51531
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
51122
51532
|
<val-title
|
|
51123
51533
|
[props]="{
|
|
51124
51534
|
size: 'large',
|
|
51125
51535
|
color: 'dark',
|
|
51126
51536
|
bold: false,
|
|
51127
|
-
content:
|
|
51537
|
+
content: pageDescription(),
|
|
51128
51538
|
}"
|
|
51129
51539
|
/>
|
|
51130
51540
|
</header>
|
|
51131
51541
|
|
|
51132
|
-
@if (
|
|
51133
|
-
<
|
|
51134
|
-
<
|
|
51135
|
-
</div>
|
|
51136
|
-
} @else if (loadError()) {
|
|
51137
|
-
<div class="error-row">
|
|
51138
|
-
<val-text
|
|
51542
|
+
@if (state() === 'needs-install') {
|
|
51543
|
+
<section class="settings-section">
|
|
51544
|
+
<val-title
|
|
51139
51545
|
[props]="{
|
|
51140
|
-
content: tt('errorTitle'),
|
|
51141
51546
|
size: 'medium',
|
|
51142
|
-
color: '
|
|
51143
|
-
bold:
|
|
51547
|
+
color: 'dark',
|
|
51548
|
+
bold: true,
|
|
51549
|
+
content: tt('iosInstallTitle'),
|
|
51144
51550
|
}"
|
|
51145
51551
|
/>
|
|
51146
|
-
<
|
|
51147
|
-
|
|
51148
|
-
|
|
51149
|
-
|
|
51150
|
-
|
|
51151
|
-
|
|
51152
|
-
|
|
51153
|
-
|
|
51154
|
-
|
|
51155
|
-
|
|
51156
|
-
|
|
51157
|
-
|
|
51158
|
-
|
|
51159
|
-
|
|
51160
|
-
|
|
51161
|
-
|
|
51162
|
-
|
|
51163
|
-
|
|
51164
|
-
color: 'medium',
|
|
51165
|
-
bold: false,
|
|
51166
|
-
}"
|
|
51167
|
-
/>
|
|
51168
|
-
</div>
|
|
51169
|
-
<div class="role-list">
|
|
51170
|
-
@for (role of systemRoles(); track role.id) {
|
|
51171
|
-
<div class="role-card">
|
|
51172
|
-
<div class="role-card__header">
|
|
51173
|
-
<span class="role-card__name">{{ roleLabel(role.name) }}</span>
|
|
51174
|
-
<val-pill
|
|
51175
|
-
[props]="{
|
|
51176
|
-
label: tt('systemBadge'),
|
|
51177
|
-
color: 'primary',
|
|
51178
|
-
weight: 'bold',
|
|
51179
|
-
}"
|
|
51180
|
-
/>
|
|
51181
|
-
</div>
|
|
51182
|
-
@if (role.description) {
|
|
51183
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51184
|
-
}
|
|
51185
|
-
@if (!role.hasPerms) {
|
|
51186
|
-
<div class="perm-chips">
|
|
51187
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51188
|
-
</div>
|
|
51189
|
-
} @else {
|
|
51190
|
-
@for (g of role.groups; track g.key) {
|
|
51191
|
-
<div class="perm-group">
|
|
51192
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51193
|
-
<div class="perm-chips">
|
|
51194
|
-
@for (p of g.perms; track p) {
|
|
51195
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51196
|
-
}
|
|
51197
|
-
</div>
|
|
51198
|
-
</div>
|
|
51199
|
-
}
|
|
51200
|
-
}
|
|
51201
|
-
</div>
|
|
51202
|
-
}
|
|
51203
|
-
</div>
|
|
51204
|
-
</section>
|
|
51205
|
-
}
|
|
51552
|
+
<div class="section-body">
|
|
51553
|
+
<val-text
|
|
51554
|
+
[props]="{
|
|
51555
|
+
size: 'small',
|
|
51556
|
+
color: 'medium',
|
|
51557
|
+
bold: false,
|
|
51558
|
+
content: tt('iosInstallHint'),
|
|
51559
|
+
}"
|
|
51560
|
+
/>
|
|
51561
|
+
<ol class="install-steps">
|
|
51562
|
+
<li>{{ tt('iosInstallStep1') }}</li>
|
|
51563
|
+
<li>{{ tt('iosInstallStep2') }}</li>
|
|
51564
|
+
<li>{{ tt('iosInstallStep3') }}</li>
|
|
51565
|
+
<li>{{ tt('iosInstallStep4') }}</li>
|
|
51566
|
+
</ol>
|
|
51567
|
+
</div>
|
|
51568
|
+
</section>
|
|
51569
|
+
}
|
|
51206
51570
|
|
|
51207
|
-
|
|
51208
|
-
|
|
51209
|
-
|
|
51210
|
-
|
|
51211
|
-
|
|
51212
|
-
|
|
51213
|
-
|
|
51214
|
-
|
|
51215
|
-
|
|
51216
|
-
|
|
51217
|
-
|
|
51218
|
-
|
|
51219
|
-
|
|
51220
|
-
|
|
51221
|
-
|
|
51222
|
-
|
|
51223
|
-
|
|
51224
|
-
|
|
51225
|
-
|
|
51226
|
-
|
|
51227
|
-
|
|
51228
|
-
|
|
51229
|
-
|
|
51230
|
-
|
|
51231
|
-
size: 'medium',
|
|
51232
|
-
color: 'medium',
|
|
51233
|
-
bold: false,
|
|
51234
|
-
}"
|
|
51235
|
-
/>
|
|
51236
|
-
} @else {
|
|
51237
|
-
<div class="role-list">
|
|
51238
|
-
@for (role of customRoles(); track role.id) {
|
|
51239
|
-
<div class="role-card">
|
|
51240
|
-
<div class="role-card__header">
|
|
51241
|
-
<span class="role-card__name">{{ role.name }}</span>
|
|
51242
|
-
</div>
|
|
51243
|
-
@if (role.description) {
|
|
51244
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51245
|
-
}
|
|
51246
|
-
@if (!role.hasPerms) {
|
|
51247
|
-
<div class="perm-chips">
|
|
51248
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51249
|
-
</div>
|
|
51250
|
-
} @else {
|
|
51251
|
-
@for (g of role.groups; track g.key) {
|
|
51252
|
-
<div class="perm-group">
|
|
51253
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51254
|
-
<div class="perm-chips">
|
|
51255
|
-
@for (p of g.perms; track p) {
|
|
51256
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51257
|
-
}
|
|
51258
|
-
</div>
|
|
51259
|
-
</div>
|
|
51260
|
-
}
|
|
51261
|
-
}
|
|
51262
|
-
</div>
|
|
51263
|
-
}
|
|
51264
|
-
</div>
|
|
51571
|
+
<section class="settings-section">
|
|
51572
|
+
<div class="notif-row">
|
|
51573
|
+
<div class="notif-row__icon">
|
|
51574
|
+
<ion-icon name="notifications-outline" aria-hidden="true" />
|
|
51575
|
+
</div>
|
|
51576
|
+
<div class="notif-row__body">
|
|
51577
|
+
<val-title
|
|
51578
|
+
[props]="{
|
|
51579
|
+
size: 'medium',
|
|
51580
|
+
color: 'dark',
|
|
51581
|
+
bold: true,
|
|
51582
|
+
content: tt('simpleTitle'),
|
|
51583
|
+
}"
|
|
51584
|
+
/>
|
|
51585
|
+
<val-text
|
|
51586
|
+
[props]="{
|
|
51587
|
+
size: 'small',
|
|
51588
|
+
color: 'medium',
|
|
51589
|
+
bold: false,
|
|
51590
|
+
content: tt('simpleDescription'),
|
|
51591
|
+
}"
|
|
51592
|
+
/>
|
|
51593
|
+
@if (busyLabel(); as label) {
|
|
51594
|
+
<span class="notif-row__busy">{{ label }}</span>
|
|
51265
51595
|
}
|
|
51266
|
-
</
|
|
51596
|
+
</div>
|
|
51597
|
+
<div class="notif-row__control">
|
|
51598
|
+
<val-toggle-input [props]="toggleProps()" />
|
|
51599
|
+
</div>
|
|
51600
|
+
</div>
|
|
51601
|
+
|
|
51602
|
+
@if (contextMessage(); as msg) {
|
|
51603
|
+
<div class="alert" [class]="'alert--' + alertVariant()">
|
|
51604
|
+
<ion-icon [name]="alertIcon()" aria-hidden="true" />
|
|
51605
|
+
<span>{{ msg }}</span>
|
|
51606
|
+
</div>
|
|
51607
|
+
}
|
|
51608
|
+
|
|
51609
|
+
@if (state() === 'error') {
|
|
51610
|
+
<div class="row-actions">
|
|
51611
|
+
<val-button [props]="retryButtonProps()" (click)="onRetry()" />
|
|
51612
|
+
</div>
|
|
51267
51613
|
}
|
|
51614
|
+
</section>
|
|
51615
|
+
|
|
51616
|
+
@if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
|
|
51617
|
+
<section class="settings-section">
|
|
51618
|
+
<button class="details-toggle" type="button" (click)="toggleDetails()">
|
|
51619
|
+
<span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
|
|
51620
|
+
<ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
|
|
51621
|
+
</button>
|
|
51622
|
+
@if (showDetails()) {
|
|
51623
|
+
<dl class="details">
|
|
51624
|
+
<div class="details__row">
|
|
51625
|
+
<dt>{{ tt('detailsToken') }}</dt>
|
|
51626
|
+
<dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
|
|
51627
|
+
</div>
|
|
51628
|
+
<div class="details__row">
|
|
51629
|
+
<dt>{{ tt('diagnosticsPermission') }}</dt>
|
|
51630
|
+
<dd>{{ permission() }}</dd>
|
|
51631
|
+
</div>
|
|
51632
|
+
<div class="details__row">
|
|
51633
|
+
<dt>{{ tt('diagnosticsSupported') }}</dt>
|
|
51634
|
+
<dd>{{ isSupported() ? '✓' : '✗' }}</dd>
|
|
51635
|
+
</div>
|
|
51636
|
+
</dl>
|
|
51637
|
+
}
|
|
51638
|
+
</section>
|
|
51268
51639
|
}
|
|
51269
51640
|
</div>
|
|
51270
|
-
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
51641
|
+
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: ToggleInputComponent, selector: "val-toggle-input", inputs: ["preset", "props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }] }); }
|
|
51271
51642
|
}
|
|
51272
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
51643
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, decorators: [{
|
|
51273
51644
|
type: Component,
|
|
51274
|
-
args: [{ selector: 'val-
|
|
51645
|
+
args: [{ selector: 'val-notification-preferences-view', standalone: true, imports: [IonIcon, ToggleInputComponent, ButtonComponent, DisplayComponent, TitleComponent, TextComponent], template: `
|
|
51275
51646
|
<div class="page">
|
|
51276
51647
|
<header class="page-header">
|
|
51277
|
-
<val-display [props]="{ size: 'small', color: 'dark', content:
|
|
51648
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
51278
51649
|
<val-title
|
|
51279
51650
|
[props]="{
|
|
51280
51651
|
size: 'large',
|
|
51281
51652
|
color: 'dark',
|
|
51282
51653
|
bold: false,
|
|
51283
|
-
content:
|
|
51654
|
+
content: pageDescription(),
|
|
51284
51655
|
}"
|
|
51285
51656
|
/>
|
|
51286
51657
|
</header>
|
|
51287
51658
|
|
|
51288
|
-
@if (
|
|
51289
|
-
<
|
|
51290
|
-
<
|
|
51291
|
-
</div>
|
|
51292
|
-
} @else if (loadError()) {
|
|
51293
|
-
<div class="error-row">
|
|
51294
|
-
<val-text
|
|
51659
|
+
@if (state() === 'needs-install') {
|
|
51660
|
+
<section class="settings-section">
|
|
51661
|
+
<val-title
|
|
51295
51662
|
[props]="{
|
|
51296
|
-
content: tt('errorTitle'),
|
|
51297
51663
|
size: 'medium',
|
|
51298
|
-
color: '
|
|
51299
|
-
bold:
|
|
51664
|
+
color: 'dark',
|
|
51665
|
+
bold: true,
|
|
51666
|
+
content: tt('iosInstallTitle'),
|
|
51300
51667
|
}"
|
|
51301
51668
|
/>
|
|
51302
|
-
<
|
|
51669
|
+
<div class="section-body">
|
|
51670
|
+
<val-text
|
|
51671
|
+
[props]="{
|
|
51672
|
+
size: 'small',
|
|
51673
|
+
color: 'medium',
|
|
51674
|
+
bold: false,
|
|
51675
|
+
content: tt('iosInstallHint'),
|
|
51676
|
+
}"
|
|
51677
|
+
/>
|
|
51678
|
+
<ol class="install-steps">
|
|
51679
|
+
<li>{{ tt('iosInstallStep1') }}</li>
|
|
51680
|
+
<li>{{ tt('iosInstallStep2') }}</li>
|
|
51681
|
+
<li>{{ tt('iosInstallStep3') }}</li>
|
|
51682
|
+
<li>{{ tt('iosInstallStep4') }}</li>
|
|
51683
|
+
</ol>
|
|
51684
|
+
</div>
|
|
51685
|
+
</section>
|
|
51686
|
+
}
|
|
51687
|
+
|
|
51688
|
+
<section class="settings-section">
|
|
51689
|
+
<div class="notif-row">
|
|
51690
|
+
<div class="notif-row__icon">
|
|
51691
|
+
<ion-icon name="notifications-outline" aria-hidden="true" />
|
|
51692
|
+
</div>
|
|
51693
|
+
<div class="notif-row__body">
|
|
51694
|
+
<val-title
|
|
51695
|
+
[props]="{
|
|
51696
|
+
size: 'medium',
|
|
51697
|
+
color: 'dark',
|
|
51698
|
+
bold: true,
|
|
51699
|
+
content: tt('simpleTitle'),
|
|
51700
|
+
}"
|
|
51701
|
+
/>
|
|
51702
|
+
<val-text
|
|
51703
|
+
[props]="{
|
|
51704
|
+
size: 'small',
|
|
51705
|
+
color: 'medium',
|
|
51706
|
+
bold: false,
|
|
51707
|
+
content: tt('simpleDescription'),
|
|
51708
|
+
}"
|
|
51709
|
+
/>
|
|
51710
|
+
@if (busyLabel(); as label) {
|
|
51711
|
+
<span class="notif-row__busy">{{ label }}</span>
|
|
51712
|
+
}
|
|
51713
|
+
</div>
|
|
51714
|
+
<div class="notif-row__control">
|
|
51715
|
+
<val-toggle-input [props]="toggleProps()" />
|
|
51716
|
+
</div>
|
|
51303
51717
|
</div>
|
|
51304
|
-
|
|
51305
|
-
@if (
|
|
51306
|
-
<
|
|
51307
|
-
<
|
|
51308
|
-
|
|
51309
|
-
|
|
51310
|
-
size: 'medium',
|
|
51311
|
-
color: 'dark',
|
|
51312
|
-
bold: true,
|
|
51313
|
-
content: tt('systemRolesTitle'),
|
|
51314
|
-
}"
|
|
51315
|
-
/>
|
|
51316
|
-
<val-text
|
|
51317
|
-
[props]="{
|
|
51318
|
-
content: tt('systemRolesHint'),
|
|
51319
|
-
size: 'medium',
|
|
51320
|
-
color: 'medium',
|
|
51321
|
-
bold: false,
|
|
51322
|
-
}"
|
|
51323
|
-
/>
|
|
51324
|
-
</div>
|
|
51325
|
-
<div class="role-list">
|
|
51326
|
-
@for (role of systemRoles(); track role.id) {
|
|
51327
|
-
<div class="role-card">
|
|
51328
|
-
<div class="role-card__header">
|
|
51329
|
-
<span class="role-card__name">{{ roleLabel(role.name) }}</span>
|
|
51330
|
-
<val-pill
|
|
51331
|
-
[props]="{
|
|
51332
|
-
label: tt('systemBadge'),
|
|
51333
|
-
color: 'primary',
|
|
51334
|
-
weight: 'bold',
|
|
51335
|
-
}"
|
|
51336
|
-
/>
|
|
51337
|
-
</div>
|
|
51338
|
-
@if (role.description) {
|
|
51339
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51340
|
-
}
|
|
51341
|
-
@if (!role.hasPerms) {
|
|
51342
|
-
<div class="perm-chips">
|
|
51343
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51344
|
-
</div>
|
|
51345
|
-
} @else {
|
|
51346
|
-
@for (g of role.groups; track g.key) {
|
|
51347
|
-
<div class="perm-group">
|
|
51348
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51349
|
-
<div class="perm-chips">
|
|
51350
|
-
@for (p of g.perms; track p) {
|
|
51351
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51352
|
-
}
|
|
51353
|
-
</div>
|
|
51354
|
-
</div>
|
|
51355
|
-
}
|
|
51356
|
-
}
|
|
51357
|
-
</div>
|
|
51358
|
-
}
|
|
51359
|
-
</div>
|
|
51360
|
-
</section>
|
|
51718
|
+
|
|
51719
|
+
@if (contextMessage(); as msg) {
|
|
51720
|
+
<div class="alert" [class]="'alert--' + alertVariant()">
|
|
51721
|
+
<ion-icon [name]="alertIcon()" aria-hidden="true" />
|
|
51722
|
+
<span>{{ msg }}</span>
|
|
51723
|
+
</div>
|
|
51361
51724
|
}
|
|
51362
51725
|
|
|
51363
|
-
@if (
|
|
51364
|
-
<
|
|
51365
|
-
<
|
|
51366
|
-
|
|
51367
|
-
[props]="{
|
|
51368
|
-
size: 'medium',
|
|
51369
|
-
color: 'dark',
|
|
51370
|
-
bold: true,
|
|
51371
|
-
content: tt('customRolesTitle'),
|
|
51372
|
-
}"
|
|
51373
|
-
/>
|
|
51374
|
-
<val-text
|
|
51375
|
-
[props]="{
|
|
51376
|
-
content: tt('customRolesHint'),
|
|
51377
|
-
size: 'medium',
|
|
51378
|
-
color: 'medium',
|
|
51379
|
-
bold: false,
|
|
51380
|
-
}"
|
|
51381
|
-
/>
|
|
51382
|
-
</div>
|
|
51383
|
-
@if (customRoles().length === 0) {
|
|
51384
|
-
<val-text
|
|
51385
|
-
[props]="{
|
|
51386
|
-
content: tt('noCustomRoles'),
|
|
51387
|
-
size: 'medium',
|
|
51388
|
-
color: 'medium',
|
|
51389
|
-
bold: false,
|
|
51390
|
-
}"
|
|
51391
|
-
/>
|
|
51392
|
-
} @else {
|
|
51393
|
-
<div class="role-list">
|
|
51394
|
-
@for (role of customRoles(); track role.id) {
|
|
51395
|
-
<div class="role-card">
|
|
51396
|
-
<div class="role-card__header">
|
|
51397
|
-
<span class="role-card__name">{{ role.name }}</span>
|
|
51398
|
-
</div>
|
|
51399
|
-
@if (role.description) {
|
|
51400
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51401
|
-
}
|
|
51402
|
-
@if (!role.hasPerms) {
|
|
51403
|
-
<div class="perm-chips">
|
|
51404
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51405
|
-
</div>
|
|
51406
|
-
} @else {
|
|
51407
|
-
@for (g of role.groups; track g.key) {
|
|
51408
|
-
<div class="perm-group">
|
|
51409
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51410
|
-
<div class="perm-chips">
|
|
51411
|
-
@for (p of g.perms; track p) {
|
|
51412
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51413
|
-
}
|
|
51414
|
-
</div>
|
|
51415
|
-
</div>
|
|
51416
|
-
}
|
|
51417
|
-
}
|
|
51418
|
-
</div>
|
|
51419
|
-
}
|
|
51420
|
-
</div>
|
|
51421
|
-
}
|
|
51422
|
-
</section>
|
|
51726
|
+
@if (state() === 'error') {
|
|
51727
|
+
<div class="row-actions">
|
|
51728
|
+
<val-button [props]="retryButtonProps()" (click)="onRetry()" />
|
|
51729
|
+
</div>
|
|
51423
51730
|
}
|
|
51731
|
+
</section>
|
|
51732
|
+
|
|
51733
|
+
@if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
|
|
51734
|
+
<section class="settings-section">
|
|
51735
|
+
<button class="details-toggle" type="button" (click)="toggleDetails()">
|
|
51736
|
+
<span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
|
|
51737
|
+
<ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
|
|
51738
|
+
</button>
|
|
51739
|
+
@if (showDetails()) {
|
|
51740
|
+
<dl class="details">
|
|
51741
|
+
<div class="details__row">
|
|
51742
|
+
<dt>{{ tt('detailsToken') }}</dt>
|
|
51743
|
+
<dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
|
|
51744
|
+
</div>
|
|
51745
|
+
<div class="details__row">
|
|
51746
|
+
<dt>{{ tt('diagnosticsPermission') }}</dt>
|
|
51747
|
+
<dd>{{ permission() }}</dd>
|
|
51748
|
+
</div>
|
|
51749
|
+
<div class="details__row">
|
|
51750
|
+
<dt>{{ tt('diagnosticsSupported') }}</dt>
|
|
51751
|
+
<dd>{{ isSupported() ? '✓' : '✗' }}</dd>
|
|
51752
|
+
</div>
|
|
51753
|
+
</dl>
|
|
51754
|
+
}
|
|
51755
|
+
</section>
|
|
51424
51756
|
}
|
|
51425
51757
|
</div>
|
|
51426
|
-
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
51758
|
+
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"] }]
|
|
51427
51759
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
51428
51760
|
type: Input
|
|
51429
51761
|
}] } });
|
|
51430
51762
|
|
|
51431
51763
|
/**
|
|
51432
|
-
* Helper para montar
|
|
51433
|
-
*
|
|
51764
|
+
* Helper para montar las **preferencias de notificaciones push**
|
|
51765
|
+
* (`val-notification-preferences-view`) como ruta en una app del factory. El
|
|
51766
|
+
* `config` se pasa por route `data` (`notificationPreferencesConfig`) y el
|
|
51434
51767
|
* componente lo lee como fallback de su `@Input() config`.
|
|
51435
51768
|
*
|
|
51769
|
+
* No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
|
|
51770
|
+
*
|
|
51436
51771
|
* @example
|
|
51437
51772
|
* ```ts
|
|
51438
|
-
* //
|
|
51773
|
+
* // app.routes.ts (típicamente bajo settings)
|
|
51439
51774
|
* export const settingsRoutes: Routes = [
|
|
51440
|
-
* ...
|
|
51441
|
-
* { path: 'preferences', loadComponent: () => ... },
|
|
51775
|
+
* ...provideValtechNotificationPreferencesRoutes(), // → notifications
|
|
51442
51776
|
* ];
|
|
51443
51777
|
*
|
|
51444
51778
|
* // con config acotada:
|
|
51445
|
-
* ...
|
|
51446
|
-
* ...
|
|
51779
|
+
* ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
|
|
51780
|
+
* ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
|
|
51447
51781
|
* ```
|
|
51448
51782
|
*/
|
|
51449
|
-
function
|
|
51783
|
+
function provideValtechNotificationPreferencesRoutes(opts) {
|
|
51450
51784
|
return [
|
|
51451
51785
|
{
|
|
51452
|
-
path: opts?.path ?? '
|
|
51453
|
-
component:
|
|
51454
|
-
data: {
|
|
51786
|
+
path: opts?.path ?? 'notifications',
|
|
51787
|
+
component: NotificationPreferencesViewComponent,
|
|
51788
|
+
data: { notificationPreferencesConfig: opts?.config },
|
|
51455
51789
|
},
|
|
51456
51790
|
];
|
|
51457
51791
|
}
|
|
51458
51792
|
|
|
51459
51793
|
/**
|
|
51460
|
-
*
|
|
51461
|
-
*
|
|
51462
|
-
*
|
|
51463
|
-
*
|
|
51464
|
-
* apagar una sección; `hubPath`/`indexRedirectTo`/`*Path` permiten reubicar el hub
|
|
51465
|
-
* y las vistas para preservar URLs existentes.
|
|
51466
|
-
*
|
|
51467
|
-
* showcase lo consume (ver `settings.routes.ts` de showcase): hub en `preferences`,
|
|
51468
|
-
* preferences-view en `general`, index→redirect, `organization/new` por `extraRoutes`.
|
|
51794
|
+
* Consume el catálogo de permisos del backend (`GET /org/{orgId}/permissions-catalog`).
|
|
51795
|
+
* El backend es la fuente de verdad de qué permisos existen y de su alcance
|
|
51796
|
+
* (globales de plataforma vs. propios de una app). La respuesta incluye el
|
|
51797
|
+
* `appId` del request para que la vista distinga la app actual.
|
|
51469
51798
|
*
|
|
51470
|
-
*
|
|
51471
|
-
*
|
|
51472
|
-
*
|
|
51473
|
-
|
|
51799
|
+
* Promovido desde `showcase` a la lib bajo el proceso de ADR-021. Usa
|
|
51800
|
+
* `VALTECH_AUTH_CONFIG.apiUrl` (como `OrgService`) en vez del `environment` de la
|
|
51801
|
+
* app — la lib no importa el environment del consumer.
|
|
51802
|
+
*/
|
|
51803
|
+
class PermissionCatalogService {
|
|
51804
|
+
constructor(config, http) {
|
|
51805
|
+
this.config = config;
|
|
51806
|
+
this.http = http;
|
|
51807
|
+
}
|
|
51808
|
+
getCatalog(orgId) {
|
|
51809
|
+
return this.http.get(`${this.config.apiUrl}/org/${orgId}/permissions-catalog`);
|
|
51810
|
+
}
|
|
51811
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$3.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
51812
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, providedIn: 'root' }); }
|
|
51813
|
+
}
|
|
51814
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, decorators: [{
|
|
51815
|
+
type: Injectable,
|
|
51816
|
+
args: [{ providedIn: 'root' }]
|
|
51817
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
51818
|
+
type: Inject,
|
|
51819
|
+
args: [VALTECH_AUTH_CONFIG]
|
|
51820
|
+
}] }, { type: i1$3.HttpClient }] });
|
|
51821
|
+
|
|
51822
|
+
/**
|
|
51823
|
+
* Defaults i18n (es/en) embebidos en `val-permissions-view`. Auto-registrados en
|
|
51824
|
+
* el constructor del componente si el consumer no proveyó el namespace
|
|
51825
|
+
* (`Settings.Permissions` por default). SOLO incluye las keys que la vista usa.
|
|
51474
51826
|
*
|
|
51475
|
-
*
|
|
51476
|
-
* {
|
|
51477
|
-
* path: 'app',
|
|
51478
|
-
* children: [
|
|
51479
|
-
* ...provideValtechSettingsRoutes({
|
|
51480
|
-
* hubConfig: { onLogout: () => auth.logoutWithConfirmation() },
|
|
51481
|
-
* extraRoutes: [
|
|
51482
|
-
* { path: 'security', loadComponent: () => ... },
|
|
51483
|
-
* ],
|
|
51484
|
-
* }),
|
|
51485
|
-
* ],
|
|
51486
|
-
* },
|
|
51487
|
-
* ];
|
|
51488
|
-
* ```
|
|
51827
|
+
* Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
|
|
51489
51828
|
*/
|
|
51490
|
-
|
|
51491
|
-
const basePath = opts?.basePath ?? 'settings';
|
|
51492
|
-
const hubPath = opts?.hubPath ?? '';
|
|
51493
|
-
const includeProfile = opts?.includeProfile ?? true;
|
|
51494
|
-
const includePreferences = opts?.includePreferences ?? true;
|
|
51495
|
-
const includeSecurity = opts?.includeSecurity ?? true;
|
|
51496
|
-
const includeAccount = opts?.includeAccount ?? true;
|
|
51497
|
-
const includeOrganization = opts?.includeOrganization ?? true;
|
|
51498
|
-
const includeNotificationPreferences = opts?.includeNotificationPreferences ?? true;
|
|
51499
|
-
const includePermissions = opts?.includePermissions ?? true;
|
|
51500
|
-
return [
|
|
51501
|
-
{
|
|
51502
|
-
path: basePath,
|
|
51503
|
-
children: [
|
|
51504
|
-
...(opts?.indexRedirectTo
|
|
51505
|
-
? [{ path: '', redirectTo: opts.indexRedirectTo, pathMatch: 'full' }]
|
|
51506
|
-
: []),
|
|
51507
|
-
{
|
|
51508
|
-
path: hubPath,
|
|
51509
|
-
component: SettingsHubComponent,
|
|
51510
|
-
data: { settingsHubConfig: opts?.hubConfig },
|
|
51511
|
-
},
|
|
51512
|
-
...(includeProfile ? provideValtechProfileRoutes({ config: opts?.profileConfig }) : []),
|
|
51513
|
-
...(includePreferences
|
|
51514
|
-
? provideValtechPreferencesRoutes({
|
|
51515
|
-
path: opts?.preferencesPath ?? 'preferences',
|
|
51516
|
-
config: opts?.preferencesConfig,
|
|
51517
|
-
})
|
|
51518
|
-
: []),
|
|
51519
|
-
...(includeSecurity ? provideValtechSecurityRoutes({ config: opts?.securityConfig }) : []),
|
|
51520
|
-
...(includeAccount ? provideValtechAccountRoutes({ config: opts?.accountConfig }) : []),
|
|
51521
|
-
...(includeOrganization
|
|
51522
|
-
? provideValtechOrganizationRoutes({ config: opts?.organizationConfig })
|
|
51523
|
-
: []),
|
|
51524
|
-
...(includeNotificationPreferences
|
|
51525
|
-
? provideValtechNotificationPreferencesRoutes({
|
|
51526
|
-
path: opts?.notificationPreferencesPath ?? 'notifications',
|
|
51527
|
-
config: opts?.notificationPreferencesConfig,
|
|
51528
|
-
})
|
|
51529
|
-
: []),
|
|
51530
|
-
...(includePermissions
|
|
51531
|
-
? provideValtechPermissionsRoutes({ config: opts?.permissionsConfig })
|
|
51532
|
-
: []),
|
|
51533
|
-
...(opts?.extraRoutes ?? []),
|
|
51534
|
-
],
|
|
51535
|
-
},
|
|
51536
|
-
];
|
|
51537
|
-
}
|
|
51538
|
-
|
|
51539
|
-
/**
|
|
51540
|
-
* Refreshable Stream
|
|
51541
|
-
*
|
|
51542
|
-
* Helper estándar para vistas que consumen datos de Firestore. Soporta DOS
|
|
51543
|
-
* modos — el caller decide cuál vía la `factory`:
|
|
51544
|
-
*
|
|
51545
|
-
* - **one-shot** (recomendado por defecto): `() => from(svc.getAllOnce())`.
|
|
51546
|
-
* Carga una vez y completa. Más barato — sin listener vivo.
|
|
51547
|
-
* - **real-time**: `() => svc.getAll()`. Listener Firestore que auto-actualiza.
|
|
51548
|
-
* Solo donde importa (inbox de notificaciones, badges).
|
|
51549
|
-
*
|
|
51550
|
-
* En ambos modos la primera suscripción se gatea por `firebaseAuthReady`
|
|
51551
|
-
* (sesión de Firebase Auth lista) — así el listener/lectura nunca se adjunta
|
|
51552
|
-
* antes de que `request.auth` esté disponible en las reglas de Firestore, lo
|
|
51553
|
-
* que cierra la ventana de `permission-denied` en cold start de PWA.
|
|
51554
|
-
*
|
|
51555
|
-
* El `retry` con backoff corto es solo red de seguridad para reconexiones
|
|
51556
|
-
* transitorias (no para cubrir cold start — eso lo cubre el gate de auth).
|
|
51557
|
-
*/
|
|
51558
|
-
/**
|
|
51559
|
-
* Crea un stream refrescable a partir de una factory de Observable.
|
|
51560
|
-
*
|
|
51561
|
-
* DEBE llamarse en un injection context (field initializer o constructor) —
|
|
51562
|
-
* usa `toSignal`/`toObservable`/`inject` internamente.
|
|
51563
|
-
*
|
|
51564
|
-
* @param factory - Función que produce el Observable a consumir. El caller
|
|
51565
|
-
* decide el modo: `() => from(svc.getAllOnce())` (one-shot) o
|
|
51566
|
-
* `() => svc.getAll()` (real-time).
|
|
51567
|
-
* @param options - Configuración opcional (fallback, retry, gate).
|
|
51568
|
-
*
|
|
51569
|
-
* @example
|
|
51570
|
-
* ```typescript
|
|
51571
|
-
* // one-shot (recomendado por defecto)
|
|
51572
|
-
* private readonly stream = createRefreshableStream(
|
|
51573
|
-
* () => from(this.svc.getAllOnce()),
|
|
51574
|
-
* );
|
|
51575
|
-
*
|
|
51576
|
-
* // real-time (inbox)
|
|
51577
|
-
* private readonly stream = createRefreshableStream(
|
|
51578
|
-
* () => this.notifs.getAll(50),
|
|
51579
|
-
* );
|
|
51580
|
-
*
|
|
51581
|
-
* readonly items = this.stream.data;
|
|
51582
|
-
* readonly loading = this.stream.loading;
|
|
51583
|
-
*
|
|
51584
|
-
* ionViewWillEnter() {
|
|
51585
|
-
* this.pageRefresh.register(() => this.stream.reload());
|
|
51586
|
-
* }
|
|
51587
|
-
* ```
|
|
51588
|
-
*/
|
|
51589
|
-
function createRefreshableStream(factory, options) {
|
|
51590
|
-
const injector = options?.injector ?? inject(Injector);
|
|
51591
|
-
const fallback = (options?.fallback ?? null);
|
|
51592
|
-
const retryCount = options?.retryCount ?? 4;
|
|
51593
|
-
const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
|
|
51594
|
-
const gateOnFirebaseAuth = options?.gateOnFirebaseAuth ?? true;
|
|
51595
|
-
const authGateTimeoutMs = options?.authGateTimeoutMs ?? 20000;
|
|
51596
|
-
const firstEmitTimeoutMs = options?.firstEmitTimeoutMs ?? 20000;
|
|
51597
|
-
// FirebaseService es opcional — sin él (apps sin Firebase) no se gatea.
|
|
51598
|
-
const firebase = gateOnFirebaseAuth ? injector.get(FirebaseService, null) : null;
|
|
51599
|
-
const _reload = signal(0);
|
|
51600
|
-
const _loading = signal(true);
|
|
51601
|
-
const _error = signal(false);
|
|
51602
|
-
// Gate de Firebase Auth: el stream no abre la primera suscripción hasta que
|
|
51603
|
-
// la sesión esté confirmada. `race` con un `timer` de seguridad — si la
|
|
51604
|
-
// sesión NUNCA se establece (token inválido, signInWithFirebase falló), el
|
|
51605
|
-
// gate igual se libera tras `authGateTimeoutMs` y la factory procede: si no
|
|
51606
|
-
// hay permiso terminará en estado `error`, jamás en skeleton infinito.
|
|
51607
|
-
// Tag los dos inputs del `race` para distinguir en logs cuál ganó: la sesión
|
|
51608
|
-
// de Firebase Auth (`auth-ready`) o el timeout de seguridad (`timeout`).
|
|
51609
|
-
const authGate$ = firebase
|
|
51610
|
-
? race(firebase.firebaseAuthReady$.pipe(map$1(() => 'auth-ready')), timer(authGateTimeoutMs).pipe(map$1(() => 'timeout'))).pipe(take(1), tap(winner => {
|
|
51611
|
-
if (winner === 'timeout') {
|
|
51612
|
-
console.warn(`[RefreshStream] auth gate resolved by TIMEOUT after ${authGateTimeoutMs}ms ` +
|
|
51613
|
-
'(Firebase Auth session never confirmed)');
|
|
51614
|
-
}
|
|
51615
|
-
else {
|
|
51616
|
-
console.log('[RefreshStream] auth gate resolved by firebaseAuthReady$ (session ready)');
|
|
51617
|
-
}
|
|
51618
|
-
}))
|
|
51619
|
-
: of(true);
|
|
51620
|
-
const data = toSignal(toObservable(_reload, { injector }).pipe(tap(() => {
|
|
51621
|
-
_loading.set(true);
|
|
51622
|
-
_error.set(false);
|
|
51623
|
-
}),
|
|
51624
|
-
// Esperar a Firebase Auth listo antes de cada (re)suscripción. Como
|
|
51625
|
-
// `firebaseAuthReady$` completa tras la primera emisión, las reloads
|
|
51626
|
-
// posteriores resuelven el gate de inmediato (auth ya está listo).
|
|
51627
|
-
switchMap(() => authGate$.pipe(switchMap(() => factory().pipe(tap(() => {
|
|
51628
|
-
_loading.set(false);
|
|
51629
|
-
_error.set(false);
|
|
51630
|
-
console.log('[RefreshStream] factory emitted first value — loading=false');
|
|
51631
|
-
}),
|
|
51632
|
-
// Backoff corto exponencial acotado — solo red de seguridad para
|
|
51633
|
-
// reconexiones transitorias, NO para cubrir cold start.
|
|
51634
|
-
retry({
|
|
51635
|
-
count: retryCount,
|
|
51636
|
-
delay: (_err, attempt) => timer(Math.min(retryBaseDelayMs * 2 ** (attempt - 1), 4000)),
|
|
51637
|
-
}),
|
|
51638
|
-
// Techo absoluto para la PRIMERA emisión. Si la factory no emite
|
|
51639
|
-
// ni valor ni error (servicio nunca inicializado → `EMPTY` →
|
|
51640
|
-
// silencio), `timeout` dispara un error → `catchError` → estado
|
|
51641
|
-
// `error`. Nunca skeleton perpetuo. Va DESPUÉS de `retry` para
|
|
51642
|
-
// que cuente el tiempo total, no por intento.
|
|
51643
|
-
timeout({ first: firstEmitTimeoutMs }), catchError(err => {
|
|
51644
|
-
_loading.set(false);
|
|
51645
|
-
_error.set(true);
|
|
51646
|
-
console.error(`[RefreshStream] factory failed/timed out → error state: ${err instanceof Error ? err.message : String(err)}`);
|
|
51647
|
-
return of(fallback);
|
|
51648
|
-
})))))), { initialValue: null, injector });
|
|
51649
|
-
return {
|
|
51650
|
-
data,
|
|
51651
|
-
loading: _loading.asReadonly(),
|
|
51652
|
-
error: _error.asReadonly(),
|
|
51653
|
-
reload: () => _reload.update(n => n + 1),
|
|
51654
|
-
};
|
|
51655
|
-
}
|
|
51656
|
-
|
|
51657
|
-
/**
|
|
51658
|
-
* Defaults i18n (es/en) embebidos en `val-notifications-view`. Auto-registrados
|
|
51659
|
-
* en el constructor del componente si el consumer no proveyó el namespace
|
|
51660
|
-
* (`Notifications` por default). SOLO incluye las keys que la vista usa.
|
|
51661
|
-
*
|
|
51662
|
-
* Portado del override `Notifications` (showcase) bajo el proceso de ADR-021.
|
|
51663
|
-
*/
|
|
51664
|
-
const NOTIFICATIONS_VIEW_I18N = {
|
|
51829
|
+
const PERMISSIONS_VIEW_I18N = {
|
|
51665
51830
|
es: {
|
|
51666
|
-
pageTitle: '
|
|
51667
|
-
pageDescription: '
|
|
51668
|
-
|
|
51669
|
-
|
|
51670
|
-
|
|
51671
|
-
|
|
51672
|
-
|
|
51673
|
-
|
|
51674
|
-
|
|
51675
|
-
|
|
51676
|
-
|
|
51677
|
-
|
|
51678
|
-
|
|
51679
|
-
|
|
51680
|
-
|
|
51681
|
-
|
|
51682
|
-
|
|
51683
|
-
|
|
51684
|
-
|
|
51685
|
-
|
|
51686
|
-
|
|
51687
|
-
|
|
51688
|
-
|
|
51689
|
-
|
|
51690
|
-
|
|
51691
|
-
|
|
51692
|
-
|
|
51693
|
-
|
|
51694
|
-
|
|
51695
|
-
|
|
51696
|
-
|
|
51831
|
+
pageTitle: 'Permisos',
|
|
51832
|
+
pageDescription: 'Roles y permisos de esta app en la organización activa',
|
|
51833
|
+
systemRolesTitle: 'Roles del sistema',
|
|
51834
|
+
systemRolesHint: 'Predefinidos por la plataforma. No editables.',
|
|
51835
|
+
customRolesTitle: 'Roles personalizados',
|
|
51836
|
+
customRolesHint: 'Definidos por tu organización.',
|
|
51837
|
+
systemBadge: 'Sistema',
|
|
51838
|
+
noCustomRoles: 'Esta organización no tiene roles personalizados.',
|
|
51839
|
+
noPermissions: 'Sin permisos asignados.',
|
|
51840
|
+
// Grupos de alcance
|
|
51841
|
+
groupApp: 'Permisos de la app',
|
|
51842
|
+
groupOrg: 'Permisos de la organización',
|
|
51843
|
+
groupPlatform: 'Permisos de plataforma',
|
|
51844
|
+
groupOther: 'Otras apps',
|
|
51845
|
+
// Roles del sistema
|
|
51846
|
+
roleViewer: 'Visor',
|
|
51847
|
+
roleEditor: 'Editor',
|
|
51848
|
+
roleAdmin: 'Administrador',
|
|
51849
|
+
roleSuperAdmin: 'Super Administrador',
|
|
51850
|
+
roleBingoAdmin: 'Bingo · Administrador',
|
|
51851
|
+
roleBingoTesorero: 'Bingo · Tesorero',
|
|
51852
|
+
roleBingoTaquilla: 'Bingo · Taquilla',
|
|
51853
|
+
roleBingoOperator: 'Bingo · Operador',
|
|
51854
|
+
// Apps
|
|
51855
|
+
app_showcase: 'Showcase',
|
|
51856
|
+
app_bingo: 'Bingo',
|
|
51857
|
+
// Recursos
|
|
51858
|
+
permUsers: 'Usuarios',
|
|
51859
|
+
permRoles: 'Roles',
|
|
51860
|
+
permRbac: 'Control de acceso',
|
|
51861
|
+
permApps: 'Apps',
|
|
51862
|
+
'permApi-keys': 'API keys',
|
|
51863
|
+
'permEmail-logs': 'Logs de correo',
|
|
51864
|
+
'permSms-logs': 'Logs de SMS',
|
|
51865
|
+
permDocuments: 'Documentos',
|
|
51866
|
+
permMedia: 'Media',
|
|
51867
|
+
permTemplates: 'Plantillas',
|
|
51868
|
+
permRequests: 'Solicitudes',
|
|
51869
|
+
permBingo: 'Bingo',
|
|
51870
|
+
// Acciones
|
|
51871
|
+
permRead: 'Leer',
|
|
51872
|
+
permWrite: 'Escribir',
|
|
51873
|
+
permCreate: 'Crear',
|
|
51874
|
+
permDelete: 'Eliminar',
|
|
51875
|
+
permManage: 'Gestionar',
|
|
51876
|
+
permEdit: 'Editar',
|
|
51877
|
+
permSell: 'Vender',
|
|
51878
|
+
permCheckin: 'Canjear',
|
|
51879
|
+
permSettle: 'Rendir',
|
|
51880
|
+
permExport: 'Exportar',
|
|
51881
|
+
permOperate: 'Operar',
|
|
51882
|
+
permAll: 'Todo',
|
|
51883
|
+
// Loading / error
|
|
51884
|
+
errorTitle: 'Error al cargar permisos',
|
|
51885
|
+
retry: 'Reintentar',
|
|
51697
51886
|
},
|
|
51698
51887
|
en: {
|
|
51699
|
-
pageTitle: '
|
|
51700
|
-
pageDescription: '
|
|
51701
|
-
|
|
51702
|
-
|
|
51703
|
-
|
|
51704
|
-
|
|
51705
|
-
|
|
51706
|
-
|
|
51707
|
-
|
|
51708
|
-
|
|
51709
|
-
|
|
51710
|
-
|
|
51711
|
-
|
|
51712
|
-
|
|
51713
|
-
|
|
51714
|
-
|
|
51715
|
-
|
|
51716
|
-
|
|
51717
|
-
|
|
51718
|
-
|
|
51719
|
-
|
|
51720
|
-
|
|
51721
|
-
|
|
51722
|
-
|
|
51723
|
-
|
|
51724
|
-
|
|
51725
|
-
|
|
51726
|
-
|
|
51727
|
-
|
|
51728
|
-
|
|
51729
|
-
|
|
51888
|
+
pageTitle: 'Permissions',
|
|
51889
|
+
pageDescription: 'Roles and permissions of this app in the active organization',
|
|
51890
|
+
systemRolesTitle: 'System roles',
|
|
51891
|
+
systemRolesHint: 'Defined by the platform. Not editable.',
|
|
51892
|
+
customRolesTitle: 'Custom roles',
|
|
51893
|
+
customRolesHint: 'Defined by your organization.',
|
|
51894
|
+
systemBadge: 'System',
|
|
51895
|
+
noCustomRoles: 'This organization has no custom roles.',
|
|
51896
|
+
noPermissions: 'No permissions assigned.',
|
|
51897
|
+
groupApp: 'App permissions',
|
|
51898
|
+
groupOrg: 'Organization permissions',
|
|
51899
|
+
groupPlatform: 'Platform permissions',
|
|
51900
|
+
groupOther: 'Other apps',
|
|
51901
|
+
roleViewer: 'Viewer',
|
|
51902
|
+
roleEditor: 'Editor',
|
|
51903
|
+
roleAdmin: 'Administrator',
|
|
51904
|
+
roleSuperAdmin: 'Super Administrator',
|
|
51905
|
+
roleBingoAdmin: 'Bingo · Administrator',
|
|
51906
|
+
roleBingoTesorero: 'Bingo · Treasurer',
|
|
51907
|
+
roleBingoTaquilla: 'Bingo · Box office',
|
|
51908
|
+
roleBingoOperator: 'Bingo · Operator',
|
|
51909
|
+
app_showcase: 'Showcase',
|
|
51910
|
+
app_bingo: 'Bingo',
|
|
51911
|
+
permUsers: 'Users',
|
|
51912
|
+
permRoles: 'Roles',
|
|
51913
|
+
permRbac: 'Access control',
|
|
51914
|
+
permApps: 'Apps',
|
|
51915
|
+
'permApi-keys': 'API keys',
|
|
51916
|
+
'permEmail-logs': 'Email logs',
|
|
51917
|
+
'permSms-logs': 'SMS logs',
|
|
51918
|
+
permDocuments: 'Documents',
|
|
51919
|
+
permMedia: 'Media',
|
|
51920
|
+
permTemplates: 'Templates',
|
|
51921
|
+
permRequests: 'Requests',
|
|
51922
|
+
permBingo: 'Bingo',
|
|
51923
|
+
permRead: 'Read',
|
|
51924
|
+
permWrite: 'Write',
|
|
51925
|
+
permCreate: 'Create',
|
|
51926
|
+
permDelete: 'Delete',
|
|
51927
|
+
permManage: 'Manage',
|
|
51928
|
+
permEdit: 'Edit',
|
|
51929
|
+
permSell: 'Sell',
|
|
51930
|
+
permCheckin: 'Check-in',
|
|
51931
|
+
permSettle: 'Settle',
|
|
51932
|
+
permExport: 'Export',
|
|
51933
|
+
permOperate: 'Operate',
|
|
51934
|
+
permAll: 'All',
|
|
51935
|
+
errorTitle: 'Error loading permissions',
|
|
51936
|
+
retry: 'Retry',
|
|
51730
51937
|
},
|
|
51731
51938
|
};
|
|
51732
51939
|
|
|
51733
|
-
|
|
51734
|
-
alarmOutline,
|
|
51735
|
-
chatboxEllipsesOutline,
|
|
51736
|
-
chatbubbleOutline,
|
|
51737
|
-
checkmarkCircleOutline,
|
|
51738
|
-
checkmarkDoneOutline,
|
|
51739
|
-
cloudOfflineOutline,
|
|
51740
|
-
cogOutline,
|
|
51741
|
-
documentTextOutline,
|
|
51742
|
-
notificationsOffOutline,
|
|
51743
|
-
notificationsOutline,
|
|
51744
|
-
openOutline,
|
|
51745
|
-
personOutline,
|
|
51746
|
-
});
|
|
51747
|
-
const DEFAULT_NAMESPACE = 'Notifications';
|
|
51748
|
-
const DEFAULT_SETTINGS_PATH = '/app/settings/notifications';
|
|
51749
|
-
const DEFAULT_PAGE_SIZE = 10;
|
|
51750
|
-
const DEFAULT_MAX_LOAD = 50;
|
|
51940
|
+
const DEFAULT_NAMESPACE = 'Settings.Permissions';
|
|
51751
51941
|
/**
|
|
51752
|
-
* `val-
|
|
51753
|
-
*
|
|
51942
|
+
* `val-permissions-view` — vista Permisos full-feature autocontenida (organism).
|
|
51943
|
+
* Visor RBAC de solo lectura: muestra los roles del sistema (Visor/Editor/Admin +
|
|
51944
|
+
* roles de plataforma) y los roles personalizados de la org activa, con el
|
|
51945
|
+
* catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
|
|
51946
|
+
* Promovida desde `showcase` bajo el proceso de ADR-021.
|
|
51754
51947
|
*
|
|
51755
|
-
*
|
|
51756
|
-
*
|
|
51948
|
+
* **RBAC interno** (como `organization-view`): lee la org activa vía
|
|
51949
|
+
* `AuthService.user().activeOrgId`. La vista NO muta nada — el control de quién
|
|
51950
|
+
* puede verla es responsabilidad del guard de ruta del consumer; no hay gating
|
|
51951
|
+
* fino interno ni se expone como config.
|
|
51757
51952
|
*
|
|
51758
|
-
*
|
|
51759
|
-
*
|
|
51760
|
-
*
|
|
51761
|
-
* (`val-empty-state` + reintentar), empty (`val-empty-state` + CTA), data
|
|
51762
|
-
* (lista agrupada Hoy / Este mes / Anteriores).
|
|
51763
|
-
* - **Abrir** una notif → `NotificationActionService.open()` (markRead +
|
|
51764
|
-
* switch-org + navegar/handoff cross-app). Hook `onNotificationOpen` opcional.
|
|
51765
|
-
* - **Marcar todas como leídas** (gateado por `enableMarkAll`).
|
|
51766
|
-
* - **CTA push-off** cuando el permiso del navegador no está concedido (gateado
|
|
51767
|
-
* por `showPushOffCta`); navega a `settingsNotificationsPath`.
|
|
51768
|
-
* - **Paginación soft** — slice local (`pageSize`, +`pageSize` por click) sobre el
|
|
51769
|
-
* cache del stream; el tope de fetch es `maxLoad`.
|
|
51770
|
-
* - **i18n auto-registrado** es/en bajo `i18nNamespace` (default `Notifications`).
|
|
51953
|
+
* Datos: `forkJoin` de `OrgService.listOrgRoles(orgId)` (roles de la org) +
|
|
51954
|
+
* `PermissionCatalogService.getCatalog(orgId)` (catálogo + appId actual). El
|
|
51955
|
+
* catálogo define el alcance (`scope`/`appId`) de cada recurso para agrupar.
|
|
51771
51956
|
*
|
|
51772
|
-
* NO renderiza ion-content — vive dentro de val-page-wrapper. `
|
|
51773
|
-
*
|
|
51774
|
-
* (
|
|
51775
|
-
*
|
|
51776
|
-
* `notifications-outline`.
|
|
51957
|
+
* NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
|
|
51958
|
+
* inyecta `{ optional: true }` solo para leer el route data de config. Auto-registra
|
|
51959
|
+
* sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
|
|
51960
|
+
* `Settings.Permissions`).
|
|
51777
51961
|
*/
|
|
51778
|
-
class
|
|
51962
|
+
class PermissionsViewComponent {
|
|
51779
51963
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
51780
51964
|
get ns() {
|
|
51781
51965
|
return this.resolvedConfig().i18nNamespace;
|
|
51782
51966
|
}
|
|
51783
|
-
/** Paso de paginación soft (revela +N por "cargar más"). */
|
|
51784
|
-
get pageStep() {
|
|
51785
|
-
return this.resolvedConfig().pageSize;
|
|
51786
|
-
}
|
|
51787
51967
|
constructor() {
|
|
51788
|
-
this.nav = inject(NavigationService);
|
|
51789
51968
|
this.i18n = inject(I18nService);
|
|
51790
|
-
this.
|
|
51791
|
-
this.
|
|
51792
|
-
this.toast = inject(ToastService);
|
|
51793
|
-
this.errors = inject(ValtechErrorService);
|
|
51969
|
+
this.orgService = inject(OrgService);
|
|
51970
|
+
this.catalog = inject(PermissionCatalogService);
|
|
51794
51971
|
this.auth = inject(AuthService);
|
|
51795
|
-
this.
|
|
51796
|
-
// `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
|
|
51797
|
-
// router (solo lo usa para leer el route data de config como fallback). La
|
|
51798
|
-
// navegación va por `NavigationService` (wrapper opcional sobre `Router`).
|
|
51972
|
+
this.nav = inject(NavigationService);
|
|
51799
51973
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
51800
51974
|
this.resolvedConfig = computed(() => {
|
|
51801
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
51975
|
+
const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
|
|
51802
51976
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
51803
51977
|
return {
|
|
51804
|
-
|
|
51805
|
-
|
|
51806
|
-
settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
|
|
51978
|
+
showSystemRoles: merged.showSystemRoles ?? true,
|
|
51979
|
+
showCustomRoles: merged.showCustomRoles ?? true,
|
|
51807
51980
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE,
|
|
51808
|
-
pageSize: merged.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
51809
|
-
maxLoad: merged.maxLoad ?? DEFAULT_MAX_LOAD,
|
|
51810
|
-
onNotificationOpen: merged.onNotificationOpen,
|
|
51811
|
-
};
|
|
51812
|
-
});
|
|
51813
|
-
this._expanded = signal(new Set());
|
|
51814
|
-
this._pageSize = signal(this.resolvedConfig().pageSize);
|
|
51815
|
-
this._bulkBusy = signal(false);
|
|
51816
|
-
this._loadingMore = signal(false);
|
|
51817
|
-
/**
|
|
51818
|
-
* Push activado = permiso del navegador en `granted`. Si NO está activado,
|
|
51819
|
-
* el user puede leer el inbox (Firestore) pero NO recibirá push en su
|
|
51820
|
-
* dispositivo → mostramos una card que lo invita a ir a configuración.
|
|
51821
|
-
* Se evalúa en cada montaje de la vista.
|
|
51822
|
-
*/
|
|
51823
|
-
this.pushEnabled = signal(this.auth.getNotificationPermissionState() === 'granted');
|
|
51824
|
-
/**
|
|
51825
|
-
* Stream firestore real-time vía el helper estándar `createRefreshableStream`
|
|
51826
|
-
* (modo real-time: factory devuelve un listener vivo `notifs.getAll(maxLoad)`).
|
|
51827
|
-
*
|
|
51828
|
-
* El helper gatea la primera suscripción por `firebaseAuthReady`, aplica un
|
|
51829
|
-
* `retry` corto con backoff y, tras agotar reintentos, emite el fallback (`[]`)
|
|
51830
|
-
* y marca `error` — estado de error en UI, no skeleton infinito. `reload()`
|
|
51831
|
-
* re-suscribe el stream completo (lo usa el pull-to-refresh).
|
|
51832
|
-
*/
|
|
51833
|
-
this._stream = createRefreshableStream(() => this.notifs.getAll(this.resolvedConfig().maxLoad), {
|
|
51834
|
-
fallback: [],
|
|
51835
|
-
// `notifs.getAll()` YA gatea internamente por `firebaseAuthReady`. Si el
|
|
51836
|
-
// stream también gateara, serían DOS esperas de auth apiladas.
|
|
51837
|
-
gateOnFirebaseAuth: false,
|
|
51838
|
-
// Cold start de PWA en iOS puede pasar de 8s en redes lentas; con 15s
|
|
51839
|
-
// evitamos el falso estado de error.
|
|
51840
|
-
firstEmitTimeoutMs: 15000,
|
|
51841
|
-
});
|
|
51842
|
-
/** True si el stream falló tras agotar reintentos → muestra estado de error. */
|
|
51843
|
-
this.streamError = this._stream.error;
|
|
51844
|
-
this.loading = computed(() => this._stream.data() === null);
|
|
51845
|
-
/**
|
|
51846
|
-
* Notifs reales mostradas. Filtramos docs sin título Y sin body — son ruido
|
|
51847
|
-
* (típicamente sentinel/system writes pre-schema con title/body opcionales).
|
|
51848
|
-
*/
|
|
51849
|
-
this.allNotifs = computed(() => (this._stream.data() ?? []).filter(n => !!(n.title && n.title.trim()) || !!(n.body && n.body.trim())));
|
|
51850
|
-
this.hasMore = computed(() => this.allNotifs().length > this._pageSize());
|
|
51851
|
-
this.showMarkAll = computed(() => this.resolvedConfig().enableMarkAll && !this.loading() && this.allNotifs().some(n => !n.isRead));
|
|
51852
|
-
/** Sub-conjunto paginado, agrupado por período. */
|
|
51853
|
-
this.groups = computed(() => {
|
|
51854
|
-
const items = this.allNotifs().slice(0, this._pageSize());
|
|
51855
|
-
const now = new Date();
|
|
51856
|
-
const buckets = {
|
|
51857
|
-
today: [],
|
|
51858
|
-
thisMonth: [],
|
|
51859
|
-
earlier: [],
|
|
51860
51981
|
};
|
|
51861
|
-
for (const n of items) {
|
|
51862
|
-
buckets[this.bucketOf(n, now)].push(n);
|
|
51863
|
-
}
|
|
51864
|
-
const out = [];
|
|
51865
|
-
if (buckets.today.length) {
|
|
51866
|
-
out.push({ key: 'today', label: this.tt('groupToday'), items: buckets.today });
|
|
51867
|
-
}
|
|
51868
|
-
if (buckets.thisMonth.length) {
|
|
51869
|
-
out.push({
|
|
51870
|
-
key: 'thisMonth',
|
|
51871
|
-
label: this.tt('groupThisMonth'),
|
|
51872
|
-
items: buckets.thisMonth,
|
|
51873
|
-
});
|
|
51874
|
-
}
|
|
51875
|
-
if (buckets.earlier.length) {
|
|
51876
|
-
out.push({ key: 'earlier', label: this.tt('groupEarlier'), items: buckets.earlier });
|
|
51877
|
-
}
|
|
51878
|
-
return out;
|
|
51879
51982
|
});
|
|
51880
|
-
this.
|
|
51881
|
-
this.
|
|
51882
|
-
|
|
51883
|
-
this.
|
|
51884
|
-
this.
|
|
51885
|
-
this.
|
|
51886
|
-
|
|
51887
|
-
|
|
51888
|
-
fill: 'outline',
|
|
51889
|
-
size: 'small',
|
|
51890
|
-
shape: 'round',
|
|
51891
|
-
type: 'button',
|
|
51892
|
-
}));
|
|
51893
|
-
/**
|
|
51894
|
-
* Estado de error de carga: convierte el error del stream en
|
|
51895
|
-
* `EmptyStateMetadata` via el helper canónico `createErrorStateProps`.
|
|
51896
|
-
* El helper decide variante (`offline` vs `error`) por `isNetwork`.
|
|
51897
|
-
* Null cuando el stream no ha fallado.
|
|
51898
|
-
*/
|
|
51899
|
-
this.errorState = computed(() => {
|
|
51900
|
-
this.i18n.lang();
|
|
51901
|
-
const err = this.streamError();
|
|
51902
|
-
if (!err)
|
|
51903
|
-
return null;
|
|
51904
|
-
return createErrorStateProps(err, {
|
|
51905
|
-
title: {
|
|
51906
|
-
offline: this.tt('offlineTitle'),
|
|
51907
|
-
error: this.tt('loadErrorTitle'),
|
|
51908
|
-
},
|
|
51909
|
-
description: {
|
|
51910
|
-
offline: this.tt('offlineHint'),
|
|
51911
|
-
error: interpretError(err).message || this.tt('loadErrorHint'),
|
|
51912
|
-
},
|
|
51913
|
-
retryLabel: this.tt('retryCta'),
|
|
51914
|
-
onRetry: () => this._stream.reload(),
|
|
51915
|
-
retrying: this.loading(),
|
|
51916
|
-
});
|
|
51917
|
-
});
|
|
51918
|
-
/**
|
|
51919
|
-
* Estado vacío (lista real = 0 notificaciones, sin error de carga).
|
|
51920
|
-
* Se construye manualmente con `variant: 'empty'` — sin helper de error.
|
|
51921
|
-
*/
|
|
51922
|
-
this.emptyStateProps = computed(() => {
|
|
51923
|
-
this.i18n.lang();
|
|
51924
|
-
return {
|
|
51925
|
-
variant: 'empty',
|
|
51926
|
-
icon: 'notifications-off-outline',
|
|
51927
|
-
title: this.tt('empty'),
|
|
51928
|
-
description: this.tt('emptyHint'),
|
|
51929
|
-
action: {
|
|
51930
|
-
label: this.tt('emptyCta'),
|
|
51931
|
-
handler: () => this.onGoToSettings(),
|
|
51932
|
-
},
|
|
51933
|
-
};
|
|
51983
|
+
this.loading = signal(false);
|
|
51984
|
+
this.loadError = signal(null);
|
|
51985
|
+
this.roles = signal([]);
|
|
51986
|
+
this.systemRoles = computed(() => this.roles().filter(r => r.isSystem));
|
|
51987
|
+
this.customRoles = computed(() => this.roles().filter(r => !r.isSystem));
|
|
51988
|
+
this.activeOrgId = computed(() => {
|
|
51989
|
+
const u = this.auth.user();
|
|
51990
|
+
return u?.activeOrgId ?? u?.activeOrg ?? '';
|
|
51934
51991
|
});
|
|
51935
|
-
this.
|
|
51936
|
-
|
|
51937
|
-
this.markAllButtonProps = computed(() => ({
|
|
51938
|
-
text: this.tt('markAll'),
|
|
51992
|
+
this.retryButtonProps = computed(() => ({
|
|
51993
|
+
text: this.tt('retry'),
|
|
51939
51994
|
color: 'dark',
|
|
51940
51995
|
fill: 'outline',
|
|
51941
|
-
size: 'small',
|
|
51942
|
-
shape: 'round',
|
|
51943
|
-
type: 'button',
|
|
51944
|
-
icon: { name: 'checkmark-done-outline', slot: 'start' },
|
|
51945
|
-
state: this._bulkBusy() ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
51946
|
-
}));
|
|
51947
|
-
this.loadMoreButtonProps = computed(() => ({
|
|
51948
|
-
text: this.tt('loadMore'),
|
|
51949
|
-
color: 'dark',
|
|
51950
|
-
fill: 'clear',
|
|
51951
51996
|
size: 'default',
|
|
51952
|
-
shape: 'round',
|
|
51953
51997
|
type: 'button',
|
|
51954
|
-
state: this.
|
|
51998
|
+
state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
|
|
51955
51999
|
}));
|
|
51956
52000
|
const ns = this.ns;
|
|
51957
52001
|
if (!this.i18n.hasNamespace(ns)) {
|
|
51958
|
-
this.i18n.registerContent(ns,
|
|
52002
|
+
this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
|
|
51959
52003
|
}
|
|
51960
52004
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
51961
|
-
|
|
51962
|
-
|
|
51963
|
-
this.i18n.lang();
|
|
51964
|
-
this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
|
|
51965
|
-
}, { allowSignalWrites: true });
|
|
51966
|
-
}
|
|
51967
|
-
/**
|
|
51968
|
-
* Registra el pull-to-refresh estándar. Usamos `ngOnInit`/`ngOnDestroy`
|
|
51969
|
-
* (NO `ionViewWillEnter`) porque `val-page-wrapper` usa `<router-outlet>`
|
|
51970
|
-
* plano — los hooks `ionView*` solo disparan dentro de `<ion-router-outlet>`.
|
|
51971
|
-
*/
|
|
51972
|
-
ngOnInit() {
|
|
51973
|
-
this.pageRefresh.register(() => this.reload());
|
|
51974
|
-
}
|
|
51975
|
-
/** Quita el handler al destruir la vista — el refresher desaparece. */
|
|
51976
|
-
ngOnDestroy() {
|
|
51977
|
-
this.pageRefresh.unregister();
|
|
51978
|
-
}
|
|
51979
|
-
/**
|
|
51980
|
-
* Refresh manual: re-suscribe el stream Firestore (listener nuevo) vía el
|
|
51981
|
-
* helper. Resuelve tras un margen breve — el stream es real-time y re-emite
|
|
51982
|
-
* en milisegundos; el delay sólo mantiene el spinner visible lo justo.
|
|
51983
|
-
*/
|
|
51984
|
-
async reload() {
|
|
51985
|
-
this._stream.reload();
|
|
51986
|
-
await new Promise(r => setTimeout(r, 700));
|
|
51987
|
-
}
|
|
51988
|
-
// ---------------------------------------------------------------------------
|
|
51989
|
-
// Per-item helpers (puros — usables desde template).
|
|
51990
|
-
// ---------------------------------------------------------------------------
|
|
51991
|
-
openButtonProps() {
|
|
51992
|
-
return {
|
|
51993
|
-
text: this.tt('open'),
|
|
51994
|
-
color: 'dark',
|
|
51995
|
-
fill: 'clear',
|
|
51996
|
-
size: 'small',
|
|
51997
|
-
shape: 'round',
|
|
51998
|
-
type: 'button',
|
|
51999
|
-
icon: { name: 'open-outline', slot: 'end' },
|
|
52000
|
-
};
|
|
52001
|
-
}
|
|
52002
|
-
isExpanded(id) {
|
|
52003
|
-
return this._expanded().has(id);
|
|
52004
|
-
}
|
|
52005
|
-
toggleExpand(id) {
|
|
52006
|
-
const next = new Set(this._expanded());
|
|
52007
|
-
if (next.has(id))
|
|
52008
|
-
next.delete(id);
|
|
52009
|
-
else
|
|
52010
|
-
next.add(id);
|
|
52011
|
-
this._expanded.set(next);
|
|
52005
|
+
this.load();
|
|
52006
|
+
connectPageRefresh(() => this.load());
|
|
52012
52007
|
}
|
|
52013
|
-
|
|
52014
|
-
|
|
52015
|
-
|
|
52016
|
-
|
|
52008
|
+
load() {
|
|
52009
|
+
const orgId = this.activeOrgId();
|
|
52010
|
+
if (!orgId)
|
|
52011
|
+
return;
|
|
52012
|
+
this.loading.set(true);
|
|
52013
|
+
this.loadError.set(null);
|
|
52014
|
+
forkJoin({
|
|
52015
|
+
roles: this.orgService.listOrgRoles(orgId),
|
|
52016
|
+
catalog: this.catalog.getCatalog(orgId),
|
|
52017
|
+
}).subscribe({
|
|
52018
|
+
next: ({ roles: rawRoles, catalog }) => {
|
|
52019
|
+
const byResource = new Map();
|
|
52020
|
+
for (const r of catalog.permissions)
|
|
52021
|
+
byResource.set(r.resource, r);
|
|
52022
|
+
const currentAppId = catalog.appId;
|
|
52023
|
+
this.roles.set((rawRoles ?? []).map((r) => this.buildRoleView({
|
|
52024
|
+
id: r.id,
|
|
52025
|
+
name: r.name,
|
|
52026
|
+
description: r.description,
|
|
52027
|
+
permissions: r.permissions ?? [],
|
|
52028
|
+
isSystem: r.isSystem ?? false,
|
|
52029
|
+
}, byResource, currentAppId)));
|
|
52030
|
+
this.loading.set(false);
|
|
52031
|
+
},
|
|
52032
|
+
error: err => {
|
|
52033
|
+
this.loadError.set(err);
|
|
52034
|
+
this.loading.set(false);
|
|
52035
|
+
},
|
|
52036
|
+
});
|
|
52017
52037
|
}
|
|
52018
|
-
|
|
52019
|
-
|
|
52020
|
-
|
|
52021
|
-
|
|
52022
|
-
|
|
52023
|
-
|
|
52024
|
-
|
|
52025
|
-
|
|
52026
|
-
|
|
52027
|
-
|
|
52028
|
-
|
|
52038
|
+
buildRoleView(role, byResource, currentAppId) {
|
|
52039
|
+
const appPerms = [];
|
|
52040
|
+
const orgPerms = [];
|
|
52041
|
+
const otherByApp = new Map();
|
|
52042
|
+
for (const perm of role.permissions) {
|
|
52043
|
+
const [resource, action] = perm.split(':');
|
|
52044
|
+
const entry = byResource.get(resource);
|
|
52045
|
+
// Sin entrada en el catálogo (el backend omite los recursos internal para
|
|
52046
|
+
// no-platform-admins) o entrada internal → NUNCA se muestra a nivel de
|
|
52047
|
+
// producto. Defensa en profundidad junto al filtro del backend (ADR-024).
|
|
52048
|
+
// Nota: el rol puede traer el permiso aunque el recurso esté filtrado del
|
|
52049
|
+
// catálogo (ej. admin tiene templates:*); por eso se descarta lo no resuelto.
|
|
52050
|
+
if (!entry || entry.scope === 'internal') {
|
|
52051
|
+
continue;
|
|
52029
52052
|
}
|
|
52030
|
-
|
|
52031
|
-
|
|
52032
|
-
|
|
52033
|
-
|
|
52034
|
-
|
|
52035
|
-
|
|
52036
|
-
|
|
52037
|
-
|
|
52038
|
-
|
|
52039
|
-
|
|
52040
|
-
|
|
52041
|
-
|
|
52042
|
-
|
|
52043
|
-
|
|
52044
|
-
|
|
52045
|
-
case 'system':
|
|
52046
|
-
return 'cog-outline';
|
|
52047
|
-
case 'user':
|
|
52048
|
-
case 'mention':
|
|
52049
|
-
return 'person-outline';
|
|
52050
|
-
case 'document':
|
|
52051
|
-
return 'document-text-outline';
|
|
52052
|
-
default:
|
|
52053
|
-
return 'notifications-outline';
|
|
52053
|
+
const label = this.permLabel(resource, action);
|
|
52054
|
+
if (entry.scope === 'app') {
|
|
52055
|
+
if (entry.appId === currentAppId) {
|
|
52056
|
+
appPerms.push(label);
|
|
52057
|
+
}
|
|
52058
|
+
else {
|
|
52059
|
+
const appId = entry.appId ?? '?';
|
|
52060
|
+
const arr = otherByApp.get(appId) ?? [];
|
|
52061
|
+
arr.push(label);
|
|
52062
|
+
otherByApp.set(appId, arr);
|
|
52063
|
+
}
|
|
52064
|
+
}
|
|
52065
|
+
else {
|
|
52066
|
+
// scope === 'org' → gestión de la organización del cliente
|
|
52067
|
+
orgPerms.push(label);
|
|
52054
52068
|
}
|
|
52055
|
-
})();
|
|
52056
|
-
return { kind: 'ionicon', name, color: 'dark' };
|
|
52057
|
-
}
|
|
52058
|
-
ioniconName(n) {
|
|
52059
|
-
const r = this.resolveIcon(n);
|
|
52060
|
-
return r.kind === 'ionicon' ? r.name : '';
|
|
52061
|
-
}
|
|
52062
|
-
ioniconColor(n) {
|
|
52063
|
-
const r = this.resolveIcon(n);
|
|
52064
|
-
return r.kind === 'ionicon' ? r.color : undefined;
|
|
52065
|
-
}
|
|
52066
|
-
imageSrc(n) {
|
|
52067
|
-
const r = this.resolveIcon(n);
|
|
52068
|
-
return r.kind === 'image' || r.kind === 'avatar' ? r.src : '';
|
|
52069
|
-
}
|
|
52070
|
-
ionColor(token) {
|
|
52071
|
-
if (!token)
|
|
52072
|
-
return 'var(--ion-color-primary)';
|
|
52073
|
-
return `var(--ion-color-${token})`;
|
|
52074
|
-
}
|
|
52075
|
-
relativeTime(n) {
|
|
52076
|
-
const d = n.createdAt instanceof Date ? n.createdAt : null;
|
|
52077
|
-
if (!d)
|
|
52078
|
-
return '';
|
|
52079
|
-
const diffMs = Date.now() - d.getTime();
|
|
52080
|
-
const min = Math.floor(diffMs / 60000);
|
|
52081
|
-
if (min < 1)
|
|
52082
|
-
return this.tt('timeNow');
|
|
52083
|
-
if (min < 60)
|
|
52084
|
-
return this.tt('timeMinutes').replace('{n}', String(min));
|
|
52085
|
-
const h = Math.floor(min / 60);
|
|
52086
|
-
if (h < 24)
|
|
52087
|
-
return this.tt('timeHours').replace('{n}', String(h));
|
|
52088
|
-
const days = Math.floor(h / 24);
|
|
52089
|
-
return this.tt('timeDays').replace('{n}', String(days));
|
|
52090
|
-
}
|
|
52091
|
-
/** Devuelve label "Desde {appName}" si la notif viene de otra app, sino null. */
|
|
52092
|
-
showCrossAppBadge(n) {
|
|
52093
|
-
const currentApp = 'showcase';
|
|
52094
|
-
if (!n.appId || n.appId === currentApp)
|
|
52095
|
-
return null;
|
|
52096
|
-
return this.tt('crossApp').replace('{app}', n.appId);
|
|
52097
|
-
}
|
|
52098
|
-
// ---------------------------------------------------------------------------
|
|
52099
|
-
// Actions
|
|
52100
|
-
// ---------------------------------------------------------------------------
|
|
52101
|
-
async onOpen(n) {
|
|
52102
|
-
try {
|
|
52103
|
-
const result = await this.action.open(n);
|
|
52104
|
-
this.resolvedConfig().onNotificationOpen?.(n, result);
|
|
52105
52069
|
}
|
|
52106
|
-
|
|
52107
|
-
|
|
52108
|
-
|
|
52109
|
-
fallbackKey: 'openError',
|
|
52110
|
-
i18nNamespace: this.ns,
|
|
52111
|
-
});
|
|
52070
|
+
const groups = [];
|
|
52071
|
+
if (appPerms.length) {
|
|
52072
|
+
groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
|
|
52112
52073
|
}
|
|
52113
|
-
|
|
52114
|
-
|
|
52115
|
-
|
|
52116
|
-
|
|
52117
|
-
|
|
52118
|
-
try {
|
|
52119
|
-
await this.notifs.markAllAsRead();
|
|
52120
|
-
this.toast.show({
|
|
52121
|
-
message: this.tt('markedAllSuccess'),
|
|
52122
|
-
color: 'dark',
|
|
52123
|
-
duration: 2500,
|
|
52124
|
-
position: 'top',
|
|
52074
|
+
if (orgPerms.length) {
|
|
52075
|
+
groups.push({
|
|
52076
|
+
key: 'org',
|
|
52077
|
+
label: this.tt('groupOrg'),
|
|
52078
|
+
perms: orgPerms,
|
|
52125
52079
|
});
|
|
52126
52080
|
}
|
|
52127
|
-
|
|
52128
|
-
|
|
52129
|
-
|
|
52130
|
-
|
|
52131
|
-
|
|
52081
|
+
for (const [appId, perms] of otherByApp) {
|
|
52082
|
+
groups.push({
|
|
52083
|
+
key: `other:${appId}`,
|
|
52084
|
+
label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
|
|
52085
|
+
perms,
|
|
52132
52086
|
});
|
|
52133
52087
|
}
|
|
52134
|
-
|
|
52135
|
-
|
|
52136
|
-
|
|
52088
|
+
return {
|
|
52089
|
+
id: role.id,
|
|
52090
|
+
name: role.name,
|
|
52091
|
+
description: role.description,
|
|
52092
|
+
isSystem: role.isSystem,
|
|
52093
|
+
groups,
|
|
52094
|
+
hasPerms: groups.length > 0,
|
|
52095
|
+
};
|
|
52137
52096
|
}
|
|
52138
|
-
|
|
52139
|
-
|
|
52140
|
-
return;
|
|
52141
|
-
this._loadingMore.set(true);
|
|
52142
|
-
// Slice viene del cache local — no hay fetch real, pero un breve spinner
|
|
52143
|
-
// confirma al user que el click registró y suaviza la aparición de items.
|
|
52144
|
-
setTimeout(() => {
|
|
52145
|
-
this._pageSize.update(n => n + this.pageStep);
|
|
52146
|
-
this._loadingMore.set(false);
|
|
52147
|
-
}, 250);
|
|
52097
|
+
tt(key) {
|
|
52098
|
+
return this.i18n.t(key, this.ns);
|
|
52148
52099
|
}
|
|
52149
|
-
|
|
52150
|
-
|
|
52100
|
+
appLabel(appId) {
|
|
52101
|
+
const key = `app_${appId}`;
|
|
52102
|
+
const label = this.tt(key);
|
|
52103
|
+
return label === key ? appId : label;
|
|
52151
52104
|
}
|
|
52152
|
-
|
|
52153
|
-
|
|
52154
|
-
|
|
52155
|
-
|
|
52156
|
-
|
|
52157
|
-
|
|
52158
|
-
|
|
52159
|
-
|
|
52160
|
-
|
|
52161
|
-
|
|
52162
|
-
|
|
52163
|
-
|
|
52164
|
-
}
|
|
52165
|
-
return 'earlier';
|
|
52105
|
+
roleLabel(name) {
|
|
52106
|
+
const map = {
|
|
52107
|
+
viewer: 'roleViewer',
|
|
52108
|
+
editor: 'roleEditor',
|
|
52109
|
+
admin: 'roleAdmin',
|
|
52110
|
+
super_admin: 'roleSuperAdmin',
|
|
52111
|
+
bingo_admin: 'roleBingoAdmin',
|
|
52112
|
+
bingo_tesorero: 'roleBingoTesorero',
|
|
52113
|
+
bingo_taquilla: 'roleBingoTaquilla',
|
|
52114
|
+
bingo_operator: 'roleBingoOperator',
|
|
52115
|
+
};
|
|
52116
|
+
return this.tt(map[name] ?? name);
|
|
52166
52117
|
}
|
|
52167
|
-
|
|
52168
|
-
|
|
52118
|
+
permLabel(resource, action) {
|
|
52119
|
+
if (resource === '*' && action === '*')
|
|
52120
|
+
return this.tt('permAll');
|
|
52121
|
+
const resKey = resource === '*' ? 'permAll' : `perm${resource.charAt(0).toUpperCase()}${resource.slice(1)}`;
|
|
52122
|
+
const actKey = action === '*' ? 'permAll' : `perm${action.charAt(0).toUpperCase()}${action.slice(1)}`;
|
|
52123
|
+
const res = this.tt(resKey);
|
|
52124
|
+
const act = this.tt(actKey);
|
|
52125
|
+
return `${res}: ${act}`;
|
|
52169
52126
|
}
|
|
52170
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
52171
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type:
|
|
52127
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
52128
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: PermissionsViewComponent, isStandalone: true, selector: "val-permissions-view", inputs: { config: "config" }, ngImport: i0, template: `
|
|
52172
52129
|
<div class="page">
|
|
52173
52130
|
<header class="page-header">
|
|
52174
|
-
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle
|
|
52131
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
|
|
52175
52132
|
<val-title
|
|
52176
52133
|
[props]="{
|
|
52177
52134
|
size: 'large',
|
|
52178
52135
|
color: 'dark',
|
|
52179
52136
|
bold: false,
|
|
52180
|
-
content: pageDescription
|
|
52137
|
+
content: tt('pageDescription'),
|
|
52181
52138
|
}"
|
|
52182
52139
|
/>
|
|
52183
|
-
@if (showMarkAll()) {
|
|
52184
|
-
<div class="row-actions">
|
|
52185
|
-
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
52186
|
-
</div>
|
|
52187
|
-
}
|
|
52188
52140
|
</header>
|
|
52189
52141
|
|
|
52190
|
-
@if (resolvedConfig().showPushOffCta && !pushEnabled()) {
|
|
52191
|
-
<section class="notif-cta">
|
|
52192
|
-
<ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
|
|
52193
|
-
<div class="notif-cta__body">
|
|
52194
|
-
<val-text
|
|
52195
|
-
[props]="{
|
|
52196
|
-
content: ctaTitle(),
|
|
52197
|
-
size: 'medium',
|
|
52198
|
-
color: 'dark',
|
|
52199
|
-
bold: true,
|
|
52200
|
-
}"
|
|
52201
|
-
/>
|
|
52202
|
-
<val-text
|
|
52203
|
-
[props]="{
|
|
52204
|
-
content: ctaHint(),
|
|
52205
|
-
size: 'small',
|
|
52206
|
-
color: 'medium',
|
|
52207
|
-
bold: false,
|
|
52208
|
-
}"
|
|
52209
|
-
/>
|
|
52210
|
-
<div class="row-actions">
|
|
52211
|
-
<val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
|
|
52212
|
-
</div>
|
|
52213
|
-
</div>
|
|
52214
|
-
</section>
|
|
52215
|
-
}
|
|
52216
|
-
|
|
52217
52142
|
@if (loading()) {
|
|
52218
|
-
<
|
|
52219
|
-
|
|
52220
|
-
|
|
52221
|
-
} @else if (
|
|
52222
|
-
<
|
|
52143
|
+
<div class="spinner-row">
|
|
52144
|
+
<ion-spinner name="crescent" />
|
|
52145
|
+
</div>
|
|
52146
|
+
} @else if (loadError()) {
|
|
52147
|
+
<div class="error-row">
|
|
52148
|
+
<val-text
|
|
52149
|
+
[props]="{
|
|
52150
|
+
content: tt('errorTitle'),
|
|
52151
|
+
size: 'medium',
|
|
52152
|
+
color: 'medium',
|
|
52153
|
+
bold: false,
|
|
52154
|
+
}"
|
|
52155
|
+
/>
|
|
52156
|
+
<val-button [props]="retryButtonProps()" (click)="load()" />
|
|
52157
|
+
</div>
|
|
52223
52158
|
} @else {
|
|
52224
|
-
@
|
|
52225
|
-
<section class="
|
|
52226
|
-
<
|
|
52227
|
-
|
|
52228
|
-
|
|
52229
|
-
|
|
52230
|
-
|
|
52231
|
-
|
|
52232
|
-
|
|
52233
|
-
|
|
52234
|
-
|
|
52235
|
-
|
|
52236
|
-
|
|
52237
|
-
|
|
52238
|
-
|
|
52239
|
-
|
|
52240
|
-
|
|
52241
|
-
|
|
52242
|
-
|
|
52243
|
-
|
|
52244
|
-
|
|
52245
|
-
|
|
52246
|
-
|
|
52247
|
-
|
|
52248
|
-
|
|
52249
|
-
|
|
52250
|
-
|
|
52251
|
-
|
|
52252
|
-
|
|
52159
|
+
@if (resolvedConfig().showSystemRoles) {
|
|
52160
|
+
<section class="perm-section">
|
|
52161
|
+
<div class="perm-section__header">
|
|
52162
|
+
<val-title
|
|
52163
|
+
[props]="{
|
|
52164
|
+
size: 'medium',
|
|
52165
|
+
color: 'dark',
|
|
52166
|
+
bold: true,
|
|
52167
|
+
content: tt('systemRolesTitle'),
|
|
52168
|
+
}"
|
|
52169
|
+
/>
|
|
52170
|
+
<val-text
|
|
52171
|
+
[props]="{
|
|
52172
|
+
content: tt('systemRolesHint'),
|
|
52173
|
+
size: 'medium',
|
|
52174
|
+
color: 'medium',
|
|
52175
|
+
bold: false,
|
|
52176
|
+
}"
|
|
52177
|
+
/>
|
|
52178
|
+
</div>
|
|
52179
|
+
<div class="role-list">
|
|
52180
|
+
@for (role of systemRoles(); track role.id) {
|
|
52181
|
+
<div class="role-card">
|
|
52182
|
+
<div class="role-card__header">
|
|
52183
|
+
<span class="role-card__name">{{ roleLabel(role.name) }}</span>
|
|
52184
|
+
<val-pill
|
|
52185
|
+
[props]="{
|
|
52186
|
+
label: tt('systemBadge'),
|
|
52187
|
+
color: 'primary',
|
|
52188
|
+
weight: 'bold',
|
|
52189
|
+
}"
|
|
52190
|
+
/>
|
|
52253
52191
|
</div>
|
|
52254
|
-
|
|
52255
|
-
|
|
52256
|
-
|
|
52257
|
-
|
|
52258
|
-
|
|
52259
|
-
|
|
52260
|
-
size: 'medium',
|
|
52261
|
-
color: 'dark',
|
|
52262
|
-
bold: true,
|
|
52263
|
-
}"
|
|
52264
|
-
/>
|
|
52265
|
-
<span class="notif__time">{{ relativeTime(n) }}</span>
|
|
52192
|
+
@if (role.description) {
|
|
52193
|
+
<span class="role-card__desc">{{ role.description }}</span>
|
|
52194
|
+
}
|
|
52195
|
+
@if (!role.hasPerms) {
|
|
52196
|
+
<div class="perm-chips">
|
|
52197
|
+
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
52266
52198
|
</div>
|
|
52267
|
-
|
|
52268
|
-
|
|
52269
|
-
|
|
52270
|
-
|
|
52271
|
-
|
|
52272
|
-
|
|
52273
|
-
|
|
52274
|
-
|
|
52275
|
-
|
|
52276
|
-
}
|
|
52277
|
-
@if (showCrossAppBadge(n); as appName) {
|
|
52278
|
-
<span class="notif__app">{{ appName }}</span>
|
|
52279
|
-
}
|
|
52280
|
-
@if (n.actionRoute) {
|
|
52281
|
-
<div class="row-actions row-actions--end">
|
|
52282
|
-
<val-button [props]="openButtonProps()" (click)="onOpen(n)" />
|
|
52199
|
+
} @else {
|
|
52200
|
+
@for (g of role.groups; track g.key) {
|
|
52201
|
+
<div class="perm-group">
|
|
52202
|
+
<span class="perm-group__label">{{ g.label }}</span>
|
|
52203
|
+
<div class="perm-chips">
|
|
52204
|
+
@for (p of g.perms; track p) {
|
|
52205
|
+
<span class="perm-chip">{{ p }}</span>
|
|
52206
|
+
}
|
|
52207
|
+
</div>
|
|
52283
52208
|
</div>
|
|
52284
52209
|
}
|
|
52285
|
-
|
|
52286
|
-
</
|
|
52210
|
+
}
|
|
52211
|
+
</div>
|
|
52287
52212
|
}
|
|
52288
|
-
</
|
|
52213
|
+
</div>
|
|
52289
52214
|
</section>
|
|
52290
52215
|
}
|
|
52291
52216
|
|
|
52292
|
-
@if (
|
|
52293
|
-
<
|
|
52294
|
-
<
|
|
52295
|
-
|
|
52217
|
+
@if (resolvedConfig().showCustomRoles) {
|
|
52218
|
+
<section class="perm-section">
|
|
52219
|
+
<div class="perm-section__header">
|
|
52220
|
+
<val-title
|
|
52221
|
+
[props]="{
|
|
52222
|
+
size: 'medium',
|
|
52223
|
+
color: 'dark',
|
|
52224
|
+
bold: true,
|
|
52225
|
+
content: tt('customRolesTitle'),
|
|
52226
|
+
}"
|
|
52227
|
+
/>
|
|
52228
|
+
<val-text
|
|
52229
|
+
[props]="{
|
|
52230
|
+
content: tt('customRolesHint'),
|
|
52231
|
+
size: 'medium',
|
|
52232
|
+
color: 'medium',
|
|
52233
|
+
bold: false,
|
|
52234
|
+
}"
|
|
52235
|
+
/>
|
|
52236
|
+
</div>
|
|
52237
|
+
@if (customRoles().length === 0) {
|
|
52238
|
+
<val-text
|
|
52239
|
+
[props]="{
|
|
52240
|
+
content: tt('noCustomRoles'),
|
|
52241
|
+
size: 'medium',
|
|
52242
|
+
color: 'medium',
|
|
52243
|
+
bold: false,
|
|
52244
|
+
}"
|
|
52245
|
+
/>
|
|
52246
|
+
} @else {
|
|
52247
|
+
<div class="role-list">
|
|
52248
|
+
@for (role of customRoles(); track role.id) {
|
|
52249
|
+
<div class="role-card">
|
|
52250
|
+
<div class="role-card__header">
|
|
52251
|
+
<span class="role-card__name">{{ role.name }}</span>
|
|
52252
|
+
</div>
|
|
52253
|
+
@if (role.description) {
|
|
52254
|
+
<span class="role-card__desc">{{ role.description }}</span>
|
|
52255
|
+
}
|
|
52256
|
+
@if (!role.hasPerms) {
|
|
52257
|
+
<div class="perm-chips">
|
|
52258
|
+
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
52259
|
+
</div>
|
|
52260
|
+
} @else {
|
|
52261
|
+
@for (g of role.groups; track g.key) {
|
|
52262
|
+
<div class="perm-group">
|
|
52263
|
+
<span class="perm-group__label">{{ g.label }}</span>
|
|
52264
|
+
<div class="perm-chips">
|
|
52265
|
+
@for (p of g.perms; track p) {
|
|
52266
|
+
<span class="perm-chip">{{ p }}</span>
|
|
52267
|
+
}
|
|
52268
|
+
</div>
|
|
52269
|
+
</div>
|
|
52270
|
+
}
|
|
52271
|
+
}
|
|
52272
|
+
</div>
|
|
52273
|
+
}
|
|
52274
|
+
</div>
|
|
52275
|
+
}
|
|
52276
|
+
</section>
|
|
52296
52277
|
}
|
|
52297
52278
|
}
|
|
52298
52279
|
</div>
|
|
52299
|
-
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
52280
|
+
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"], dependencies: [{ kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: PillComponent, selector: "val-pill", inputs: ["preset", "props"], outputs: ["pillClick", "pillAction"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }] }); }
|
|
52300
52281
|
}
|
|
52301
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
52282
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, decorators: [{
|
|
52302
52283
|
type: Component,
|
|
52303
|
-
args: [{ selector: 'val-
|
|
52304
|
-
IonIcon,
|
|
52305
|
-
DisplayComponent,
|
|
52306
|
-
TitleComponent,
|
|
52307
|
-
TextComponent,
|
|
52308
|
-
ButtonComponent,
|
|
52309
|
-
SkeletonLayoutComponent,
|
|
52310
|
-
EmptyStateComponent,
|
|
52311
|
-
], template: `
|
|
52284
|
+
args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
|
|
52312
52285
|
<div class="page">
|
|
52313
52286
|
<header class="page-header">
|
|
52314
|
-
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle
|
|
52287
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
|
|
52315
52288
|
<val-title
|
|
52316
52289
|
[props]="{
|
|
52317
52290
|
size: 'large',
|
|
52318
52291
|
color: 'dark',
|
|
52319
52292
|
bold: false,
|
|
52320
|
-
content: pageDescription
|
|
52293
|
+
content: tt('pageDescription'),
|
|
52321
52294
|
}"
|
|
52322
52295
|
/>
|
|
52323
|
-
@if (showMarkAll()) {
|
|
52324
|
-
<div class="row-actions">
|
|
52325
|
-
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
52326
|
-
</div>
|
|
52327
|
-
}
|
|
52328
52296
|
</header>
|
|
52329
52297
|
|
|
52330
|
-
@if (resolvedConfig().showPushOffCta && !pushEnabled()) {
|
|
52331
|
-
<section class="notif-cta">
|
|
52332
|
-
<ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
|
|
52333
|
-
<div class="notif-cta__body">
|
|
52334
|
-
<val-text
|
|
52335
|
-
[props]="{
|
|
52336
|
-
content: ctaTitle(),
|
|
52337
|
-
size: 'medium',
|
|
52338
|
-
color: 'dark',
|
|
52339
|
-
bold: true,
|
|
52340
|
-
}"
|
|
52341
|
-
/>
|
|
52342
|
-
<val-text
|
|
52343
|
-
[props]="{
|
|
52344
|
-
content: ctaHint(),
|
|
52345
|
-
size: 'small',
|
|
52346
|
-
color: 'medium',
|
|
52347
|
-
bold: false,
|
|
52348
|
-
}"
|
|
52349
|
-
/>
|
|
52350
|
-
<div class="row-actions">
|
|
52351
|
-
<val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
|
|
52352
|
-
</div>
|
|
52353
|
-
</div>
|
|
52354
|
-
</section>
|
|
52355
|
-
}
|
|
52356
|
-
|
|
52357
52298
|
@if (loading()) {
|
|
52358
|
-
<
|
|
52359
|
-
|
|
52360
|
-
|
|
52361
|
-
} @else if (
|
|
52362
|
-
<
|
|
52299
|
+
<div class="spinner-row">
|
|
52300
|
+
<ion-spinner name="crescent" />
|
|
52301
|
+
</div>
|
|
52302
|
+
} @else if (loadError()) {
|
|
52303
|
+
<div class="error-row">
|
|
52304
|
+
<val-text
|
|
52305
|
+
[props]="{
|
|
52306
|
+
content: tt('errorTitle'),
|
|
52307
|
+
size: 'medium',
|
|
52308
|
+
color: 'medium',
|
|
52309
|
+
bold: false,
|
|
52310
|
+
}"
|
|
52311
|
+
/>
|
|
52312
|
+
<val-button [props]="retryButtonProps()" (click)="load()" />
|
|
52313
|
+
</div>
|
|
52363
52314
|
} @else {
|
|
52364
|
-
@
|
|
52365
|
-
<section class="
|
|
52366
|
-
<
|
|
52367
|
-
|
|
52368
|
-
|
|
52369
|
-
|
|
52370
|
-
|
|
52371
|
-
|
|
52372
|
-
|
|
52373
|
-
|
|
52374
|
-
|
|
52375
|
-
|
|
52376
|
-
|
|
52377
|
-
|
|
52378
|
-
|
|
52379
|
-
|
|
52380
|
-
|
|
52381
|
-
|
|
52382
|
-
|
|
52383
|
-
|
|
52384
|
-
|
|
52385
|
-
|
|
52386
|
-
|
|
52387
|
-
|
|
52388
|
-
|
|
52389
|
-
|
|
52390
|
-
|
|
52391
|
-
|
|
52392
|
-
|
|
52315
|
+
@if (resolvedConfig().showSystemRoles) {
|
|
52316
|
+
<section class="perm-section">
|
|
52317
|
+
<div class="perm-section__header">
|
|
52318
|
+
<val-title
|
|
52319
|
+
[props]="{
|
|
52320
|
+
size: 'medium',
|
|
52321
|
+
color: 'dark',
|
|
52322
|
+
bold: true,
|
|
52323
|
+
content: tt('systemRolesTitle'),
|
|
52324
|
+
}"
|
|
52325
|
+
/>
|
|
52326
|
+
<val-text
|
|
52327
|
+
[props]="{
|
|
52328
|
+
content: tt('systemRolesHint'),
|
|
52329
|
+
size: 'medium',
|
|
52330
|
+
color: 'medium',
|
|
52331
|
+
bold: false,
|
|
52332
|
+
}"
|
|
52333
|
+
/>
|
|
52334
|
+
</div>
|
|
52335
|
+
<div class="role-list">
|
|
52336
|
+
@for (role of systemRoles(); track role.id) {
|
|
52337
|
+
<div class="role-card">
|
|
52338
|
+
<div class="role-card__header">
|
|
52339
|
+
<span class="role-card__name">{{ roleLabel(role.name) }}</span>
|
|
52340
|
+
<val-pill
|
|
52341
|
+
[props]="{
|
|
52342
|
+
label: tt('systemBadge'),
|
|
52343
|
+
color: 'primary',
|
|
52344
|
+
weight: 'bold',
|
|
52345
|
+
}"
|
|
52346
|
+
/>
|
|
52393
52347
|
</div>
|
|
52394
|
-
|
|
52395
|
-
|
|
52396
|
-
|
|
52397
|
-
|
|
52398
|
-
|
|
52399
|
-
|
|
52400
|
-
size: 'medium',
|
|
52401
|
-
color: 'dark',
|
|
52402
|
-
bold: true,
|
|
52403
|
-
}"
|
|
52404
|
-
/>
|
|
52405
|
-
<span class="notif__time">{{ relativeTime(n) }}</span>
|
|
52348
|
+
@if (role.description) {
|
|
52349
|
+
<span class="role-card__desc">{{ role.description }}</span>
|
|
52350
|
+
}
|
|
52351
|
+
@if (!role.hasPerms) {
|
|
52352
|
+
<div class="perm-chips">
|
|
52353
|
+
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
52406
52354
|
</div>
|
|
52407
|
-
|
|
52408
|
-
|
|
52409
|
-
|
|
52410
|
-
|
|
52411
|
-
|
|
52412
|
-
|
|
52413
|
-
|
|
52414
|
-
|
|
52415
|
-
|
|
52416
|
-
}
|
|
52417
|
-
@if (showCrossAppBadge(n); as appName) {
|
|
52418
|
-
<span class="notif__app">{{ appName }}</span>
|
|
52419
|
-
}
|
|
52420
|
-
@if (n.actionRoute) {
|
|
52421
|
-
<div class="row-actions row-actions--end">
|
|
52422
|
-
<val-button [props]="openButtonProps()" (click)="onOpen(n)" />
|
|
52355
|
+
} @else {
|
|
52356
|
+
@for (g of role.groups; track g.key) {
|
|
52357
|
+
<div class="perm-group">
|
|
52358
|
+
<span class="perm-group__label">{{ g.label }}</span>
|
|
52359
|
+
<div class="perm-chips">
|
|
52360
|
+
@for (p of g.perms; track p) {
|
|
52361
|
+
<span class="perm-chip">{{ p }}</span>
|
|
52362
|
+
}
|
|
52363
|
+
</div>
|
|
52423
52364
|
</div>
|
|
52424
52365
|
}
|
|
52425
|
-
|
|
52426
|
-
</
|
|
52366
|
+
}
|
|
52367
|
+
</div>
|
|
52427
52368
|
}
|
|
52428
|
-
</
|
|
52369
|
+
</div>
|
|
52429
52370
|
</section>
|
|
52430
52371
|
}
|
|
52431
52372
|
|
|
52432
|
-
@if (
|
|
52433
|
-
<
|
|
52434
|
-
<
|
|
52435
|
-
|
|
52373
|
+
@if (resolvedConfig().showCustomRoles) {
|
|
52374
|
+
<section class="perm-section">
|
|
52375
|
+
<div class="perm-section__header">
|
|
52376
|
+
<val-title
|
|
52377
|
+
[props]="{
|
|
52378
|
+
size: 'medium',
|
|
52379
|
+
color: 'dark',
|
|
52380
|
+
bold: true,
|
|
52381
|
+
content: tt('customRolesTitle'),
|
|
52382
|
+
}"
|
|
52383
|
+
/>
|
|
52384
|
+
<val-text
|
|
52385
|
+
[props]="{
|
|
52386
|
+
content: tt('customRolesHint'),
|
|
52387
|
+
size: 'medium',
|
|
52388
|
+
color: 'medium',
|
|
52389
|
+
bold: false,
|
|
52390
|
+
}"
|
|
52391
|
+
/>
|
|
52392
|
+
</div>
|
|
52393
|
+
@if (customRoles().length === 0) {
|
|
52394
|
+
<val-text
|
|
52395
|
+
[props]="{
|
|
52396
|
+
content: tt('noCustomRoles'),
|
|
52397
|
+
size: 'medium',
|
|
52398
|
+
color: 'medium',
|
|
52399
|
+
bold: false,
|
|
52400
|
+
}"
|
|
52401
|
+
/>
|
|
52402
|
+
} @else {
|
|
52403
|
+
<div class="role-list">
|
|
52404
|
+
@for (role of customRoles(); track role.id) {
|
|
52405
|
+
<div class="role-card">
|
|
52406
|
+
<div class="role-card__header">
|
|
52407
|
+
<span class="role-card__name">{{ role.name }}</span>
|
|
52408
|
+
</div>
|
|
52409
|
+
@if (role.description) {
|
|
52410
|
+
<span class="role-card__desc">{{ role.description }}</span>
|
|
52411
|
+
}
|
|
52412
|
+
@if (!role.hasPerms) {
|
|
52413
|
+
<div class="perm-chips">
|
|
52414
|
+
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
52415
|
+
</div>
|
|
52416
|
+
} @else {
|
|
52417
|
+
@for (g of role.groups; track g.key) {
|
|
52418
|
+
<div class="perm-group">
|
|
52419
|
+
<span class="perm-group__label">{{ g.label }}</span>
|
|
52420
|
+
<div class="perm-chips">
|
|
52421
|
+
@for (p of g.perms; track p) {
|
|
52422
|
+
<span class="perm-chip">{{ p }}</span>
|
|
52423
|
+
}
|
|
52424
|
+
</div>
|
|
52425
|
+
</div>
|
|
52426
|
+
}
|
|
52427
|
+
}
|
|
52428
|
+
</div>
|
|
52429
|
+
}
|
|
52430
|
+
</div>
|
|
52431
|
+
}
|
|
52432
|
+
</section>
|
|
52436
52433
|
}
|
|
52437
52434
|
}
|
|
52438
52435
|
</div>
|
|
52439
|
-
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
52436
|
+
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"] }]
|
|
52440
52437
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
52441
52438
|
type: Input
|
|
52442
52439
|
}] } });
|
|
52443
52440
|
|
|
52444
52441
|
/**
|
|
52445
|
-
* Helper para montar
|
|
52446
|
-
*
|
|
52447
|
-
*
|
|
52448
|
-
* `@Input() config`.
|
|
52442
|
+
* Helper para montar la vista Permisos (`val-permissions-view`) como ruta en una
|
|
52443
|
+
* app del factory. El `config` se pasa por route `data` (`permissionsConfig`) y el
|
|
52444
|
+
* componente lo lee como fallback de su `@Input() config`.
|
|
52449
52445
|
*
|
|
52450
52446
|
* @example
|
|
52451
52447
|
* ```ts
|
|
52452
|
-
* //
|
|
52453
|
-
* export const
|
|
52454
|
-
* ...
|
|
52455
|
-
* { path: '
|
|
52448
|
+
* // settings.routes.ts
|
|
52449
|
+
* export const settingsRoutes: Routes = [
|
|
52450
|
+
* ...provideValtechPermissionsRoutes(), // → /app/settings/permissions
|
|
52451
|
+
* { path: 'preferences', loadComponent: () => ... },
|
|
52456
52452
|
* ];
|
|
52457
52453
|
*
|
|
52458
52454
|
* // con config acotada:
|
|
52459
|
-
* ...
|
|
52460
|
-
* ...
|
|
52461
|
-
* config: { settingsNotificationsPath: '/app/preferences/push' },
|
|
52462
|
-
* }),
|
|
52455
|
+
* ...provideValtechPermissionsRoutes({ config: { showCustomRoles: false } }),
|
|
52456
|
+
* ...provideValtechPermissionsRoutes({ path: 'roles' }),
|
|
52463
52457
|
* ```
|
|
52464
52458
|
*/
|
|
52465
|
-
function
|
|
52459
|
+
function provideValtechPermissionsRoutes(opts) {
|
|
52466
52460
|
return [
|
|
52467
52461
|
{
|
|
52468
|
-
path: opts?.path ?? '
|
|
52469
|
-
component:
|
|
52470
|
-
data: {
|
|
52462
|
+
path: opts?.path ?? 'permissions',
|
|
52463
|
+
component: PermissionsViewComponent,
|
|
52464
|
+
data: { permissionsConfig: opts?.config },
|
|
52471
52465
|
},
|
|
52472
52466
|
];
|
|
52473
52467
|
}
|