valtech-components 2.0.1002 → 2.0.1003

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