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