valtech-components 2.0.1001 → 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 +9 -6
- package/esm2022/lib/components/organisms/settings-hub/settings.routes.mjs +8 -26
- package/esm2022/lib/version.mjs +2 -2
- package/fesm2022/valtech-components.mjs +2000 -2013
- package/fesm2022/valtech-components.mjs.map +1 -1
- package/lib/components/organisms/settings-hub/settings.routes.d.ts +6 -31
- 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,1827 +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
|
-
*
|
|
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.
|
|
50798
51051
|
*
|
|
50799
|
-
*
|
|
50800
|
-
*
|
|
50801
|
-
* app — la lib no importa el environment del consumer.
|
|
51052
|
+
* Portado del override `Settings.Notifications` (showcase) bajo el proceso de
|
|
51053
|
+
* ADR-021.
|
|
50802
51054
|
*/
|
|
50803
|
-
|
|
50804
|
-
|
|
50805
|
-
|
|
50806
|
-
|
|
50807
|
-
|
|
50808
|
-
|
|
50809
|
-
|
|
50810
|
-
|
|
50811
|
-
|
|
50812
|
-
|
|
50813
|
-
|
|
50814
|
-
|
|
50815
|
-
|
|
50816
|
-
|
|
50817
|
-
|
|
50818
|
-
|
|
50819
|
-
|
|
50820
|
-
|
|
51055
|
+
const NOTIFICATION_PREFERENCES_VIEW_I18N = {
|
|
51056
|
+
es: {
|
|
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',
|
|
51087
|
+
},
|
|
51088
|
+
en: {
|
|
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',
|
|
51114
|
+
},
|
|
51115
|
+
};
|
|
50821
51116
|
|
|
51117
|
+
addIcons({
|
|
51118
|
+
alertCircleOutline,
|
|
51119
|
+
chevronDownOutline,
|
|
51120
|
+
chevronUpOutline,
|
|
51121
|
+
closeCircleOutline,
|
|
51122
|
+
notificationsOffOutline,
|
|
51123
|
+
notificationsOutline,
|
|
51124
|
+
refreshOutline,
|
|
51125
|
+
});
|
|
51126
|
+
const DEFAULT_NAMESPACE$1 = 'Settings.Notifications';
|
|
50822
51127
|
/**
|
|
50823
|
-
*
|
|
50824
|
-
*
|
|
50825
|
-
*
|
|
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.
|
|
50826
51131
|
*
|
|
50827
|
-
*
|
|
51132
|
+
* Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
|
|
51133
|
+
* desactiva** las notificaciones push en este dispositivo.
|
|
51134
|
+
*
|
|
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.
|
|
51140
|
+
*
|
|
51141
|
+
* NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
|
|
51142
|
+
* inyecta `{ optional: true }` → funciona sin router (embebida / Storybook).
|
|
50828
51143
|
*/
|
|
50829
|
-
|
|
50830
|
-
es: {
|
|
50831
|
-
pageTitle: 'Permisos',
|
|
50832
|
-
pageDescription: 'Roles y permisos de esta app en la organización activa',
|
|
50833
|
-
systemRolesTitle: 'Roles del sistema',
|
|
50834
|
-
systemRolesHint: 'Predefinidos por la plataforma. No editables.',
|
|
50835
|
-
customRolesTitle: 'Roles personalizados',
|
|
50836
|
-
customRolesHint: 'Definidos por tu organización.',
|
|
50837
|
-
systemBadge: 'Sistema',
|
|
50838
|
-
noCustomRoles: 'Esta organización no tiene roles personalizados.',
|
|
50839
|
-
noPermissions: 'Sin permisos asignados.',
|
|
50840
|
-
// Grupos de alcance
|
|
50841
|
-
groupApp: 'Permisos de la app',
|
|
50842
|
-
groupOrg: 'Permisos de la organización',
|
|
50843
|
-
groupPlatform: 'Permisos de plataforma',
|
|
50844
|
-
groupOther: 'Otras apps',
|
|
50845
|
-
// Roles del sistema
|
|
50846
|
-
roleViewer: 'Visor',
|
|
50847
|
-
roleEditor: 'Editor',
|
|
50848
|
-
roleAdmin: 'Administrador',
|
|
50849
|
-
roleSuperAdmin: 'Super Administrador',
|
|
50850
|
-
roleBingoAdmin: 'Bingo · Administrador',
|
|
50851
|
-
roleBingoTesorero: 'Bingo · Tesorero',
|
|
50852
|
-
roleBingoTaquilla: 'Bingo · Taquilla',
|
|
50853
|
-
roleBingoOperator: 'Bingo · Operador',
|
|
50854
|
-
// Apps
|
|
50855
|
-
app_showcase: 'Showcase',
|
|
50856
|
-
app_bingo: 'Bingo',
|
|
50857
|
-
// Recursos
|
|
50858
|
-
permUsers: 'Usuarios',
|
|
50859
|
-
permRoles: 'Roles',
|
|
50860
|
-
permRbac: 'Control de acceso',
|
|
50861
|
-
permApps: 'Apps',
|
|
50862
|
-
'permApi-keys': 'API keys',
|
|
50863
|
-
'permEmail-logs': 'Logs de correo',
|
|
50864
|
-
'permSms-logs': 'Logs de SMS',
|
|
50865
|
-
permDocuments: 'Documentos',
|
|
50866
|
-
permMedia: 'Media',
|
|
50867
|
-
permTemplates: 'Plantillas',
|
|
50868
|
-
permRequests: 'Solicitudes',
|
|
50869
|
-
permBingo: 'Bingo',
|
|
50870
|
-
// Acciones
|
|
50871
|
-
permRead: 'Leer',
|
|
50872
|
-
permWrite: 'Escribir',
|
|
50873
|
-
permCreate: 'Crear',
|
|
50874
|
-
permDelete: 'Eliminar',
|
|
50875
|
-
permManage: 'Gestionar',
|
|
50876
|
-
permEdit: 'Editar',
|
|
50877
|
-
permSell: 'Vender',
|
|
50878
|
-
permCheckin: 'Canjear',
|
|
50879
|
-
permSettle: 'Rendir',
|
|
50880
|
-
permExport: 'Exportar',
|
|
50881
|
-
permOperate: 'Operar',
|
|
50882
|
-
permAll: 'Todo',
|
|
50883
|
-
// Loading / error
|
|
50884
|
-
errorTitle: 'Error al cargar permisos',
|
|
50885
|
-
retry: 'Reintentar',
|
|
50886
|
-
},
|
|
50887
|
-
en: {
|
|
50888
|
-
pageTitle: 'Permissions',
|
|
50889
|
-
pageDescription: 'Roles and permissions of this app in the active organization',
|
|
50890
|
-
systemRolesTitle: 'System roles',
|
|
50891
|
-
systemRolesHint: 'Defined by the platform. Not editable.',
|
|
50892
|
-
customRolesTitle: 'Custom roles',
|
|
50893
|
-
customRolesHint: 'Defined by your organization.',
|
|
50894
|
-
systemBadge: 'System',
|
|
50895
|
-
noCustomRoles: 'This organization has no custom roles.',
|
|
50896
|
-
noPermissions: 'No permissions assigned.',
|
|
50897
|
-
groupApp: 'App permissions',
|
|
50898
|
-
groupOrg: 'Organization permissions',
|
|
50899
|
-
groupPlatform: 'Platform permissions',
|
|
50900
|
-
groupOther: 'Other apps',
|
|
50901
|
-
roleViewer: 'Viewer',
|
|
50902
|
-
roleEditor: 'Editor',
|
|
50903
|
-
roleAdmin: 'Administrator',
|
|
50904
|
-
roleSuperAdmin: 'Super Administrator',
|
|
50905
|
-
roleBingoAdmin: 'Bingo · Administrator',
|
|
50906
|
-
roleBingoTesorero: 'Bingo · Treasurer',
|
|
50907
|
-
roleBingoTaquilla: 'Bingo · Box office',
|
|
50908
|
-
roleBingoOperator: 'Bingo · Operator',
|
|
50909
|
-
app_showcase: 'Showcase',
|
|
50910
|
-
app_bingo: 'Bingo',
|
|
50911
|
-
permUsers: 'Users',
|
|
50912
|
-
permRoles: 'Roles',
|
|
50913
|
-
permRbac: 'Access control',
|
|
50914
|
-
permApps: 'Apps',
|
|
50915
|
-
'permApi-keys': 'API keys',
|
|
50916
|
-
'permEmail-logs': 'Email logs',
|
|
50917
|
-
'permSms-logs': 'SMS logs',
|
|
50918
|
-
permDocuments: 'Documents',
|
|
50919
|
-
permMedia: 'Media',
|
|
50920
|
-
permTemplates: 'Templates',
|
|
50921
|
-
permRequests: 'Requests',
|
|
50922
|
-
permBingo: 'Bingo',
|
|
50923
|
-
permRead: 'Read',
|
|
50924
|
-
permWrite: 'Write',
|
|
50925
|
-
permCreate: 'Create',
|
|
50926
|
-
permDelete: 'Delete',
|
|
50927
|
-
permManage: 'Manage',
|
|
50928
|
-
permEdit: 'Edit',
|
|
50929
|
-
permSell: 'Sell',
|
|
50930
|
-
permCheckin: 'Check-in',
|
|
50931
|
-
permSettle: 'Settle',
|
|
50932
|
-
permExport: 'Export',
|
|
50933
|
-
permOperate: 'Operate',
|
|
50934
|
-
permAll: 'All',
|
|
50935
|
-
errorTitle: 'Error loading permissions',
|
|
50936
|
-
retry: 'Retry',
|
|
50937
|
-
},
|
|
50938
|
-
};
|
|
50939
|
-
|
|
50940
|
-
const DEFAULT_NAMESPACE$1 = 'Settings.Permissions';
|
|
50941
|
-
/**
|
|
50942
|
-
* `val-permissions-view` — vista Permisos full-feature autocontenida (organism).
|
|
50943
|
-
* Visor RBAC de solo lectura: muestra los roles del sistema (Visor/Editor/Admin +
|
|
50944
|
-
* roles de plataforma) y los roles personalizados de la org activa, con el
|
|
50945
|
-
* catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
|
|
50946
|
-
* Promovida desde `showcase` bajo el proceso de ADR-021.
|
|
50947
|
-
*
|
|
50948
|
-
* **RBAC interno** (como `organization-view`): lee la org activa vía
|
|
50949
|
-
* `AuthService.user().activeOrgId`. La vista NO muta nada — el control de quién
|
|
50950
|
-
* puede verla es responsabilidad del guard de ruta del consumer; no hay gating
|
|
50951
|
-
* fino interno ni se expone como config.
|
|
50952
|
-
*
|
|
50953
|
-
* Datos: `forkJoin` de `OrgService.listOrgRoles(orgId)` (roles de la org) +
|
|
50954
|
-
* `PermissionCatalogService.getCatalog(orgId)` (catálogo + appId actual). El
|
|
50955
|
-
* catálogo define el alcance (`scope`/`appId`) de cada recurso para agrupar.
|
|
50956
|
-
*
|
|
50957
|
-
* NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
|
|
50958
|
-
* inyecta `{ optional: true }` solo para leer el route data de config. Auto-registra
|
|
50959
|
-
* sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
|
|
50960
|
-
* `Settings.Permissions`).
|
|
50961
|
-
*/
|
|
50962
|
-
class PermissionsViewComponent {
|
|
51144
|
+
class NotificationPreferencesViewComponent {
|
|
50963
51145
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
50964
51146
|
get ns() {
|
|
50965
51147
|
return this.resolvedConfig().i18nNamespace;
|
|
50966
51148
|
}
|
|
51149
|
+
// ---------------------------------------------------------------------------
|
|
51150
|
+
// Lifecycle
|
|
51151
|
+
// ---------------------------------------------------------------------------
|
|
50967
51152
|
constructor() {
|
|
51153
|
+
this.nav = inject(NavigationService);
|
|
50968
51154
|
this.i18n = inject(I18nService);
|
|
50969
|
-
this.
|
|
50970
|
-
this.catalog = inject(PermissionCatalogService);
|
|
51155
|
+
this.toast = inject(ToastService);
|
|
50971
51156
|
this.auth = inject(AuthService);
|
|
50972
|
-
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).
|
|
50973
51162
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
50974
51163
|
this.resolvedConfig = computed(() => {
|
|
50975
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
51164
|
+
const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
|
|
51165
|
+
{});
|
|
50976
51166
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
50977
51167
|
return {
|
|
50978
|
-
|
|
50979
|
-
showCustomRoles: merged.showCustomRoles ?? true,
|
|
51168
|
+
showDetails: merged.showDetails ?? true,
|
|
50980
51169
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$1,
|
|
51170
|
+
onPushEnabled: merged.onPushEnabled,
|
|
51171
|
+
onPushDisabled: merged.onPushDisabled,
|
|
50981
51172
|
};
|
|
50982
51173
|
});
|
|
50983
|
-
|
|
50984
|
-
this.
|
|
50985
|
-
|
|
50986
|
-
this.
|
|
50987
|
-
|
|
50988
|
-
this.
|
|
50989
|
-
|
|
50990
|
-
|
|
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
|
+
}
|
|
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';
|
|
50991
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
|
+
}));
|
|
50992
51275
|
this.retryButtonProps = computed(() => ({
|
|
50993
|
-
text: this.tt('
|
|
51276
|
+
text: this.tt('retryCta'),
|
|
50994
51277
|
color: 'dark',
|
|
50995
51278
|
fill: 'outline',
|
|
50996
51279
|
size: 'default',
|
|
51280
|
+
shape: 'round',
|
|
50997
51281
|
type: 'button',
|
|
50998
|
-
|
|
51282
|
+
icon: { name: 'refresh-outline', slot: 'start' },
|
|
51283
|
+
state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
50999
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
|
+
});
|
|
51000
51291
|
const ns = this.ns;
|
|
51001
51292
|
if (!this.i18n.hasNamespace(ns)) {
|
|
51002
|
-
this.i18n.registerContent(ns,
|
|
51293
|
+
this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
|
|
51003
51294
|
}
|
|
51004
51295
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
51005
|
-
this.
|
|
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
|
-
|
|
51032
|
-
|
|
51033
|
-
this.
|
|
51034
|
-
|
|
51035
|
-
|
|
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
|
+
}
|
|
51036
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
|
+
}
|
|
51037
51337
|
}
|
|
51038
|
-
|
|
51039
|
-
|
|
51040
|
-
|
|
51041
|
-
|
|
51042
|
-
|
|
51043
|
-
|
|
51044
|
-
|
|
51045
|
-
|
|
51046
|
-
|
|
51047
|
-
|
|
51048
|
-
|
|
51049
|
-
|
|
51050
|
-
|
|
51051
|
-
|
|
51052
|
-
|
|
51053
|
-
|
|
51054
|
-
|
|
51055
|
-
|
|
51056
|
-
|
|
51057
|
-
|
|
51058
|
-
|
|
51059
|
-
|
|
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);
|
|
51060
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;
|
|
51061
51406
|
}
|
|
51062
51407
|
else {
|
|
51063
|
-
//
|
|
51064
|
-
|
|
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
|
+
});
|
|
51065
51416
|
}
|
|
51066
51417
|
}
|
|
51067
|
-
|
|
51068
|
-
|
|
51069
|
-
|
|
51070
|
-
|
|
51071
|
-
|
|
51072
|
-
|
|
51073
|
-
|
|
51074
|
-
|
|
51075
|
-
perms: orgPerms,
|
|
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',
|
|
51076
51426
|
});
|
|
51077
51427
|
}
|
|
51078
|
-
|
|
51079
|
-
|
|
51080
|
-
|
|
51081
|
-
|
|
51082
|
-
|
|
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',
|
|
51083
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);
|
|
51084
51456
|
}
|
|
51085
|
-
return {
|
|
51086
|
-
id: role.id,
|
|
51087
|
-
name: role.name,
|
|
51088
|
-
description: role.description,
|
|
51089
|
-
isSystem: role.isSystem,
|
|
51090
|
-
groups,
|
|
51091
|
-
hasPerms: groups.length > 0,
|
|
51092
|
-
};
|
|
51093
51457
|
}
|
|
51094
|
-
|
|
51095
|
-
|
|
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();
|
|
51096
51462
|
}
|
|
51097
|
-
|
|
51098
|
-
|
|
51099
|
-
const label = this.tt(key);
|
|
51100
|
-
return label === key ? appId : label;
|
|
51463
|
+
toggleDetails() {
|
|
51464
|
+
this.showDetails.update(v => !v);
|
|
51101
51465
|
}
|
|
51102
|
-
|
|
51103
|
-
|
|
51104
|
-
|
|
51105
|
-
|
|
51106
|
-
|
|
51107
|
-
|
|
51108
|
-
|
|
51109
|
-
|
|
51110
|
-
bingo_taquilla: 'roleBingoTaquilla',
|
|
51111
|
-
bingo_operator: 'roleBingoOperator',
|
|
51112
|
-
};
|
|
51113
|
-
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();
|
|
51114
51474
|
}
|
|
51115
|
-
|
|
51116
|
-
|
|
51117
|
-
|
|
51118
|
-
|
|
51119
|
-
|
|
51120
|
-
|
|
51121
|
-
|
|
51122
|
-
|
|
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
|
+
}
|
|
51123
51507
|
}
|
|
51124
|
-
|
|
51125
|
-
|
|
51126
|
-
|
|
51127
|
-
|
|
51128
|
-
|
|
51129
|
-
|
|
51130
|
-
|
|
51131
|
-
|
|
51132
|
-
|
|
51133
|
-
|
|
51134
|
-
|
|
51135
|
-
|
|
51136
|
-
|
|
51137
|
-
|
|
51138
|
-
|
|
51139
|
-
|
|
51140
|
-
|
|
51141
|
-
|
|
51142
|
-
|
|
51143
|
-
|
|
51144
|
-
|
|
51145
|
-
|
|
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: `
|
|
51529
|
+
<div class="page">
|
|
51530
|
+
<header class="page-header">
|
|
51531
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
51532
|
+
<val-title
|
|
51533
|
+
[props]="{
|
|
51534
|
+
size: 'large',
|
|
51535
|
+
color: 'dark',
|
|
51536
|
+
bold: false,
|
|
51537
|
+
content: pageDescription(),
|
|
51538
|
+
}"
|
|
51539
|
+
/>
|
|
51540
|
+
</header>
|
|
51541
|
+
|
|
51542
|
+
@if (state() === 'needs-install') {
|
|
51543
|
+
<section class="settings-section">
|
|
51544
|
+
<val-title
|
|
51146
51545
|
[props]="{
|
|
51147
|
-
content: tt('errorTitle'),
|
|
51148
51546
|
size: 'medium',
|
|
51149
|
-
color: '
|
|
51150
|
-
bold:
|
|
51547
|
+
color: 'dark',
|
|
51548
|
+
bold: true,
|
|
51549
|
+
content: tt('iosInstallTitle'),
|
|
51151
51550
|
}"
|
|
51152
51551
|
/>
|
|
51153
|
-
<
|
|
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
|
+
}
|
|
51570
|
+
|
|
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>
|
|
51595
|
+
}
|
|
51596
|
+
</div>
|
|
51597
|
+
<div class="notif-row__control">
|
|
51598
|
+
<val-toggle-input [props]="toggleProps()" />
|
|
51599
|
+
</div>
|
|
51154
51600
|
</div>
|
|
51155
|
-
|
|
51156
|
-
@if (
|
|
51157
|
-
<
|
|
51158
|
-
<
|
|
51159
|
-
|
|
51160
|
-
|
|
51161
|
-
size: 'medium',
|
|
51162
|
-
color: 'dark',
|
|
51163
|
-
bold: true,
|
|
51164
|
-
content: tt('systemRolesTitle'),
|
|
51165
|
-
}"
|
|
51166
|
-
/>
|
|
51167
|
-
<val-text
|
|
51168
|
-
[props]="{
|
|
51169
|
-
content: tt('systemRolesHint'),
|
|
51170
|
-
size: 'medium',
|
|
51171
|
-
color: 'medium',
|
|
51172
|
-
bold: false,
|
|
51173
|
-
}"
|
|
51174
|
-
/>
|
|
51175
|
-
</div>
|
|
51176
|
-
<div class="role-list">
|
|
51177
|
-
@for (role of systemRoles(); track role.id) {
|
|
51178
|
-
<div class="role-card">
|
|
51179
|
-
<div class="role-card__header">
|
|
51180
|
-
<span class="role-card__name">{{ roleLabel(role.name) }}</span>
|
|
51181
|
-
<val-pill
|
|
51182
|
-
[props]="{
|
|
51183
|
-
label: tt('systemBadge'),
|
|
51184
|
-
color: 'primary',
|
|
51185
|
-
weight: 'bold',
|
|
51186
|
-
}"
|
|
51187
|
-
/>
|
|
51188
|
-
</div>
|
|
51189
|
-
@if (role.description) {
|
|
51190
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51191
|
-
}
|
|
51192
|
-
@if (!role.hasPerms) {
|
|
51193
|
-
<div class="perm-chips">
|
|
51194
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51195
|
-
</div>
|
|
51196
|
-
} @else {
|
|
51197
|
-
@for (g of role.groups; track g.key) {
|
|
51198
|
-
<div class="perm-group">
|
|
51199
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51200
|
-
<div class="perm-chips">
|
|
51201
|
-
@for (p of g.perms; track p) {
|
|
51202
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51203
|
-
}
|
|
51204
|
-
</div>
|
|
51205
|
-
</div>
|
|
51206
|
-
}
|
|
51207
|
-
}
|
|
51208
|
-
</div>
|
|
51209
|
-
}
|
|
51210
|
-
</div>
|
|
51211
|
-
</section>
|
|
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>
|
|
51212
51607
|
}
|
|
51213
51608
|
|
|
51214
|
-
@if (
|
|
51215
|
-
<
|
|
51216
|
-
<
|
|
51217
|
-
|
|
51218
|
-
[props]="{
|
|
51219
|
-
size: 'medium',
|
|
51220
|
-
color: 'dark',
|
|
51221
|
-
bold: true,
|
|
51222
|
-
content: tt('customRolesTitle'),
|
|
51223
|
-
}"
|
|
51224
|
-
/>
|
|
51225
|
-
<val-text
|
|
51226
|
-
[props]="{
|
|
51227
|
-
content: tt('customRolesHint'),
|
|
51228
|
-
size: 'medium',
|
|
51229
|
-
color: 'medium',
|
|
51230
|
-
bold: false,
|
|
51231
|
-
}"
|
|
51232
|
-
/>
|
|
51233
|
-
</div>
|
|
51234
|
-
@if (customRoles().length === 0) {
|
|
51235
|
-
<val-text
|
|
51236
|
-
[props]="{
|
|
51237
|
-
content: tt('noCustomRoles'),
|
|
51238
|
-
size: 'medium',
|
|
51239
|
-
color: 'medium',
|
|
51240
|
-
bold: false,
|
|
51241
|
-
}"
|
|
51242
|
-
/>
|
|
51243
|
-
} @else {
|
|
51244
|
-
<div class="role-list">
|
|
51245
|
-
@for (role of customRoles(); track role.id) {
|
|
51246
|
-
<div class="role-card">
|
|
51247
|
-
<div class="role-card__header">
|
|
51248
|
-
<span class="role-card__name">{{ role.name }}</span>
|
|
51249
|
-
</div>
|
|
51250
|
-
@if (role.description) {
|
|
51251
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51252
|
-
}
|
|
51253
|
-
@if (!role.hasPerms) {
|
|
51254
|
-
<div class="perm-chips">
|
|
51255
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51256
|
-
</div>
|
|
51257
|
-
} @else {
|
|
51258
|
-
@for (g of role.groups; track g.key) {
|
|
51259
|
-
<div class="perm-group">
|
|
51260
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51261
|
-
<div class="perm-chips">
|
|
51262
|
-
@for (p of g.perms; track p) {
|
|
51263
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51264
|
-
}
|
|
51265
|
-
</div>
|
|
51266
|
-
</div>
|
|
51267
|
-
}
|
|
51268
|
-
}
|
|
51269
|
-
</div>
|
|
51270
|
-
}
|
|
51271
|
-
</div>
|
|
51272
|
-
}
|
|
51273
|
-
</section>
|
|
51609
|
+
@if (state() === 'error') {
|
|
51610
|
+
<div class="row-actions">
|
|
51611
|
+
<val-button [props]="retryButtonProps()" (click)="onRetry()" />
|
|
51612
|
+
</div>
|
|
51274
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>
|
|
51275
51639
|
}
|
|
51276
51640
|
</div>
|
|
51277
|
-
`, 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"] }] }); }
|
|
51278
51642
|
}
|
|
51279
|
-
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: [{
|
|
51280
51644
|
type: Component,
|
|
51281
|
-
args: [{ selector: 'val-
|
|
51645
|
+
args: [{ selector: 'val-notification-preferences-view', standalone: true, imports: [IonIcon, ToggleInputComponent, ButtonComponent, DisplayComponent, TitleComponent, TextComponent], template: `
|
|
51282
51646
|
<div class="page">
|
|
51283
51647
|
<header class="page-header">
|
|
51284
|
-
<val-display [props]="{ size: 'small', color: 'dark', content:
|
|
51648
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
51285
51649
|
<val-title
|
|
51286
51650
|
[props]="{
|
|
51287
51651
|
size: 'large',
|
|
51288
51652
|
color: 'dark',
|
|
51289
51653
|
bold: false,
|
|
51290
|
-
content:
|
|
51654
|
+
content: pageDescription(),
|
|
51291
51655
|
}"
|
|
51292
51656
|
/>
|
|
51293
51657
|
</header>
|
|
51294
51658
|
|
|
51295
|
-
@if (
|
|
51296
|
-
<
|
|
51297
|
-
<
|
|
51298
|
-
</div>
|
|
51299
|
-
} @else if (loadError()) {
|
|
51300
|
-
<div class="error-row">
|
|
51301
|
-
<val-text
|
|
51659
|
+
@if (state() === 'needs-install') {
|
|
51660
|
+
<section class="settings-section">
|
|
51661
|
+
<val-title
|
|
51302
51662
|
[props]="{
|
|
51303
|
-
content: tt('errorTitle'),
|
|
51304
51663
|
size: 'medium',
|
|
51305
|
-
color: '
|
|
51306
|
-
bold:
|
|
51664
|
+
color: 'dark',
|
|
51665
|
+
bold: true,
|
|
51666
|
+
content: tt('iosInstallTitle'),
|
|
51307
51667
|
}"
|
|
51308
51668
|
/>
|
|
51309
|
-
<
|
|
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>
|
|
51310
51717
|
</div>
|
|
51311
|
-
|
|
51312
|
-
@if (
|
|
51313
|
-
<
|
|
51314
|
-
<
|
|
51315
|
-
|
|
51316
|
-
|
|
51317
|
-
size: 'medium',
|
|
51318
|
-
color: 'dark',
|
|
51319
|
-
bold: true,
|
|
51320
|
-
content: tt('systemRolesTitle'),
|
|
51321
|
-
}"
|
|
51322
|
-
/>
|
|
51323
|
-
<val-text
|
|
51324
|
-
[props]="{
|
|
51325
|
-
content: tt('systemRolesHint'),
|
|
51326
|
-
size: 'medium',
|
|
51327
|
-
color: 'medium',
|
|
51328
|
-
bold: false,
|
|
51329
|
-
}"
|
|
51330
|
-
/>
|
|
51331
|
-
</div>
|
|
51332
|
-
<div class="role-list">
|
|
51333
|
-
@for (role of systemRoles(); track role.id) {
|
|
51334
|
-
<div class="role-card">
|
|
51335
|
-
<div class="role-card__header">
|
|
51336
|
-
<span class="role-card__name">{{ roleLabel(role.name) }}</span>
|
|
51337
|
-
<val-pill
|
|
51338
|
-
[props]="{
|
|
51339
|
-
label: tt('systemBadge'),
|
|
51340
|
-
color: 'primary',
|
|
51341
|
-
weight: 'bold',
|
|
51342
|
-
}"
|
|
51343
|
-
/>
|
|
51344
|
-
</div>
|
|
51345
|
-
@if (role.description) {
|
|
51346
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51347
|
-
}
|
|
51348
|
-
@if (!role.hasPerms) {
|
|
51349
|
-
<div class="perm-chips">
|
|
51350
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51351
|
-
</div>
|
|
51352
|
-
} @else {
|
|
51353
|
-
@for (g of role.groups; track g.key) {
|
|
51354
|
-
<div class="perm-group">
|
|
51355
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51356
|
-
<div class="perm-chips">
|
|
51357
|
-
@for (p of g.perms; track p) {
|
|
51358
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51359
|
-
}
|
|
51360
|
-
</div>
|
|
51361
|
-
</div>
|
|
51362
|
-
}
|
|
51363
|
-
}
|
|
51364
|
-
</div>
|
|
51365
|
-
}
|
|
51366
|
-
</div>
|
|
51367
|
-
</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>
|
|
51368
51724
|
}
|
|
51369
51725
|
|
|
51370
|
-
@if (
|
|
51371
|
-
<
|
|
51372
|
-
<
|
|
51373
|
-
|
|
51374
|
-
[props]="{
|
|
51375
|
-
size: 'medium',
|
|
51376
|
-
color: 'dark',
|
|
51377
|
-
bold: true,
|
|
51378
|
-
content: tt('customRolesTitle'),
|
|
51379
|
-
}"
|
|
51380
|
-
/>
|
|
51381
|
-
<val-text
|
|
51382
|
-
[props]="{
|
|
51383
|
-
content: tt('customRolesHint'),
|
|
51384
|
-
size: 'medium',
|
|
51385
|
-
color: 'medium',
|
|
51386
|
-
bold: false,
|
|
51387
|
-
}"
|
|
51388
|
-
/>
|
|
51389
|
-
</div>
|
|
51390
|
-
@if (customRoles().length === 0) {
|
|
51391
|
-
<val-text
|
|
51392
|
-
[props]="{
|
|
51393
|
-
content: tt('noCustomRoles'),
|
|
51394
|
-
size: 'medium',
|
|
51395
|
-
color: 'medium',
|
|
51396
|
-
bold: false,
|
|
51397
|
-
}"
|
|
51398
|
-
/>
|
|
51399
|
-
} @else {
|
|
51400
|
-
<div class="role-list">
|
|
51401
|
-
@for (role of customRoles(); track role.id) {
|
|
51402
|
-
<div class="role-card">
|
|
51403
|
-
<div class="role-card__header">
|
|
51404
|
-
<span class="role-card__name">{{ role.name }}</span>
|
|
51405
|
-
</div>
|
|
51406
|
-
@if (role.description) {
|
|
51407
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
51408
|
-
}
|
|
51409
|
-
@if (!role.hasPerms) {
|
|
51410
|
-
<div class="perm-chips">
|
|
51411
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
51412
|
-
</div>
|
|
51413
|
-
} @else {
|
|
51414
|
-
@for (g of role.groups; track g.key) {
|
|
51415
|
-
<div class="perm-group">
|
|
51416
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
51417
|
-
<div class="perm-chips">
|
|
51418
|
-
@for (p of g.perms; track p) {
|
|
51419
|
-
<span class="perm-chip">{{ p }}</span>
|
|
51420
|
-
}
|
|
51421
|
-
</div>
|
|
51422
|
-
</div>
|
|
51423
|
-
}
|
|
51424
|
-
}
|
|
51425
|
-
</div>
|
|
51426
|
-
}
|
|
51427
|
-
</div>
|
|
51428
|
-
}
|
|
51429
|
-
</section>
|
|
51726
|
+
@if (state() === 'error') {
|
|
51727
|
+
<div class="row-actions">
|
|
51728
|
+
<val-button [props]="retryButtonProps()" (click)="onRetry()" />
|
|
51729
|
+
</div>
|
|
51430
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>
|
|
51431
51756
|
}
|
|
51432
51757
|
</div>
|
|
51433
|
-
`, 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"] }]
|
|
51434
51759
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
51435
51760
|
type: Input
|
|
51436
51761
|
}] } });
|
|
51437
51762
|
|
|
51438
51763
|
/**
|
|
51439
|
-
* Helper para montar
|
|
51440
|
-
*
|
|
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
|
|
51441
51767
|
* componente lo lee como fallback de su `@Input() config`.
|
|
51442
51768
|
*
|
|
51769
|
+
* No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
|
|
51770
|
+
*
|
|
51443
51771
|
* @example
|
|
51444
51772
|
* ```ts
|
|
51445
|
-
* //
|
|
51773
|
+
* // app.routes.ts (típicamente bajo settings)
|
|
51446
51774
|
* export const settingsRoutes: Routes = [
|
|
51447
|
-
* ...
|
|
51448
|
-
* { path: 'preferences', loadComponent: () => ... },
|
|
51775
|
+
* ...provideValtechNotificationPreferencesRoutes(), // → notifications
|
|
51449
51776
|
* ];
|
|
51450
51777
|
*
|
|
51451
51778
|
* // con config acotada:
|
|
51452
|
-
* ...
|
|
51453
|
-
* ...
|
|
51454
|
-
* ```
|
|
51455
|
-
*/
|
|
51456
|
-
function provideValtechPermissionsRoutes(opts) {
|
|
51457
|
-
return [
|
|
51458
|
-
{
|
|
51459
|
-
path: opts?.path ?? 'permissions',
|
|
51460
|
-
component: PermissionsViewComponent,
|
|
51461
|
-
data: { permissionsConfig: opts?.config },
|
|
51462
|
-
},
|
|
51463
|
-
];
|
|
51464
|
-
}
|
|
51465
|
-
|
|
51466
|
-
/**
|
|
51467
|
-
* Helper para montar el bloque completo de Settings en una app del factory: el
|
|
51468
|
-
* HUB de navegación (`val-settings-hub`) + TODAS las vistas promovidas (Perfil,
|
|
51469
|
-
* Preferencias, Seguridad, Cuenta, Gestión org, Preferencias de notificaciones,
|
|
51470
|
-
* Permisos) + las rutas propias de la app (`extraRoutes`). Cada `include*` permite
|
|
51471
|
-
* apagar una sección; `hubPath`/`indexRedirectTo`/`*Path` permiten reubicar el hub
|
|
51472
|
-
* y las vistas para preservar URLs existentes.
|
|
51473
|
-
*
|
|
51474
|
-
* showcase lo consume (ver `settings.routes.ts` de showcase): hub en `preferences`,
|
|
51475
|
-
* preferences-view en `general`, index→redirect, `organization/new` por `extraRoutes`.
|
|
51476
|
-
*
|
|
51477
|
-
* @example
|
|
51478
|
-
* ```ts
|
|
51479
|
-
* // app.routes.ts
|
|
51480
|
-
* import { provideValtechSettingsRoutes } from 'valtech-components';
|
|
51481
|
-
*
|
|
51482
|
-
* export const routes: Routes = [
|
|
51483
|
-
* {
|
|
51484
|
-
* path: 'app',
|
|
51485
|
-
* children: [
|
|
51486
|
-
* ...provideValtechSettingsRoutes({
|
|
51487
|
-
* hubConfig: { onLogout: () => auth.logoutWithConfirmation() },
|
|
51488
|
-
* extraRoutes: [
|
|
51489
|
-
* { path: 'security', loadComponent: () => ... },
|
|
51490
|
-
* ],
|
|
51491
|
-
* }),
|
|
51492
|
-
* ],
|
|
51493
|
-
* },
|
|
51494
|
-
* ];
|
|
51779
|
+
* ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
|
|
51780
|
+
* ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
|
|
51495
51781
|
* ```
|
|
51496
51782
|
*/
|
|
51497
|
-
function
|
|
51498
|
-
const basePath = opts?.basePath ?? 'settings';
|
|
51499
|
-
const hubPath = opts?.hubPath ?? '';
|
|
51500
|
-
const includeProfile = opts?.includeProfile ?? true;
|
|
51501
|
-
const includePreferences = opts?.includePreferences ?? true;
|
|
51502
|
-
const includeSecurity = opts?.includeSecurity ?? true;
|
|
51503
|
-
const includeAccount = opts?.includeAccount ?? true;
|
|
51504
|
-
const includeOrganization = opts?.includeOrganization ?? true;
|
|
51505
|
-
const includeNotificationPreferences = opts?.includeNotificationPreferences ?? true;
|
|
51506
|
-
const includePermissions = opts?.includePermissions ?? true;
|
|
51783
|
+
function provideValtechNotificationPreferencesRoutes(opts) {
|
|
51507
51784
|
return [
|
|
51508
51785
|
{
|
|
51509
|
-
path:
|
|
51510
|
-
|
|
51511
|
-
|
|
51512
|
-
? [{ path: '', redirectTo: opts.indexRedirectTo, pathMatch: 'full' }]
|
|
51513
|
-
: []),
|
|
51514
|
-
{
|
|
51515
|
-
path: hubPath,
|
|
51516
|
-
component: SettingsHubComponent,
|
|
51517
|
-
data: { settingsHubConfig: opts?.hubConfig },
|
|
51518
|
-
},
|
|
51519
|
-
...(includeProfile ? provideValtechProfileRoutes({ config: opts?.profileConfig }) : []),
|
|
51520
|
-
...(includePreferences
|
|
51521
|
-
? provideValtechPreferencesRoutes({
|
|
51522
|
-
path: opts?.preferencesPath ?? 'preferences',
|
|
51523
|
-
config: opts?.preferencesConfig,
|
|
51524
|
-
})
|
|
51525
|
-
: []),
|
|
51526
|
-
...(includeSecurity ? provideValtechSecurityRoutes({ config: opts?.securityConfig }) : []),
|
|
51527
|
-
...(includeAccount ? provideValtechAccountRoutes({ config: opts?.accountConfig }) : []),
|
|
51528
|
-
...(includeOrganization
|
|
51529
|
-
? provideValtechOrganizationRoutes({ config: opts?.organizationConfig })
|
|
51530
|
-
: []),
|
|
51531
|
-
...(includeNotificationPreferences
|
|
51532
|
-
? provideValtechNotificationPreferencesRoutes({
|
|
51533
|
-
path: opts?.notificationPreferencesPath ?? 'notifications',
|
|
51534
|
-
config: opts?.notificationPreferencesConfig,
|
|
51535
|
-
})
|
|
51536
|
-
: []),
|
|
51537
|
-
...(includePermissions
|
|
51538
|
-
? provideValtechPermissionsRoutes({ config: opts?.permissionsConfig })
|
|
51539
|
-
: []),
|
|
51540
|
-
...(opts?.extraRoutes ?? []),
|
|
51541
|
-
],
|
|
51786
|
+
path: opts?.path ?? 'notifications',
|
|
51787
|
+
component: NotificationPreferencesViewComponent,
|
|
51788
|
+
data: { notificationPreferencesConfig: opts?.config },
|
|
51542
51789
|
},
|
|
51543
51790
|
];
|
|
51544
51791
|
}
|
|
51545
51792
|
|
|
51546
51793
|
/**
|
|
51547
|
-
*
|
|
51548
|
-
*
|
|
51549
|
-
*
|
|
51550
|
-
*
|
|
51551
|
-
*
|
|
51552
|
-
* - **one-shot** (recomendado por defecto): `() => from(svc.getAllOnce())`.
|
|
51553
|
-
* Carga una vez y completa. Más barato — sin listener vivo.
|
|
51554
|
-
* - **real-time**: `() => svc.getAll()`. Listener Firestore que auto-actualiza.
|
|
51555
|
-
* Solo donde importa (inbox de notificaciones, badges).
|
|
51556
|
-
*
|
|
51557
|
-
* En ambos modos la primera suscripción se gatea por `firebaseAuthReady`
|
|
51558
|
-
* (sesión de Firebase Auth lista) — así el listener/lectura nunca se adjunta
|
|
51559
|
-
* antes de que `request.auth` esté disponible en las reglas de Firestore, lo
|
|
51560
|
-
* que cierra la ventana de `permission-denied` en cold start de PWA.
|
|
51561
|
-
*
|
|
51562
|
-
* El `retry` con backoff corto es solo red de seguridad para reconexiones
|
|
51563
|
-
* transitorias (no para cubrir cold start — eso lo cubre el gate de auth).
|
|
51564
|
-
*/
|
|
51565
|
-
/**
|
|
51566
|
-
* Crea un stream refrescable a partir de una factory de Observable.
|
|
51567
|
-
*
|
|
51568
|
-
* DEBE llamarse en un injection context (field initializer o constructor) —
|
|
51569
|
-
* usa `toSignal`/`toObservable`/`inject` internamente.
|
|
51570
|
-
*
|
|
51571
|
-
* @param factory - Función que produce el Observable a consumir. El caller
|
|
51572
|
-
* decide el modo: `() => from(svc.getAllOnce())` (one-shot) o
|
|
51573
|
-
* `() => svc.getAll()` (real-time).
|
|
51574
|
-
* @param options - Configuración opcional (fallback, retry, gate).
|
|
51575
|
-
*
|
|
51576
|
-
* @example
|
|
51577
|
-
* ```typescript
|
|
51578
|
-
* // one-shot (recomendado por defecto)
|
|
51579
|
-
* private readonly stream = createRefreshableStream(
|
|
51580
|
-
* () => from(this.svc.getAllOnce()),
|
|
51581
|
-
* );
|
|
51582
|
-
*
|
|
51583
|
-
* // real-time (inbox)
|
|
51584
|
-
* private readonly stream = createRefreshableStream(
|
|
51585
|
-
* () => this.notifs.getAll(50),
|
|
51586
|
-
* );
|
|
51587
|
-
*
|
|
51588
|
-
* readonly items = this.stream.data;
|
|
51589
|
-
* readonly loading = this.stream.loading;
|
|
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.
|
|
51590
51798
|
*
|
|
51591
|
-
*
|
|
51592
|
-
*
|
|
51593
|
-
*
|
|
51594
|
-
* ```
|
|
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.
|
|
51595
51802
|
*/
|
|
51596
|
-
|
|
51597
|
-
|
|
51598
|
-
|
|
51599
|
-
|
|
51600
|
-
|
|
51601
|
-
|
|
51602
|
-
|
|
51603
|
-
|
|
51604
|
-
|
|
51605
|
-
|
|
51606
|
-
const _reload = signal(0);
|
|
51607
|
-
const _loading = signal(true);
|
|
51608
|
-
const _error = signal(false);
|
|
51609
|
-
// Gate de Firebase Auth: el stream no abre la primera suscripción hasta que
|
|
51610
|
-
// la sesión esté confirmada. `race` con un `timer` de seguridad — si la
|
|
51611
|
-
// sesión NUNCA se establece (token inválido, signInWithFirebase falló), el
|
|
51612
|
-
// gate igual se libera tras `authGateTimeoutMs` y la factory procede: si no
|
|
51613
|
-
// hay permiso terminará en estado `error`, jamás en skeleton infinito.
|
|
51614
|
-
// Tag los dos inputs del `race` para distinguir en logs cuál ganó: la sesión
|
|
51615
|
-
// de Firebase Auth (`auth-ready`) o el timeout de seguridad (`timeout`).
|
|
51616
|
-
const authGate$ = firebase
|
|
51617
|
-
? race(firebase.firebaseAuthReady$.pipe(map$1(() => 'auth-ready')), timer(authGateTimeoutMs).pipe(map$1(() => 'timeout'))).pipe(take(1), tap(winner => {
|
|
51618
|
-
if (winner === 'timeout') {
|
|
51619
|
-
console.warn(`[RefreshStream] auth gate resolved by TIMEOUT after ${authGateTimeoutMs}ms ` +
|
|
51620
|
-
'(Firebase Auth session never confirmed)');
|
|
51621
|
-
}
|
|
51622
|
-
else {
|
|
51623
|
-
console.log('[RefreshStream] auth gate resolved by firebaseAuthReady$ (session ready)');
|
|
51624
|
-
}
|
|
51625
|
-
}))
|
|
51626
|
-
: of(true);
|
|
51627
|
-
const data = toSignal(toObservable(_reload, { injector }).pipe(tap(() => {
|
|
51628
|
-
_loading.set(true);
|
|
51629
|
-
_error.set(false);
|
|
51630
|
-
}),
|
|
51631
|
-
// Esperar a Firebase Auth listo antes de cada (re)suscripción. Como
|
|
51632
|
-
// `firebaseAuthReady$` completa tras la primera emisión, las reloads
|
|
51633
|
-
// posteriores resuelven el gate de inmediato (auth ya está listo).
|
|
51634
|
-
switchMap(() => authGate$.pipe(switchMap(() => factory().pipe(tap(() => {
|
|
51635
|
-
_loading.set(false);
|
|
51636
|
-
_error.set(false);
|
|
51637
|
-
console.log('[RefreshStream] factory emitted first value — loading=false');
|
|
51638
|
-
}),
|
|
51639
|
-
// Backoff corto exponencial acotado — solo red de seguridad para
|
|
51640
|
-
// reconexiones transitorias, NO para cubrir cold start.
|
|
51641
|
-
retry({
|
|
51642
|
-
count: retryCount,
|
|
51643
|
-
delay: (_err, attempt) => timer(Math.min(retryBaseDelayMs * 2 ** (attempt - 1), 4000)),
|
|
51644
|
-
}),
|
|
51645
|
-
// Techo absoluto para la PRIMERA emisión. Si la factory no emite
|
|
51646
|
-
// ni valor ni error (servicio nunca inicializado → `EMPTY` →
|
|
51647
|
-
// silencio), `timeout` dispara un error → `catchError` → estado
|
|
51648
|
-
// `error`. Nunca skeleton perpetuo. Va DESPUÉS de `retry` para
|
|
51649
|
-
// que cuente el tiempo total, no por intento.
|
|
51650
|
-
timeout({ first: firstEmitTimeoutMs }), catchError(err => {
|
|
51651
|
-
_loading.set(false);
|
|
51652
|
-
_error.set(true);
|
|
51653
|
-
console.error(`[RefreshStream] factory failed/timed out → error state: ${err instanceof Error ? err.message : String(err)}`);
|
|
51654
|
-
return of(fallback);
|
|
51655
|
-
})))))), { initialValue: null, injector });
|
|
51656
|
-
return {
|
|
51657
|
-
data,
|
|
51658
|
-
loading: _loading.asReadonly(),
|
|
51659
|
-
error: _error.asReadonly(),
|
|
51660
|
-
reload: () => _reload.update(n => n + 1),
|
|
51661
|
-
};
|
|
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' }); }
|
|
51662
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 }] });
|
|
51663
51821
|
|
|
51664
51822
|
/**
|
|
51665
|
-
* Defaults i18n (es/en) embebidos en `val-
|
|
51666
|
-
*
|
|
51667
|
-
* (`
|
|
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.
|
|
51668
51826
|
*
|
|
51669
|
-
* Portado
|
|
51827
|
+
* Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
|
|
51670
51828
|
*/
|
|
51671
|
-
const
|
|
51829
|
+
const PERMISSIONS_VIEW_I18N = {
|
|
51672
51830
|
es: {
|
|
51673
|
-
pageTitle: '
|
|
51674
|
-
pageDescription: '
|
|
51675
|
-
|
|
51676
|
-
|
|
51677
|
-
|
|
51678
|
-
|
|
51679
|
-
|
|
51680
|
-
|
|
51681
|
-
|
|
51682
|
-
|
|
51683
|
-
|
|
51684
|
-
|
|
51685
|
-
|
|
51686
|
-
|
|
51687
|
-
|
|
51688
|
-
|
|
51689
|
-
|
|
51690
|
-
|
|
51691
|
-
|
|
51692
|
-
|
|
51693
|
-
|
|
51694
|
-
|
|
51695
|
-
|
|
51696
|
-
|
|
51697
|
-
|
|
51698
|
-
|
|
51699
|
-
|
|
51700
|
-
|
|
51701
|
-
|
|
51702
|
-
|
|
51703
|
-
|
|
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',
|
|
51704
51886
|
},
|
|
51705
51887
|
en: {
|
|
51706
|
-
pageTitle: '
|
|
51707
|
-
pageDescription: '
|
|
51708
|
-
|
|
51709
|
-
|
|
51710
|
-
|
|
51711
|
-
|
|
51712
|
-
|
|
51713
|
-
|
|
51714
|
-
|
|
51715
|
-
|
|
51716
|
-
|
|
51717
|
-
|
|
51718
|
-
|
|
51719
|
-
|
|
51720
|
-
|
|
51721
|
-
|
|
51722
|
-
|
|
51723
|
-
|
|
51724
|
-
|
|
51725
|
-
|
|
51726
|
-
|
|
51727
|
-
|
|
51728
|
-
|
|
51729
|
-
|
|
51730
|
-
|
|
51731
|
-
|
|
51732
|
-
|
|
51733
|
-
|
|
51734
|
-
|
|
51735
|
-
|
|
51736
|
-
|
|
51737
|
-
|
|
51738
|
-
|
|
51739
|
-
|
|
51740
|
-
|
|
51741
|
-
|
|
51742
|
-
|
|
51743
|
-
|
|
51744
|
-
|
|
51745
|
-
|
|
51746
|
-
|
|
51747
|
-
|
|
51748
|
-
|
|
51749
|
-
|
|
51750
|
-
|
|
51751
|
-
|
|
51752
|
-
|
|
51753
|
-
|
|
51754
|
-
|
|
51755
|
-
|
|
51756
|
-
|
|
51757
|
-
|
|
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',
|
|
51937
|
+
},
|
|
51938
|
+
};
|
|
51939
|
+
|
|
51940
|
+
const DEFAULT_NAMESPACE = 'Settings.Permissions';
|
|
51758
51941
|
/**
|
|
51759
|
-
* `val-
|
|
51760
|
-
*
|
|
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.
|
|
51761
51947
|
*
|
|
51762
|
-
*
|
|
51763
|
-
*
|
|
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.
|
|
51764
51952
|
*
|
|
51765
|
-
*
|
|
51766
|
-
*
|
|
51767
|
-
*
|
|
51768
|
-
* (`val-empty-state` + reintentar), empty (`val-empty-state` + CTA), data
|
|
51769
|
-
* (lista agrupada Hoy / Este mes / Anteriores).
|
|
51770
|
-
* - **Abrir** una notif → `NotificationActionService.open()` (markRead +
|
|
51771
|
-
* switch-org + navegar/handoff cross-app). Hook `onNotificationOpen` opcional.
|
|
51772
|
-
* - **Marcar todas como leídas** (gateado por `enableMarkAll`).
|
|
51773
|
-
* - **CTA push-off** cuando el permiso del navegador no está concedido (gateado
|
|
51774
|
-
* por `showPushOffCta`); navega a `settingsNotificationsPath`.
|
|
51775
|
-
* - **Paginación soft** — slice local (`pageSize`, +`pageSize` por click) sobre el
|
|
51776
|
-
* cache del stream; el tope de fetch es `maxLoad`.
|
|
51777
|
-
* - **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.
|
|
51778
51956
|
*
|
|
51779
|
-
* NO renderiza ion-content — vive dentro de val-page-wrapper. `
|
|
51780
|
-
*
|
|
51781
|
-
* (
|
|
51782
|
-
*
|
|
51783
|
-
* `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`).
|
|
51784
51961
|
*/
|
|
51785
|
-
class
|
|
51962
|
+
class PermissionsViewComponent {
|
|
51786
51963
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
51787
51964
|
get ns() {
|
|
51788
51965
|
return this.resolvedConfig().i18nNamespace;
|
|
51789
51966
|
}
|
|
51790
|
-
/** Paso de paginación soft (revela +N por "cargar más"). */
|
|
51791
|
-
get pageStep() {
|
|
51792
|
-
return this.resolvedConfig().pageSize;
|
|
51793
|
-
}
|
|
51794
51967
|
constructor() {
|
|
51795
|
-
this.nav = inject(NavigationService);
|
|
51796
51968
|
this.i18n = inject(I18nService);
|
|
51797
|
-
this.
|
|
51798
|
-
this.
|
|
51799
|
-
this.toast = inject(ToastService);
|
|
51800
|
-
this.errors = inject(ValtechErrorService);
|
|
51969
|
+
this.orgService = inject(OrgService);
|
|
51970
|
+
this.catalog = inject(PermissionCatalogService);
|
|
51801
51971
|
this.auth = inject(AuthService);
|
|
51802
|
-
this.
|
|
51803
|
-
// `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
|
|
51804
|
-
// router (solo lo usa para leer el route data de config como fallback). La
|
|
51805
|
-
// navegación va por `NavigationService` (wrapper opcional sobre `Router`).
|
|
51972
|
+
this.nav = inject(NavigationService);
|
|
51806
51973
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
51807
51974
|
this.resolvedConfig = computed(() => {
|
|
51808
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
51975
|
+
const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
|
|
51809
51976
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
51810
51977
|
return {
|
|
51811
|
-
|
|
51812
|
-
|
|
51813
|
-
settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
|
|
51978
|
+
showSystemRoles: merged.showSystemRoles ?? true,
|
|
51979
|
+
showCustomRoles: merged.showCustomRoles ?? true,
|
|
51814
51980
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE,
|
|
51815
|
-
pageSize: merged.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
51816
|
-
maxLoad: merged.maxLoad ?? DEFAULT_MAX_LOAD,
|
|
51817
|
-
onNotificationOpen: merged.onNotificationOpen,
|
|
51818
|
-
};
|
|
51819
|
-
});
|
|
51820
|
-
this._expanded = signal(new Set());
|
|
51821
|
-
this._pageSize = signal(this.resolvedConfig().pageSize);
|
|
51822
|
-
this._bulkBusy = signal(false);
|
|
51823
|
-
this._loadingMore = signal(false);
|
|
51824
|
-
/**
|
|
51825
|
-
* Push activado = permiso del navegador en `granted`. Si NO está activado,
|
|
51826
|
-
* el user puede leer el inbox (Firestore) pero NO recibirá push en su
|
|
51827
|
-
* dispositivo → mostramos una card que lo invita a ir a configuración.
|
|
51828
|
-
* Se evalúa en cada montaje de la vista.
|
|
51829
|
-
*/
|
|
51830
|
-
this.pushEnabled = signal(this.auth.getNotificationPermissionState() === 'granted');
|
|
51831
|
-
/**
|
|
51832
|
-
* Stream firestore real-time vía el helper estándar `createRefreshableStream`
|
|
51833
|
-
* (modo real-time: factory devuelve un listener vivo `notifs.getAll(maxLoad)`).
|
|
51834
|
-
*
|
|
51835
|
-
* El helper gatea la primera suscripción por `firebaseAuthReady`, aplica un
|
|
51836
|
-
* `retry` corto con backoff y, tras agotar reintentos, emite el fallback (`[]`)
|
|
51837
|
-
* y marca `error` — estado de error en UI, no skeleton infinito. `reload()`
|
|
51838
|
-
* re-suscribe el stream completo (lo usa el pull-to-refresh).
|
|
51839
|
-
*/
|
|
51840
|
-
this._stream = createRefreshableStream(() => this.notifs.getAll(this.resolvedConfig().maxLoad), {
|
|
51841
|
-
fallback: [],
|
|
51842
|
-
// `notifs.getAll()` YA gatea internamente por `firebaseAuthReady`. Si el
|
|
51843
|
-
// stream también gateara, serían DOS esperas de auth apiladas.
|
|
51844
|
-
gateOnFirebaseAuth: false,
|
|
51845
|
-
// Cold start de PWA en iOS puede pasar de 8s en redes lentas; con 15s
|
|
51846
|
-
// evitamos el falso estado de error.
|
|
51847
|
-
firstEmitTimeoutMs: 15000,
|
|
51848
|
-
});
|
|
51849
|
-
/** True si el stream falló tras agotar reintentos → muestra estado de error. */
|
|
51850
|
-
this.streamError = this._stream.error;
|
|
51851
|
-
this.loading = computed(() => this._stream.data() === null);
|
|
51852
|
-
/**
|
|
51853
|
-
* Notifs reales mostradas. Filtramos docs sin título Y sin body — son ruido
|
|
51854
|
-
* (típicamente sentinel/system writes pre-schema con title/body opcionales).
|
|
51855
|
-
*/
|
|
51856
|
-
this.allNotifs = computed(() => (this._stream.data() ?? []).filter(n => !!(n.title && n.title.trim()) || !!(n.body && n.body.trim())));
|
|
51857
|
-
this.hasMore = computed(() => this.allNotifs().length > this._pageSize());
|
|
51858
|
-
this.showMarkAll = computed(() => this.resolvedConfig().enableMarkAll && !this.loading() && this.allNotifs().some(n => !n.isRead));
|
|
51859
|
-
/** Sub-conjunto paginado, agrupado por período. */
|
|
51860
|
-
this.groups = computed(() => {
|
|
51861
|
-
const items = this.allNotifs().slice(0, this._pageSize());
|
|
51862
|
-
const now = new Date();
|
|
51863
|
-
const buckets = {
|
|
51864
|
-
today: [],
|
|
51865
|
-
thisMonth: [],
|
|
51866
|
-
earlier: [],
|
|
51867
51981
|
};
|
|
51868
|
-
for (const n of items) {
|
|
51869
|
-
buckets[this.bucketOf(n, now)].push(n);
|
|
51870
|
-
}
|
|
51871
|
-
const out = [];
|
|
51872
|
-
if (buckets.today.length) {
|
|
51873
|
-
out.push({ key: 'today', label: this.tt('groupToday'), items: buckets.today });
|
|
51874
|
-
}
|
|
51875
|
-
if (buckets.thisMonth.length) {
|
|
51876
|
-
out.push({
|
|
51877
|
-
key: 'thisMonth',
|
|
51878
|
-
label: this.tt('groupThisMonth'),
|
|
51879
|
-
items: buckets.thisMonth,
|
|
51880
|
-
});
|
|
51881
|
-
}
|
|
51882
|
-
if (buckets.earlier.length) {
|
|
51883
|
-
out.push({ key: 'earlier', label: this.tt('groupEarlier'), items: buckets.earlier });
|
|
51884
|
-
}
|
|
51885
|
-
return out;
|
|
51886
|
-
});
|
|
51887
|
-
this.pageTitle = computed(() => this.tt('pageTitle'));
|
|
51888
|
-
this.pageDescription = computed(() => this.tt('pageDescription'));
|
|
51889
|
-
// CTA "activa las notificaciones push" — visible cuando pushEnabled() es false.
|
|
51890
|
-
this.ctaTitle = computed(() => this.tt('pushOffTitle'));
|
|
51891
|
-
this.ctaHint = computed(() => this.tt('pushOffHint'));
|
|
51892
|
-
this.ctaButtonProps = computed(() => ({
|
|
51893
|
-
text: this.tt('pushOffCta'),
|
|
51894
|
-
color: 'dark',
|
|
51895
|
-
fill: 'outline',
|
|
51896
|
-
size: 'small',
|
|
51897
|
-
shape: 'round',
|
|
51898
|
-
type: 'button',
|
|
51899
|
-
}));
|
|
51900
|
-
/**
|
|
51901
|
-
* Estado de error de carga: convierte el error del stream en
|
|
51902
|
-
* `EmptyStateMetadata` via el helper canónico `createErrorStateProps`.
|
|
51903
|
-
* El helper decide variante (`offline` vs `error`) por `isNetwork`.
|
|
51904
|
-
* Null cuando el stream no ha fallado.
|
|
51905
|
-
*/
|
|
51906
|
-
this.errorState = computed(() => {
|
|
51907
|
-
this.i18n.lang();
|
|
51908
|
-
const err = this.streamError();
|
|
51909
|
-
if (!err)
|
|
51910
|
-
return null;
|
|
51911
|
-
return createErrorStateProps(err, {
|
|
51912
|
-
title: {
|
|
51913
|
-
offline: this.tt('offlineTitle'),
|
|
51914
|
-
error: this.tt('loadErrorTitle'),
|
|
51915
|
-
},
|
|
51916
|
-
description: {
|
|
51917
|
-
offline: this.tt('offlineHint'),
|
|
51918
|
-
error: interpretError(err).message || this.tt('loadErrorHint'),
|
|
51919
|
-
},
|
|
51920
|
-
retryLabel: this.tt('retryCta'),
|
|
51921
|
-
onRetry: () => this._stream.reload(),
|
|
51922
|
-
retrying: this.loading(),
|
|
51923
|
-
});
|
|
51924
51982
|
});
|
|
51925
|
-
|
|
51926
|
-
|
|
51927
|
-
|
|
51928
|
-
|
|
51929
|
-
this.
|
|
51930
|
-
|
|
51931
|
-
|
|
51932
|
-
|
|
51933
|
-
icon: 'notifications-off-outline',
|
|
51934
|
-
title: this.tt('empty'),
|
|
51935
|
-
description: this.tt('emptyHint'),
|
|
51936
|
-
action: {
|
|
51937
|
-
label: this.tt('emptyCta'),
|
|
51938
|
-
handler: () => this.onGoToSettings(),
|
|
51939
|
-
},
|
|
51940
|
-
};
|
|
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 ?? '';
|
|
51941
51991
|
});
|
|
51942
|
-
this.
|
|
51943
|
-
|
|
51944
|
-
this.markAllButtonProps = computed(() => ({
|
|
51945
|
-
text: this.tt('markAll'),
|
|
51992
|
+
this.retryButtonProps = computed(() => ({
|
|
51993
|
+
text: this.tt('retry'),
|
|
51946
51994
|
color: 'dark',
|
|
51947
51995
|
fill: 'outline',
|
|
51948
|
-
size: 'small',
|
|
51949
|
-
shape: 'round',
|
|
51950
|
-
type: 'button',
|
|
51951
|
-
icon: { name: 'checkmark-done-outline', slot: 'start' },
|
|
51952
|
-
state: this._bulkBusy() ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
51953
|
-
}));
|
|
51954
|
-
this.loadMoreButtonProps = computed(() => ({
|
|
51955
|
-
text: this.tt('loadMore'),
|
|
51956
|
-
color: 'dark',
|
|
51957
|
-
fill: 'clear',
|
|
51958
51996
|
size: 'default',
|
|
51959
|
-
shape: 'round',
|
|
51960
51997
|
type: 'button',
|
|
51961
|
-
state: this.
|
|
51998
|
+
state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
|
|
51962
51999
|
}));
|
|
51963
52000
|
const ns = this.ns;
|
|
51964
52001
|
if (!this.i18n.hasNamespace(ns)) {
|
|
51965
|
-
this.i18n.registerContent(ns,
|
|
52002
|
+
this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
|
|
51966
52003
|
}
|
|
51967
52004
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
51968
|
-
|
|
51969
|
-
|
|
51970
|
-
this.i18n.lang();
|
|
51971
|
-
this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
|
|
51972
|
-
}, { allowSignalWrites: true });
|
|
51973
|
-
}
|
|
51974
|
-
/**
|
|
51975
|
-
* Registra el pull-to-refresh estándar. Usamos `ngOnInit`/`ngOnDestroy`
|
|
51976
|
-
* (NO `ionViewWillEnter`) porque `val-page-wrapper` usa `<router-outlet>`
|
|
51977
|
-
* plano — los hooks `ionView*` solo disparan dentro de `<ion-router-outlet>`.
|
|
51978
|
-
*/
|
|
51979
|
-
ngOnInit() {
|
|
51980
|
-
this.pageRefresh.register(() => this.reload());
|
|
51981
|
-
}
|
|
51982
|
-
/** Quita el handler al destruir la vista — el refresher desaparece. */
|
|
51983
|
-
ngOnDestroy() {
|
|
51984
|
-
this.pageRefresh.unregister();
|
|
51985
|
-
}
|
|
51986
|
-
/**
|
|
51987
|
-
* Refresh manual: re-suscribe el stream Firestore (listener nuevo) vía el
|
|
51988
|
-
* helper. Resuelve tras un margen breve — el stream es real-time y re-emite
|
|
51989
|
-
* en milisegundos; el delay sólo mantiene el spinner visible lo justo.
|
|
51990
|
-
*/
|
|
51991
|
-
async reload() {
|
|
51992
|
-
this._stream.reload();
|
|
51993
|
-
await new Promise(r => setTimeout(r, 700));
|
|
51994
|
-
}
|
|
51995
|
-
// ---------------------------------------------------------------------------
|
|
51996
|
-
// Per-item helpers (puros — usables desde template).
|
|
51997
|
-
// ---------------------------------------------------------------------------
|
|
51998
|
-
openButtonProps() {
|
|
51999
|
-
return {
|
|
52000
|
-
text: this.tt('open'),
|
|
52001
|
-
color: 'dark',
|
|
52002
|
-
fill: 'clear',
|
|
52003
|
-
size: 'small',
|
|
52004
|
-
shape: 'round',
|
|
52005
|
-
type: 'button',
|
|
52006
|
-
icon: { name: 'open-outline', slot: 'end' },
|
|
52007
|
-
};
|
|
52008
|
-
}
|
|
52009
|
-
isExpanded(id) {
|
|
52010
|
-
return this._expanded().has(id);
|
|
52011
|
-
}
|
|
52012
|
-
toggleExpand(id) {
|
|
52013
|
-
const next = new Set(this._expanded());
|
|
52014
|
-
if (next.has(id))
|
|
52015
|
-
next.delete(id);
|
|
52016
|
-
else
|
|
52017
|
-
next.add(id);
|
|
52018
|
-
this._expanded.set(next);
|
|
52005
|
+
this.load();
|
|
52006
|
+
connectPageRefresh(() => this.load());
|
|
52019
52007
|
}
|
|
52020
|
-
|
|
52021
|
-
|
|
52022
|
-
|
|
52023
|
-
|
|
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
|
+
});
|
|
52024
52037
|
}
|
|
52025
|
-
|
|
52026
|
-
|
|
52027
|
-
|
|
52028
|
-
|
|
52029
|
-
|
|
52030
|
-
|
|
52031
|
-
|
|
52032
|
-
|
|
52033
|
-
|
|
52034
|
-
|
|
52035
|
-
|
|
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;
|
|
52036
52052
|
}
|
|
52037
|
-
|
|
52038
|
-
|
|
52039
|
-
|
|
52040
|
-
|
|
52041
|
-
|
|
52042
|
-
|
|
52043
|
-
|
|
52044
|
-
|
|
52045
|
-
|
|
52046
|
-
|
|
52047
|
-
|
|
52048
|
-
|
|
52049
|
-
|
|
52050
|
-
|
|
52051
|
-
|
|
52052
|
-
case 'system':
|
|
52053
|
-
return 'cog-outline';
|
|
52054
|
-
case 'user':
|
|
52055
|
-
case 'mention':
|
|
52056
|
-
return 'person-outline';
|
|
52057
|
-
case 'document':
|
|
52058
|
-
return 'document-text-outline';
|
|
52059
|
-
default:
|
|
52060
|
-
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);
|
|
52061
52068
|
}
|
|
52062
|
-
})();
|
|
52063
|
-
return { kind: 'ionicon', name, color: 'dark' };
|
|
52064
|
-
}
|
|
52065
|
-
ioniconName(n) {
|
|
52066
|
-
const r = this.resolveIcon(n);
|
|
52067
|
-
return r.kind === 'ionicon' ? r.name : '';
|
|
52068
|
-
}
|
|
52069
|
-
ioniconColor(n) {
|
|
52070
|
-
const r = this.resolveIcon(n);
|
|
52071
|
-
return r.kind === 'ionicon' ? r.color : undefined;
|
|
52072
|
-
}
|
|
52073
|
-
imageSrc(n) {
|
|
52074
|
-
const r = this.resolveIcon(n);
|
|
52075
|
-
return r.kind === 'image' || r.kind === 'avatar' ? r.src : '';
|
|
52076
|
-
}
|
|
52077
|
-
ionColor(token) {
|
|
52078
|
-
if (!token)
|
|
52079
|
-
return 'var(--ion-color-primary)';
|
|
52080
|
-
return `var(--ion-color-${token})`;
|
|
52081
|
-
}
|
|
52082
|
-
relativeTime(n) {
|
|
52083
|
-
const d = n.createdAt instanceof Date ? n.createdAt : null;
|
|
52084
|
-
if (!d)
|
|
52085
|
-
return '';
|
|
52086
|
-
const diffMs = Date.now() - d.getTime();
|
|
52087
|
-
const min = Math.floor(diffMs / 60000);
|
|
52088
|
-
if (min < 1)
|
|
52089
|
-
return this.tt('timeNow');
|
|
52090
|
-
if (min < 60)
|
|
52091
|
-
return this.tt('timeMinutes').replace('{n}', String(min));
|
|
52092
|
-
const h = Math.floor(min / 60);
|
|
52093
|
-
if (h < 24)
|
|
52094
|
-
return this.tt('timeHours').replace('{n}', String(h));
|
|
52095
|
-
const days = Math.floor(h / 24);
|
|
52096
|
-
return this.tt('timeDays').replace('{n}', String(days));
|
|
52097
|
-
}
|
|
52098
|
-
/** Devuelve label "Desde {appName}" si la notif viene de otra app, sino null. */
|
|
52099
|
-
showCrossAppBadge(n) {
|
|
52100
|
-
const currentApp = 'showcase';
|
|
52101
|
-
if (!n.appId || n.appId === currentApp)
|
|
52102
|
-
return null;
|
|
52103
|
-
return this.tt('crossApp').replace('{app}', n.appId);
|
|
52104
|
-
}
|
|
52105
|
-
// ---------------------------------------------------------------------------
|
|
52106
|
-
// Actions
|
|
52107
|
-
// ---------------------------------------------------------------------------
|
|
52108
|
-
async onOpen(n) {
|
|
52109
|
-
try {
|
|
52110
|
-
const result = await this.action.open(n);
|
|
52111
|
-
this.resolvedConfig().onNotificationOpen?.(n, result);
|
|
52112
52069
|
}
|
|
52113
|
-
|
|
52114
|
-
|
|
52115
|
-
|
|
52116
|
-
fallbackKey: 'openError',
|
|
52117
|
-
i18nNamespace: this.ns,
|
|
52118
|
-
});
|
|
52070
|
+
const groups = [];
|
|
52071
|
+
if (appPerms.length) {
|
|
52072
|
+
groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
|
|
52119
52073
|
}
|
|
52120
|
-
|
|
52121
|
-
|
|
52122
|
-
|
|
52123
|
-
|
|
52124
|
-
|
|
52125
|
-
try {
|
|
52126
|
-
await this.notifs.markAllAsRead();
|
|
52127
|
-
this.toast.show({
|
|
52128
|
-
message: this.tt('markedAllSuccess'),
|
|
52129
|
-
color: 'dark',
|
|
52130
|
-
duration: 2500,
|
|
52131
|
-
position: 'top',
|
|
52074
|
+
if (orgPerms.length) {
|
|
52075
|
+
groups.push({
|
|
52076
|
+
key: 'org',
|
|
52077
|
+
label: this.tt('groupOrg'),
|
|
52078
|
+
perms: orgPerms,
|
|
52132
52079
|
});
|
|
52133
52080
|
}
|
|
52134
|
-
|
|
52135
|
-
|
|
52136
|
-
|
|
52137
|
-
|
|
52138
|
-
|
|
52081
|
+
for (const [appId, perms] of otherByApp) {
|
|
52082
|
+
groups.push({
|
|
52083
|
+
key: `other:${appId}`,
|
|
52084
|
+
label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
|
|
52085
|
+
perms,
|
|
52139
52086
|
});
|
|
52140
52087
|
}
|
|
52141
|
-
|
|
52142
|
-
|
|
52143
|
-
|
|
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
|
+
};
|
|
52144
52096
|
}
|
|
52145
|
-
|
|
52146
|
-
|
|
52147
|
-
return;
|
|
52148
|
-
this._loadingMore.set(true);
|
|
52149
|
-
// Slice viene del cache local — no hay fetch real, pero un breve spinner
|
|
52150
|
-
// confirma al user que el click registró y suaviza la aparición de items.
|
|
52151
|
-
setTimeout(() => {
|
|
52152
|
-
this._pageSize.update(n => n + this.pageStep);
|
|
52153
|
-
this._loadingMore.set(false);
|
|
52154
|
-
}, 250);
|
|
52097
|
+
tt(key) {
|
|
52098
|
+
return this.i18n.t(key, this.ns);
|
|
52155
52099
|
}
|
|
52156
|
-
|
|
52157
|
-
|
|
52100
|
+
appLabel(appId) {
|
|
52101
|
+
const key = `app_${appId}`;
|
|
52102
|
+
const label = this.tt(key);
|
|
52103
|
+
return label === key ? appId : label;
|
|
52158
52104
|
}
|
|
52159
|
-
|
|
52160
|
-
|
|
52161
|
-
|
|
52162
|
-
|
|
52163
|
-
|
|
52164
|
-
|
|
52165
|
-
|
|
52166
|
-
|
|
52167
|
-
|
|
52168
|
-
|
|
52169
|
-
|
|
52170
|
-
|
|
52171
|
-
}
|
|
52172
|
-
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);
|
|
52173
52117
|
}
|
|
52174
|
-
|
|
52175
|
-
|
|
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}`;
|
|
52176
52126
|
}
|
|
52177
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
52178
|
-
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: `
|
|
52179
52129
|
<div class="page">
|
|
52180
52130
|
<header class="page-header">
|
|
52181
|
-
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle
|
|
52131
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
|
|
52182
52132
|
<val-title
|
|
52183
52133
|
[props]="{
|
|
52184
52134
|
size: 'large',
|
|
52185
52135
|
color: 'dark',
|
|
52186
52136
|
bold: false,
|
|
52187
|
-
content: pageDescription
|
|
52137
|
+
content: tt('pageDescription'),
|
|
52188
52138
|
}"
|
|
52189
52139
|
/>
|
|
52190
|
-
@if (showMarkAll()) {
|
|
52191
|
-
<div class="row-actions">
|
|
52192
|
-
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
52193
|
-
</div>
|
|
52194
|
-
}
|
|
52195
52140
|
</header>
|
|
52196
52141
|
|
|
52197
|
-
@if (resolvedConfig().showPushOffCta && !pushEnabled()) {
|
|
52198
|
-
<section class="notif-cta">
|
|
52199
|
-
<ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
|
|
52200
|
-
<div class="notif-cta__body">
|
|
52201
|
-
<val-text
|
|
52202
|
-
[props]="{
|
|
52203
|
-
content: ctaTitle(),
|
|
52204
|
-
size: 'medium',
|
|
52205
|
-
color: 'dark',
|
|
52206
|
-
bold: true,
|
|
52207
|
-
}"
|
|
52208
|
-
/>
|
|
52209
|
-
<val-text
|
|
52210
|
-
[props]="{
|
|
52211
|
-
content: ctaHint(),
|
|
52212
|
-
size: 'small',
|
|
52213
|
-
color: 'medium',
|
|
52214
|
-
bold: false,
|
|
52215
|
-
}"
|
|
52216
|
-
/>
|
|
52217
|
-
<div class="row-actions">
|
|
52218
|
-
<val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
|
|
52219
|
-
</div>
|
|
52220
|
-
</div>
|
|
52221
|
-
</section>
|
|
52222
|
-
}
|
|
52223
|
-
|
|
52224
52142
|
@if (loading()) {
|
|
52225
|
-
<
|
|
52226
|
-
|
|
52227
|
-
|
|
52228
|
-
} @else if (
|
|
52229
|
-
<
|
|
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>
|
|
52230
52158
|
} @else {
|
|
52231
|
-
@
|
|
52232
|
-
<section class="
|
|
52233
|
-
<
|
|
52234
|
-
|
|
52235
|
-
|
|
52236
|
-
|
|
52237
|
-
|
|
52238
|
-
|
|
52239
|
-
|
|
52240
|
-
|
|
52241
|
-
|
|
52242
|
-
|
|
52243
|
-
|
|
52244
|
-
|
|
52245
|
-
|
|
52246
|
-
|
|
52247
|
-
|
|
52248
|
-
|
|
52249
|
-
|
|
52250
|
-
|
|
52251
|
-
|
|
52252
|
-
|
|
52253
|
-
|
|
52254
|
-
|
|
52255
|
-
|
|
52256
|
-
|
|
52257
|
-
|
|
52258
|
-
|
|
52259
|
-
|
|
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
|
+
/>
|
|
52260
52191
|
</div>
|
|
52261
|
-
|
|
52262
|
-
|
|
52263
|
-
|
|
52264
|
-
|
|
52265
|
-
|
|
52266
|
-
|
|
52267
|
-
size: 'medium',
|
|
52268
|
-
color: 'dark',
|
|
52269
|
-
bold: true,
|
|
52270
|
-
}"
|
|
52271
|
-
/>
|
|
52272
|
-
<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>
|
|
52273
52198
|
</div>
|
|
52274
|
-
|
|
52275
|
-
|
|
52276
|
-
|
|
52277
|
-
|
|
52278
|
-
|
|
52279
|
-
|
|
52280
|
-
|
|
52281
|
-
|
|
52282
|
-
|
|
52283
|
-
}
|
|
52284
|
-
@if (showCrossAppBadge(n); as appName) {
|
|
52285
|
-
<span class="notif__app">{{ appName }}</span>
|
|
52286
|
-
}
|
|
52287
|
-
@if (n.actionRoute) {
|
|
52288
|
-
<div class="row-actions row-actions--end">
|
|
52289
|
-
<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>
|
|
52290
52208
|
</div>
|
|
52291
52209
|
}
|
|
52292
|
-
|
|
52293
|
-
</
|
|
52210
|
+
}
|
|
52211
|
+
</div>
|
|
52294
52212
|
}
|
|
52295
|
-
</
|
|
52213
|
+
</div>
|
|
52296
52214
|
</section>
|
|
52297
52215
|
}
|
|
52298
52216
|
|
|
52299
|
-
@if (
|
|
52300
|
-
<
|
|
52301
|
-
<
|
|
52302
|
-
|
|
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>
|
|
52303
52277
|
}
|
|
52304
52278
|
}
|
|
52305
52279
|
</div>
|
|
52306
|
-
`, 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"] }] }); }
|
|
52307
52281
|
}
|
|
52308
|
-
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: [{
|
|
52309
52283
|
type: Component,
|
|
52310
|
-
args: [{ selector: 'val-
|
|
52311
|
-
IonIcon,
|
|
52312
|
-
DisplayComponent,
|
|
52313
|
-
TitleComponent,
|
|
52314
|
-
TextComponent,
|
|
52315
|
-
ButtonComponent,
|
|
52316
|
-
SkeletonLayoutComponent,
|
|
52317
|
-
EmptyStateComponent,
|
|
52318
|
-
], template: `
|
|
52284
|
+
args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
|
|
52319
52285
|
<div class="page">
|
|
52320
52286
|
<header class="page-header">
|
|
52321
|
-
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle
|
|
52287
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
|
|
52322
52288
|
<val-title
|
|
52323
52289
|
[props]="{
|
|
52324
52290
|
size: 'large',
|
|
52325
52291
|
color: 'dark',
|
|
52326
52292
|
bold: false,
|
|
52327
|
-
content: pageDescription
|
|
52293
|
+
content: tt('pageDescription'),
|
|
52328
52294
|
}"
|
|
52329
52295
|
/>
|
|
52330
|
-
@if (showMarkAll()) {
|
|
52331
|
-
<div class="row-actions">
|
|
52332
|
-
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
52333
|
-
</div>
|
|
52334
|
-
}
|
|
52335
52296
|
</header>
|
|
52336
52297
|
|
|
52337
|
-
@if (resolvedConfig().showPushOffCta && !pushEnabled()) {
|
|
52338
|
-
<section class="notif-cta">
|
|
52339
|
-
<ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
|
|
52340
|
-
<div class="notif-cta__body">
|
|
52341
|
-
<val-text
|
|
52342
|
-
[props]="{
|
|
52343
|
-
content: ctaTitle(),
|
|
52344
|
-
size: 'medium',
|
|
52345
|
-
color: 'dark',
|
|
52346
|
-
bold: true,
|
|
52347
|
-
}"
|
|
52348
|
-
/>
|
|
52349
|
-
<val-text
|
|
52350
|
-
[props]="{
|
|
52351
|
-
content: ctaHint(),
|
|
52352
|
-
size: 'small',
|
|
52353
|
-
color: 'medium',
|
|
52354
|
-
bold: false,
|
|
52355
|
-
}"
|
|
52356
|
-
/>
|
|
52357
|
-
<div class="row-actions">
|
|
52358
|
-
<val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
|
|
52359
|
-
</div>
|
|
52360
|
-
</div>
|
|
52361
|
-
</section>
|
|
52362
|
-
}
|
|
52363
|
-
|
|
52364
52298
|
@if (loading()) {
|
|
52365
|
-
<
|
|
52366
|
-
|
|
52367
|
-
|
|
52368
|
-
} @else if (
|
|
52369
|
-
<
|
|
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>
|
|
52370
52314
|
} @else {
|
|
52371
|
-
@
|
|
52372
|
-
<section class="
|
|
52373
|
-
<
|
|
52374
|
-
|
|
52375
|
-
|
|
52376
|
-
|
|
52377
|
-
|
|
52378
|
-
|
|
52379
|
-
|
|
52380
|
-
|
|
52381
|
-
|
|
52382
|
-
|
|
52383
|
-
|
|
52384
|
-
|
|
52385
|
-
|
|
52386
|
-
|
|
52387
|
-
|
|
52388
|
-
|
|
52389
|
-
|
|
52390
|
-
|
|
52391
|
-
|
|
52392
|
-
|
|
52393
|
-
|
|
52394
|
-
|
|
52395
|
-
|
|
52396
|
-
|
|
52397
|
-
|
|
52398
|
-
|
|
52399
|
-
|
|
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
|
+
/>
|
|
52400
52347
|
</div>
|
|
52401
|
-
|
|
52402
|
-
|
|
52403
|
-
|
|
52404
|
-
|
|
52405
|
-
|
|
52406
|
-
|
|
52407
|
-
size: 'medium',
|
|
52408
|
-
color: 'dark',
|
|
52409
|
-
bold: true,
|
|
52410
|
-
}"
|
|
52411
|
-
/>
|
|
52412
|
-
<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>
|
|
52413
52354
|
</div>
|
|
52414
|
-
|
|
52415
|
-
|
|
52416
|
-
|
|
52417
|
-
|
|
52418
|
-
|
|
52419
|
-
|
|
52420
|
-
|
|
52421
|
-
|
|
52422
|
-
|
|
52423
|
-
}
|
|
52424
|
-
@if (showCrossAppBadge(n); as appName) {
|
|
52425
|
-
<span class="notif__app">{{ appName }}</span>
|
|
52426
|
-
}
|
|
52427
|
-
@if (n.actionRoute) {
|
|
52428
|
-
<div class="row-actions row-actions--end">
|
|
52429
|
-
<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>
|
|
52430
52364
|
</div>
|
|
52431
52365
|
}
|
|
52432
|
-
|
|
52433
|
-
</
|
|
52366
|
+
}
|
|
52367
|
+
</div>
|
|
52434
52368
|
}
|
|
52435
|
-
</
|
|
52369
|
+
</div>
|
|
52436
52370
|
</section>
|
|
52437
52371
|
}
|
|
52438
52372
|
|
|
52439
|
-
@if (
|
|
52440
|
-
<
|
|
52441
|
-
<
|
|
52442
|
-
|
|
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>
|
|
52443
52433
|
}
|
|
52444
52434
|
}
|
|
52445
52435
|
</div>
|
|
52446
|
-
`, 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"] }]
|
|
52447
52437
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
52448
52438
|
type: Input
|
|
52449
52439
|
}] } });
|
|
52450
52440
|
|
|
52451
52441
|
/**
|
|
52452
|
-
* Helper para montar
|
|
52453
|
-
*
|
|
52454
|
-
*
|
|
52455
|
-
* `@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`.
|
|
52456
52445
|
*
|
|
52457
52446
|
* @example
|
|
52458
52447
|
* ```ts
|
|
52459
|
-
* //
|
|
52460
|
-
* export const
|
|
52461
|
-
* ...
|
|
52462
|
-
* { path: '
|
|
52448
|
+
* // settings.routes.ts
|
|
52449
|
+
* export const settingsRoutes: Routes = [
|
|
52450
|
+
* ...provideValtechPermissionsRoutes(), // → /app/settings/permissions
|
|
52451
|
+
* { path: 'preferences', loadComponent: () => ... },
|
|
52463
52452
|
* ];
|
|
52464
52453
|
*
|
|
52465
52454
|
* // con config acotada:
|
|
52466
|
-
* ...
|
|
52467
|
-
* ...
|
|
52468
|
-
* config: { settingsNotificationsPath: '/app/preferences/push' },
|
|
52469
|
-
* }),
|
|
52455
|
+
* ...provideValtechPermissionsRoutes({ config: { showCustomRoles: false } }),
|
|
52456
|
+
* ...provideValtechPermissionsRoutes({ path: 'roles' }),
|
|
52470
52457
|
* ```
|
|
52471
52458
|
*/
|
|
52472
|
-
function
|
|
52459
|
+
function provideValtechPermissionsRoutes(opts) {
|
|
52473
52460
|
return [
|
|
52474
52461
|
{
|
|
52475
|
-
path: opts?.path ?? '
|
|
52476
|
-
component:
|
|
52477
|
-
data: {
|
|
52462
|
+
path: opts?.path ?? 'permissions',
|
|
52463
|
+
component: PermissionsViewComponent,
|
|
52464
|
+
data: { permissionsConfig: opts?.config },
|
|
52478
52465
|
},
|
|
52479
52466
|
];
|
|
52480
52467
|
}
|