valtech-components 2.0.983 → 2.0.984

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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
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, Injector, isSignal, ViewEncapsulation } from '@angular/core';
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
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, shareReplay, race, timer, forkJoin } from 'rxjs';
4
4
  import { catchError, switchMap, finalize, filter, take, map as map$1, tap, retry, timeout, debounceTime as debounceTime$1, takeUntil as takeUntil$1 } from 'rxjs/operators';
5
5
  import * as i1$3 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, IonDatetimeButton, IonInput, IonSelect, IonSelectOption, IonPopover, IonList, IonItem, IonRadioGroup, IonRadio, IonRange, IonSearchbar, IonSegment, IonSegmentButton, IonToggle, IonRippleEffect, IonAccordion, IonAccordionGroup, IonTabBar, IonTabButton, IonBadge, IonBreadcrumb, IonBreadcrumbs, IonChip, IonNote, ToastController as ToastController$1, IonCol, IonRow, IonRefresher, IonRefresherContent, IonMenuButton, IonFooter, IonListHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonGrid, MenuController, IonMenu, IonMenuToggle, 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, 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, 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.983';
57
+ const VERSION = '2.0.984';
58
58
 
59
59
  // Control de estado de refresco (singleton a nivel de módulo)
60
60
  let isRefreshing = false;
@@ -41652,7 +41652,7 @@ const PROFILE_VIEW_I18N = {
41652
41652
  const HANDLE_PATTERN = /^[a-z_][a-z0-9_]*$/;
41653
41653
  const HANDLE_MIN = 3;
41654
41654
  const HANDLE_MAX = 20;
41655
- const DEFAULT_NAMESPACE$1 = 'Settings.Profile';
41655
+ const DEFAULT_NAMESPACE$2 = 'Settings.Profile';
41656
41656
  /**
41657
41657
  * `val-profile-view` — vista Perfil full-feature autocontenida (organism).
41658
41658
  *
@@ -41702,7 +41702,7 @@ class ProfileViewComponent {
41702
41702
  showPhone: merged.showPhone ?? true,
41703
41703
  showShareCta: merged.showShareCta ?? true,
41704
41704
  profileBaseUrl: merged.profileBaseUrl ?? '',
41705
- i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$1,
41705
+ i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$2,
41706
41706
  onSaved: merged.onSaved,
41707
41707
  onAvatarUploaded: merged.onAvatarUploaded,
41708
41708
  };
@@ -42616,7 +42616,7 @@ const PREFERENCES_VIEW_I18N = {
42616
42616
  },
42617
42617
  };
42618
42618
 
42619
- const DEFAULT_NAMESPACE = 'Settings.General';
42619
+ const DEFAULT_NAMESPACE$1 = 'Settings.General';
42620
42620
  const DEFAULT_LANGUAGES = ['es', 'en'];
42621
42621
  /**
42622
42622
  * `val-preferences-view` — vista de preferencias de UI cross-app (tema +
@@ -42653,7 +42653,7 @@ class PreferencesViewComponent {
42653
42653
  showLanguage: merged.showLanguage ?? true,
42654
42654
  showFontSize: merged.showFontSize ?? true,
42655
42655
  supportedLanguages: merged.supportedLanguages ?? DEFAULT_LANGUAGES,
42656
- i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE,
42656
+ i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$1,
42657
42657
  onThemeChanged: merged.onThemeChanged,
42658
42658
  onLanguageChanged: merged.onLanguageChanged,
42659
42659
  onFontSizeChanged: merged.onFontSizeChanged,
@@ -43357,387 +43357,2308 @@ function provideValtechSettingsRoutes(opts) {
43357
43357
  ];
43358
43358
  }
43359
43359
 
43360
- /** Built-in label sets for the three platform locales. */
43361
- const CALLOUT_LABELS = {
43362
- es: {
43363
- NOTE: 'Nota',
43364
- TIP: 'Tip',
43365
- INFO: 'Info',
43366
- IMPORTANT: 'Importante',
43367
- WARNING: 'Atención',
43368
- CAUTION: 'Precaución',
43369
- },
43370
- en: {
43371
- NOTE: 'Note',
43372
- TIP: 'Tip',
43373
- INFO: 'Info',
43374
- IMPORTANT: 'Important',
43375
- WARNING: 'Warning',
43376
- CAUTION: 'Caution',
43377
- },
43378
- pt: {
43379
- NOTE: 'Nota',
43380
- TIP: 'Dica',
43381
- INFO: 'Info',
43382
- IMPORTANT: 'Importante',
43383
- WARNING: 'Atenção',
43384
- CAUTION: 'Cuidado',
43385
- },
43386
- };
43387
- /**
43388
- * Per-callout-kind color (Ionic semantic color). Fixed across locales since
43389
- * red = danger no matter what language you speak.
43390
- */
43391
- const CALLOUT_COLORS = {
43392
- NOTE: 'primary',
43393
- TIP: 'success',
43394
- INFO: 'tertiary',
43395
- IMPORTANT: 'warning',
43396
- WARNING: 'warning',
43397
- CAUTION: 'danger',
43398
- };
43399
- const BOX_DRAWING = /[┌┐└┘├┤┬┴┼─│╔╗╚╝═║]/;
43400
- const CALLOUT_RE = /^>\s*\[!(NOTE|TIP|INFO|WARNING|CAUTION|IMPORTANT)\]\s*(.*)$/i;
43401
- const FENCE_RE = /^```([\w-]*)\s*$/;
43402
- const HEADING_RE = /^(#{1,6})\s+(.+)$/;
43403
- const SEPARATOR_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
43404
- const UNORDERED_RE = /^\s*[-*+]\s+(.+)$/;
43405
- const CHECKLIST_RE = /^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/;
43406
- const ORDERED_RE = /^\s*\d+\.\s+(.+)$/;
43407
- const TABLE_DIVIDER_RE = /^\s*\|?[\s:|-]+\|?\s*$/;
43408
- const TABLE_ROW_RE = /^\s*\|(.+)\|\s*$/;
43360
+ addIcons({ shieldCheckmarkOutline, checkmarkCircleOutline });
43409
43361
  /**
43410
- * Pure Markdown ArticleMetadata parser. No Angular deps usable from Node scripts
43411
- * (build-time generation) and from the Angular `MarkdownArticleParserService` wrapper.
43362
+ * `val-login-attempt-modal` modal de confirmación de intento de inicio de sesión.
43412
43363
  *
43413
- * Supported syntax:
43414
- * - Headings (#, ##, ### …) title / subtitle
43415
- * - Paragraphs paragraph (with `processLinks` + `allowPartialBold`)
43416
- * - Lists (-, *, 1.) → unordered / ordered list (- [ ] / - [x] for checklist)
43417
- * - Blockquotes (>) → quote, with GitHub callouts `> [!NOTE|TIP|INFO|WARNING|CAUTION|IMPORTANT]` mapped to notes
43418
- * - Code fences (```lang) code
43419
- * - Box-drawing ASCII art (┌┐└┘│─) auto-wrapped as code
43420
- * - Tables flattened into paragraphs with bold keys
43421
- * - Horizontal rules (---, ***, ___) → separator
43364
+ * Se abre cuando el usuario llega a la vista Seguridad vía
43365
+ * `?token=...` desde el email/push de "nuevo acceso". Muestra el dispositivo
43366
+ * detectado y dos acciones: confirmar que fue uno mismo (cierra sin hacer nada)
43367
+ * o bloquear el dispositivo (ejecuta el token de acción).
43368
+ *
43369
+ * Inputs vía `componentProps` del ModalService. Sin `@Output` (no funcionan con
43370
+ * `modalController.create()`) usa callback `onBlock` y `_modalRef.dismiss`.
43371
+ * Recibe los labels ya traducidos (agnóstico a i18n).
43372
+ *
43373
+ * Header canónico (Regla #5): sin `ion-title`, botón Cerrar texto en `slot=end`,
43374
+ * título en el body con `val-display`.
43422
43375
  */
43423
- function parseMarkdownArticle(markdown, options) {
43424
- const lines = normalize(markdown).split('\n');
43425
- const elements = [];
43426
- const labels = options?.calloutLabels ??
43427
- (options?.locale ? CALLOUT_LABELS[options.locale] : CALLOUT_LABELS.es);
43428
- let i = 0;
43429
- while (i < lines.length) {
43430
- const line = lines[i];
43431
- if (line.trim() === '') {
43432
- i++;
43433
- continue;
43434
- }
43435
- const fence = line.match(FENCE_RE);
43436
- if (fence) {
43437
- const lang = fence[1] || undefined;
43438
- const body = [];
43439
- i++;
43440
- while (i < lines.length && !FENCE_RE.test(lines[i])) {
43441
- body.push(lines[i]);
43442
- i++;
43443
- }
43444
- i++;
43445
- elements.push({
43446
- type: 'code',
43447
- props: { code: body.join('\n'), language: lang, theme: 'dark' },
43448
- });
43449
- continue;
43450
- }
43451
- if (BOX_DRAWING.test(line)) {
43452
- const body = [];
43453
- while (i < lines.length && (lines[i].trim() === '' || BOX_DRAWING.test(lines[i]))) {
43454
- body.push(lines[i]);
43455
- i++;
43456
- }
43457
- while (body.length && body[body.length - 1].trim() === '')
43458
- body.pop();
43459
- elements.push({
43460
- type: 'code',
43461
- props: { code: body.join('\n'), language: 'text', theme: 'dark' },
43462
- });
43463
- continue;
43464
- }
43465
- if (SEPARATOR_RE.test(line)) {
43466
- elements.push({ type: 'separator', props: { style: 'line' } });
43467
- i++;
43468
- continue;
43469
- }
43470
- const heading = line.match(HEADING_RE);
43471
- if (heading) {
43472
- elements.push(makeHeading(heading[1].length, heading[2].trim()));
43473
- i++;
43474
- continue;
43475
- }
43476
- if (line.startsWith('>')) {
43477
- const block = [];
43478
- while (i < lines.length && lines[i].startsWith('>')) {
43479
- block.push(lines[i]);
43480
- i++;
43481
- }
43482
- elements.push(makeQuoteOrCallout(block, labels));
43483
- continue;
43484
- }
43485
- if (TABLE_ROW_RE.test(line)) {
43486
- const rows = [];
43487
- while (i < lines.length && TABLE_ROW_RE.test(lines[i])) {
43488
- rows.push(lines[i]);
43489
- i++;
43490
- }
43491
- elements.push(...makeTable(rows));
43492
- continue;
43493
- }
43494
- if (CHECKLIST_RE.test(line) || UNORDERED_RE.test(line) || ORDERED_RE.test(line)) {
43495
- const listType = CHECKLIST_RE.test(line)
43496
- ? 'checklist'
43497
- : ORDERED_RE.test(line)
43498
- ? 'ordered'
43499
- : 'unordered';
43500
- const items = [];
43501
- while (i < lines.length && lines[i].trim() !== '') {
43502
- const cur = lines[i];
43503
- const check = cur.match(CHECKLIST_RE);
43504
- const unord = cur.match(UNORDERED_RE);
43505
- const ord = cur.match(ORDERED_RE);
43506
- if (check)
43507
- items.push({ text: processInlineBold(check[2].trim()) });
43508
- else if (ord && listType === 'ordered')
43509
- items.push({ text: processInlineBold(ord[1].trim()) });
43510
- else if (unord && listType !== 'ordered')
43511
- items.push({ text: processInlineBold(unord[1].trim()) });
43512
- else
43513
- break;
43514
- i++;
43515
- }
43516
- elements.push({ type: 'list', props: { items, listType } });
43517
- continue;
43376
+ class LoginAttemptModalComponent {
43377
+ constructor() {
43378
+ /** Título del modal (i18n ya resuelto). */
43379
+ this.title = '';
43380
+ /** Texto explicativo. */
43381
+ this.body = '';
43382
+ /** Descripción del dispositivo (navegador · OS · ubicación). */
43383
+ this.deviceLabel = '';
43384
+ /** Metadata secundaria (fecha, IP). Opcional. */
43385
+ this.metaLabel = '';
43386
+ /** Mensaje mostrado tras bloquear. */
43387
+ this.doneLabel = '';
43388
+ /** Labels de botones. */
43389
+ this.mineLabel = '';
43390
+ this.blockLabel = '';
43391
+ this.closeLabel = '';
43392
+ /** `true` si el dispositivo es móvil — cambia el icono. */
43393
+ this.isMobile = false;
43394
+ /** `true` tras bloquear con éxito — cambia el modal a estado "hecho". */
43395
+ this.done = signal(false);
43396
+ this.working = false;
43397
+ }
43398
+ get deviceIcon() {
43399
+ return this.isMobile ? 'phone-portrait-outline' : 'laptop-outline';
43400
+ }
43401
+ /** "Sí, fui yo" — cierra sin acción. El dispositivo ya está activo. */
43402
+ confirmMine() {
43403
+ this._modalRef?.dismiss(undefined, 'mine');
43404
+ }
43405
+ /** "No fui yo" — ejecuta el bloqueo y pasa a estado done. */
43406
+ async confirmBlock() {
43407
+ if (this.working || !this.onBlock)
43408
+ return;
43409
+ this.working = true;
43410
+ try {
43411
+ await this.onBlock();
43412
+ this.done.set(true);
43518
43413
  }
43519
- const paragraph = [];
43520
- while (i < lines.length && lines[i].trim() !== '' && !startsNewBlock(lines[i])) {
43521
- paragraph.push(lines[i]);
43522
- i++;
43414
+ catch {
43415
+ // El parent muestra el toast de error; cerramos el modal.
43416
+ this._modalRef?.dismiss(undefined, 'error');
43523
43417
  }
43524
- const text = processInlineBold(paragraph.join(' ').trim());
43525
- if (text) {
43526
- elements.push({
43527
- type: 'paragraph',
43528
- props: {
43529
- content: text,
43530
- size: 'medium',
43531
- color: 'dark',
43532
- bold: false,
43533
- processLinks: true,
43534
- allowPartialBold: true,
43535
- },
43536
- });
43418
+ finally {
43419
+ this.working = false;
43537
43420
  }
43538
43421
  }
43539
- // Strip parser-only options before merging into ArticleMetadata
43540
- const { locale: _l, calloutLabels: _c, ...metadataOverrides } = options ?? {};
43541
- return {
43542
- elements,
43543
- maxWidth: '900px',
43544
- centered: true,
43545
- theme: 'auto',
43546
- ...metadataOverrides,
43547
- };
43548
- }
43549
- function normalize(md) {
43550
- return md.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
43551
- }
43552
- function startsNewBlock(line) {
43553
- return (HEADING_RE.test(line) ||
43554
- FENCE_RE.test(line) ||
43555
- SEPARATOR_RE.test(line) ||
43556
- TABLE_ROW_RE.test(line) ||
43557
- UNORDERED_RE.test(line) ||
43558
- ORDERED_RE.test(line) ||
43559
- CHECKLIST_RE.test(line) ||
43560
- line.startsWith('>') ||
43561
- BOX_DRAWING.test(line));
43562
- }
43563
- function processInlineBold(text) {
43564
- return text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
43565
- }
43566
- function makeHeading(level, content) {
43567
- if (level === 1) {
43422
+ close() {
43423
+ this._modalRef?.dismiss(undefined, this.done() ? 'blocked' : 'close');
43424
+ }
43425
+ get mineBtn() {
43568
43426
  return {
43569
- type: 'title',
43570
- props: { content, size: 'xlarge', color: 'dark', bold: true },
43427
+ text: this.mineLabel,
43428
+ color: 'medium',
43429
+ fill: 'outline',
43430
+ size: 'default',
43431
+ type: 'button',
43432
+ state: ComponentStates.ENABLED,
43571
43433
  };
43572
43434
  }
43573
- const size = level === 2 ? 'large' : level === 3 ? 'medium' : 'small';
43574
- return {
43575
- type: 'subtitle',
43576
- props: { content, size, color: 'dark', bold: true },
43577
- };
43578
- }
43579
- function makeQuoteOrCallout(block, labels) {
43580
- const first = block[0];
43581
- const callout = first.match(CALLOUT_RE);
43582
- const lines = block.map(l => l.replace(/^>\s?/, ''));
43583
- if (callout) {
43584
- const type = callout[1].toUpperCase();
43585
- const firstLineRest = callout[2] || '';
43586
- const rest = lines.slice(1).join(' ').trim();
43587
- const text = processInlineBold([firstLineRest, rest].filter(Boolean).join(' ').trim());
43588
- return makeNote(type, text, labels);
43435
+ get blockBtn() {
43436
+ return {
43437
+ text: this.blockLabel,
43438
+ color: 'danger',
43439
+ fill: 'solid',
43440
+ size: 'default',
43441
+ type: 'button',
43442
+ state: ComponentStates.ENABLED,
43443
+ };
43589
43444
  }
43590
- const text = processInlineBold(lines.join(' ').trim());
43591
- return {
43592
- type: 'quote',
43593
- props: {
43594
- content: text,
43445
+ get closeBtn() {
43446
+ return {
43447
+ text: this.closeLabel,
43448
+ color: 'dark',
43449
+ fill: 'solid',
43450
+ size: 'default',
43451
+ type: 'button',
43452
+ state: ComponentStates.ENABLED,
43453
+ };
43454
+ }
43455
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LoginAttemptModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
43456
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: LoginAttemptModalComponent, isStandalone: true, selector: "val-login-attempt-modal", inputs: { title: "title", body: "body", deviceLabel: "deviceLabel", metaLabel: "metaLabel", doneLabel: "doneLabel", mineLabel: "mineLabel", blockLabel: "blockLabel", closeLabel: "closeLabel", isMobile: "isMobile", onBlock: "onBlock", _modalRef: "_modalRef" }, ngImport: i0, template: `
43457
+ <ion-header>
43458
+ <ion-toolbar>
43459
+ <ion-buttons slot="end">
43460
+ <ion-button fill="clear" color="dark" (click)="close()">
43461
+ <strong>{{ closeLabel }}</strong>
43462
+ </ion-button>
43463
+ </ion-buttons>
43464
+ </ion-toolbar>
43465
+ </ion-header>
43466
+ <ion-content class="ion-padding">
43467
+ <val-display [props]="{ content: title, size: 'small', color: 'dark' }" />
43468
+ <div class="attempt">
43469
+ <div class="attempt__hero">
43470
+ <ion-icon name="shield-checkmark-outline" aria-hidden="true" />
43471
+ </div>
43472
+
43473
+ <val-text
43474
+ [props]="{
43475
+ content: body,
43595
43476
  size: 'medium',
43596
- color: 'medium',
43477
+ color: 'dark',
43597
43478
  bold: false,
43598
- showQuoteMark: true,
43599
- alignment: 'left',
43600
- },
43601
- };
43479
+ }"
43480
+ />
43481
+
43482
+ <div class="attempt__device">
43483
+ <ion-icon [name]="deviceIcon" aria-hidden="true" />
43484
+ <div class="attempt__device-body">
43485
+ <val-text
43486
+ [props]="{
43487
+ content: deviceLabel,
43488
+ size: 'medium',
43489
+ color: 'dark',
43490
+ bold: true,
43491
+ }"
43492
+ />
43493
+ @if (metaLabel) {
43494
+ <val-text
43495
+ [props]="{
43496
+ content: metaLabel,
43497
+ size: 'small',
43498
+ color: 'medium',
43499
+ bold: false,
43500
+ }"
43501
+ />
43502
+ }
43503
+ </div>
43504
+ </div>
43505
+
43506
+ @if (done()) {
43507
+ <div class="attempt__done">
43508
+ <ion-icon name="checkmark-circle-outline" aria-hidden="true" />
43509
+ <val-text
43510
+ [props]="{
43511
+ content: doneLabel,
43512
+ size: 'small',
43513
+ color: 'medium',
43514
+ bold: false,
43515
+ }"
43516
+ />
43517
+ </div>
43518
+ }
43519
+ </div>
43520
+ </ion-content>
43521
+
43522
+ @if (!done()) {
43523
+ <div class="attempt__actions">
43524
+ <val-button [props]="mineBtn" (click)="confirmMine()" />
43525
+ <val-button [props]="blockBtn" (click)="confirmBlock()" />
43526
+ </div>
43527
+ } @else {
43528
+ <div class="attempt__actions">
43529
+ <val-button [props]="closeBtn" (click)="close()" />
43530
+ </div>
43531
+ }
43532
+ `, isInline: true, styles: [":host{display:block}.attempt{display:flex;flex-direction:column;gap:18px;align-items:flex-start}.attempt__hero{width:56px;height:56px;border-radius:16px;display:flex;align-items:center;justify-content:center;background:#7026df1a}.attempt__hero ion-icon{font-size:30px;color:var(--ion-color-primary, #7026df)}.attempt__device{display:flex;align-items:center;gap:12px;width:100%;padding:14px 16px;border-radius:12px;background:var(--ion-color-light, #f4f5f8);border:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.attempt__device ion-icon{font-size:26px;color:var(--ion-color-dark);flex-shrink:0}.attempt__device-body{display:flex;flex-direction:column;gap:2px;min-width:0}.attempt__done{display:flex;align-items:center;gap:8px}.attempt__done ion-icon{font-size:20px;color:var(--ion-color-success);flex-shrink:0}.attempt__actions{display:flex;gap:10px;padding:12px 16px calc(12px + env(safe-area-inset-bottom,0px));border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.attempt__actions val-button{flex:1}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { 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: 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: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }] }); }
43602
43533
  }
43603
- function makeNote(kind, text, labels) {
43604
- return {
43605
- type: 'note',
43606
- props: {
43607
- text,
43608
- prefix: `${labels[kind]}:`,
43609
- color: CALLOUT_COLORS[kind],
43610
- textColor: 'dark',
43534
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LoginAttemptModalComponent, decorators: [{
43535
+ type: Component,
43536
+ args: [{ selector: 'val-login-attempt-modal', standalone: true, imports: [
43537
+ CommonModule,
43538
+ IonContent,
43539
+ IonHeader,
43540
+ IonToolbar,
43541
+ IonButtons,
43542
+ IonButton,
43543
+ IonIcon,
43544
+ DisplayComponent,
43545
+ TextComponent,
43546
+ ButtonComponent,
43547
+ ], template: `
43548
+ <ion-header>
43549
+ <ion-toolbar>
43550
+ <ion-buttons slot="end">
43551
+ <ion-button fill="clear" color="dark" (click)="close()">
43552
+ <strong>{{ closeLabel }}</strong>
43553
+ </ion-button>
43554
+ </ion-buttons>
43555
+ </ion-toolbar>
43556
+ </ion-header>
43557
+ <ion-content class="ion-padding">
43558
+ <val-display [props]="{ content: title, size: 'small', color: 'dark' }" />
43559
+ <div class="attempt">
43560
+ <div class="attempt__hero">
43561
+ <ion-icon name="shield-checkmark-outline" aria-hidden="true" />
43562
+ </div>
43563
+
43564
+ <val-text
43565
+ [props]="{
43566
+ content: body,
43611
43567
  size: 'medium',
43612
- rounded: true,
43613
- allowPartialBold: true,
43614
- processLinks: true,
43615
- },
43616
- };
43617
- }
43618
- function makeTable(rows) {
43619
- const parsed = rows
43620
- .filter(r => !TABLE_DIVIDER_RE.test(r))
43621
- .map(r => r
43622
- .trim()
43623
- .replace(/^\|/, '')
43624
- .replace(/\|$/, '')
43625
- .split('|')
43626
- .map(c => c.trim()));
43627
- if (parsed.length === 0)
43628
- return [];
43629
- const header = parsed[0];
43630
- const dataRows = parsed.slice(1);
43631
- if (dataRows.length === 0) {
43632
- return [
43633
- {
43634
- type: 'paragraph',
43635
- props: {
43636
- content: header.join(' · '),
43637
- size: 'medium',
43638
- color: 'dark',
43639
- bold: false,
43640
- processLinks: true,
43641
- allowPartialBold: true,
43642
- },
43643
- },
43644
- ];
43645
- }
43646
- return dataRows.map(row => {
43647
- const pairs = row.map((cell, idx) => {
43648
- const key = header[idx] ?? '';
43649
- return key ? `<strong>${key}:</strong> ${cell}` : cell;
43650
- });
43651
- return {
43652
- type: 'paragraph',
43653
- props: {
43654
- content: pairs.join(' · '),
43568
+ color: 'dark',
43569
+ bold: false,
43570
+ }"
43571
+ />
43572
+
43573
+ <div class="attempt__device">
43574
+ <ion-icon [name]="deviceIcon" aria-hidden="true" />
43575
+ <div class="attempt__device-body">
43576
+ <val-text
43577
+ [props]="{
43578
+ content: deviceLabel,
43655
43579
  size: 'medium',
43656
43580
  color: 'dark',
43581
+ bold: true,
43582
+ }"
43583
+ />
43584
+ @if (metaLabel) {
43585
+ <val-text
43586
+ [props]="{
43587
+ content: metaLabel,
43588
+ size: 'small',
43589
+ color: 'medium',
43590
+ bold: false,
43591
+ }"
43592
+ />
43593
+ }
43594
+ </div>
43595
+ </div>
43596
+
43597
+ @if (done()) {
43598
+ <div class="attempt__done">
43599
+ <ion-icon name="checkmark-circle-outline" aria-hidden="true" />
43600
+ <val-text
43601
+ [props]="{
43602
+ content: doneLabel,
43603
+ size: 'small',
43604
+ color: 'medium',
43657
43605
  bold: false,
43658
- processLinks: true,
43659
- allowPartialBold: true,
43660
- },
43661
- };
43662
- });
43663
- }
43606
+ }"
43607
+ />
43608
+ </div>
43609
+ }
43610
+ </div>
43611
+ </ion-content>
43612
+
43613
+ @if (!done()) {
43614
+ <div class="attempt__actions">
43615
+ <val-button [props]="mineBtn" (click)="confirmMine()" />
43616
+ <val-button [props]="blockBtn" (click)="confirmBlock()" />
43617
+ </div>
43618
+ } @else {
43619
+ <div class="attempt__actions">
43620
+ <val-button [props]="closeBtn" (click)="close()" />
43621
+ </div>
43622
+ }
43623
+ `, styles: [":host{display:block}.attempt{display:flex;flex-direction:column;gap:18px;align-items:flex-start}.attempt__hero{width:56px;height:56px;border-radius:16px;display:flex;align-items:center;justify-content:center;background:#7026df1a}.attempt__hero ion-icon{font-size:30px;color:var(--ion-color-primary, #7026df)}.attempt__device{display:flex;align-items:center;gap:12px;width:100%;padding:14px 16px;border-radius:12px;background:var(--ion-color-light, #f4f5f8);border:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.attempt__device ion-icon{font-size:26px;color:var(--ion-color-dark);flex-shrink:0}.attempt__device-body{display:flex;flex-direction:column;gap:2px;min-width:0}.attempt__done{display:flex;align-items:center;gap:8px}.attempt__done ion-icon{font-size:20px;color:var(--ion-color-success);flex-shrink:0}.attempt__actions{display:flex;gap:10px;padding:12px 16px calc(12px + env(safe-area-inset-bottom,0px));border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.attempt__actions val-button{flex:1}\n"] }]
43624
+ }], propDecorators: { title: [{
43625
+ type: Input
43626
+ }], body: [{
43627
+ type: Input
43628
+ }], deviceLabel: [{
43629
+ type: Input
43630
+ }], metaLabel: [{
43631
+ type: Input
43632
+ }], doneLabel: [{
43633
+ type: Input
43634
+ }], mineLabel: [{
43635
+ type: Input
43636
+ }], blockLabel: [{
43637
+ type: Input
43638
+ }], closeLabel: [{
43639
+ type: Input
43640
+ }], isMobile: [{
43641
+ type: Input
43642
+ }], onBlock: [{
43643
+ type: Input
43644
+ }], _modalRef: [{
43645
+ type: Input
43646
+ }] } });
43664
43647
 
43665
43648
  /**
43666
- * `val-faq`
43649
+ * `val-session-list-modal` — modal de "Ver todas las sesiones activas". Organism
43650
+ * compartido entre la vista Seguridad y la vista Cuenta del factory.
43667
43651
  *
43668
- * Organism de preguntas frecuentes. Q&A categorizado con accordion colapsable
43669
- * y buscador en vivo. Pensado para vistas FAQ públicas — el caller puede
43670
- * reusar la misma data (`FaqMetadata.categories`) para emitir el structured
43671
- * data `FAQPage` (JSON-LD) y ganar rich results en buscadores.
43652
+ * Render reusa el mismo markup de la sección de sesiones inline (mismas clases
43653
+ * CSS) pero sin truncar la lista.
43672
43654
  *
43673
- * Las respuestas (`FaqItem.answer`) son **Markdown** — se parsean con
43674
- * `parseMarkdownArticle` y se renderizan vía `val-article`. Soportan links,
43675
- * listas, negritas, tablas, etc.
43655
+ * Inputs (vienen de `componentProps` del ModalService):
43656
+ * - `sessions` snapshot inicial (mostrado al abrir)
43657
+ * - `thisLabel` / `revokeLabel` — i18n strings ya resueltas
43658
+ * - `onRevoke` — callback async que el parent ejecuta contra el backend; debe
43659
+ * retornar la lista actualizada para que el modal refresque en sitio.
43676
43660
  *
43677
- * @example
43678
- * ```html
43679
- * <val-faq [props]="{ categories: faqCategories, searchable: true }" />
43680
- * ```
43661
+ * `@Output` events NO funcionan con `modalController.create()` — por eso usamos
43662
+ * callback pasado por props + `_modalRef` para dismiss desde dentro.
43663
+ *
43664
+ * Header canónico (Regla #5): sin `ion-title`, botón Cerrar texto en `slot=end`,
43665
+ * título en el body con `val-display`.
43681
43666
  */
43682
- class FaqComponent {
43667
+ class SessionListModalComponent {
43683
43668
  constructor() {
43684
- this.i18n = inject(I18nService);
43685
- this.props_ = signal({ categories: [] });
43686
- /** Término de búsqueda actual. */
43687
- this.query = signal('');
43688
- /**
43689
- * Cache de respuestas parseadas (Markdown → ArticleMetadata). Se reconstruye
43690
- * solo cuando cambian las categorías — el parseo NO corre por keystroke.
43691
- * Keyed por id de item (los ids son únicos en toda la FAQ).
43692
- */
43693
- this.parsedAnswers = computed(() => {
43694
- const locale = (this.i18n.lang() ?? 'es');
43695
- const map = new Map();
43696
- for (const cat of this.props_().categories ?? []) {
43697
- for (const item of cat.items) {
43698
- map.set(item.id, parseMarkdownArticle(item.answer, { locale }));
43699
- }
43700
- }
43701
- return map;
43702
- });
43703
- /**
43704
- * Categorías visibles tras el filtro. Una categoría desaparece si ninguna
43705
- * de sus preguntas matchea. Match sobre pregunta + respuesta (raw markdown),
43706
- * case-insensitive.
43707
- */
43708
- this.visibleCategories = computed(() => {
43709
- const q = this.query().trim().toLowerCase();
43710
- const cats = this.props_().categories ?? [];
43711
- if (!q)
43712
- return cats;
43713
- return cats
43714
- .map(cat => ({
43715
- ...cat,
43716
- items: cat.items.filter(it => it.question.toLowerCase().includes(q) || it.answer.toLowerCase().includes(q)),
43717
- }))
43718
- .filter(cat => cat.items.length > 0);
43719
- });
43720
- /** `true` si se debe mostrar el label de cada categoría. */
43721
- this.showCategoryLabel = computed(() => {
43722
- const p = this.props_();
43723
- if (p.hideSingleCategoryLabel === false)
43724
- return true;
43725
- return (p.categories?.length ?? 0) > 1;
43726
- });
43727
- }
43728
- set props(value) {
43729
- if (value)
43730
- this.props_.set(value);
43731
- }
43732
- get props() {
43733
- return this.props_();
43669
+ /** Título del modal (ya traducido). */
43670
+ this.title = '';
43671
+ /** Label del botón cerrar del header (ya traducido). */
43672
+ this.closeLabel = '';
43673
+ /** Label "Esta sesión" (en idioma activo). */
43674
+ this.thisLabel = '';
43675
+ /** Label del botón revoke individual. */
43676
+ this.revokeLabel = '';
43677
+ /** Lista local mutable — refrescada después de cada revoke confirmado. */
43678
+ this.sessionsLocal = signal([]);
43679
+ }
43680
+ /** Snapshot inicial el modal mantiene su propia copia y la refresca tras revoke. */
43681
+ set sessions(value) {
43682
+ this.sessionsLocal.set([...value]);
43734
43683
  }
43735
- /** Respuesta parseada de un item — desde el cache. */
43736
- answerOf(item) {
43737
- return this.parsedAnswers().get(item.id) ?? { elements: [] };
43684
+ close() {
43685
+ this._modalRef?.dismiss(undefined, 'close');
43738
43686
  }
43739
- onSearch(term) {
43740
- this.query.set(term ?? '');
43687
+ async handleRevoke(sessionId) {
43688
+ if (!this.onRevoke)
43689
+ return;
43690
+ try {
43691
+ const updated = await this.onRevoke(sessionId);
43692
+ this.sessionsLocal.set([...updated]);
43693
+ }
43694
+ catch {
43695
+ // El parent muestra el toast de error; modal no necesita feedback extra.
43696
+ }
43697
+ }
43698
+ get revokeBtn() {
43699
+ return {
43700
+ text: this.revokeLabel,
43701
+ color: 'dark',
43702
+ fill: 'clear',
43703
+ size: 'small',
43704
+ type: 'button',
43705
+ state: ComponentStates.ENABLED,
43706
+ };
43707
+ }
43708
+ deviceIcon(s) {
43709
+ const ua = (s.deviceInfo || '').toLowerCase();
43710
+ const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua);
43711
+ return isMobile ? 'phone-portrait-outline' : 'laptop-outline';
43712
+ }
43713
+ formatMeta(s) {
43714
+ const parts = [];
43715
+ if (s.location)
43716
+ parts.push(s.location);
43717
+ else if (s.ip)
43718
+ parts.push(s.ip);
43719
+ if (s.createdAt)
43720
+ parts.push(new Date(s.createdAt).toLocaleString());
43721
+ return parts.join(' · ');
43722
+ }
43723
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionListModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
43724
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SessionListModalComponent, isStandalone: true, selector: "val-session-list-modal", inputs: { sessions: "sessions", title: "title", closeLabel: "closeLabel", thisLabel: "thisLabel", revokeLabel: "revokeLabel", onRevoke: "onRevoke", _modalRef: "_modalRef" }, ngImport: i0, template: `
43725
+ <ion-header>
43726
+ <ion-toolbar>
43727
+ <ion-buttons slot="end">
43728
+ <ion-button fill="clear" color="dark" (click)="close()">
43729
+ <strong>{{ closeLabel }}</strong>
43730
+ </ion-button>
43731
+ </ion-buttons>
43732
+ </ion-toolbar>
43733
+ </ion-header>
43734
+ <ion-content class="ion-padding">
43735
+ <val-display [props]="{ content: title, size: 'small', color: 'dark' }" />
43736
+ <ul class="sessions">
43737
+ @for (s of sessionsLocal(); track s.sessionId) {
43738
+ <li class="session" [class.session--current]="s.isCurrent">
43739
+ <ion-icon class="session__icon" [name]="deviceIcon(s)" aria-hidden="true" />
43740
+ <div class="session__body">
43741
+ <val-text
43742
+ [props]="{
43743
+ content: s.deviceInfo || s.deviceId || '—',
43744
+ size: 'medium',
43745
+ color: 'dark',
43746
+ bold: true,
43747
+ }"
43748
+ />
43749
+ <val-text
43750
+ [props]="{
43751
+ content: formatMeta(s),
43752
+ size: 'small',
43753
+ color: 'medium',
43754
+ bold: false,
43755
+ }"
43756
+ />
43757
+ </div>
43758
+ <div class="session__end">
43759
+ @if (s.isCurrent) {
43760
+ <span class="session__badge">{{ thisLabel }}</span>
43761
+ } @else {
43762
+ <val-button [props]="revokeBtn" (click)="handleRevoke(s.sessionId)" />
43763
+ }
43764
+ </div>
43765
+ </li>
43766
+ }
43767
+ </ul>
43768
+ </ion-content>
43769
+ `, isInline: true, styles: [":host{display:block;height:100%}.sessions{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}.session{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ion-color-light, rgba(0, 0, 0, .04));border:1px solid transparent}.session--current{background:#2dd36f1a;border-color:#2dd36f4d}.session__icon{font-size:26px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0}.session--current .session__icon{color:var(--ion-color-success-shade, #28ba62)}.session__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.session__end{flex-shrink:0}.session__badge{display:inline-block;padding:4px 10px;font-size:11px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--ion-color-success-shade, #28ba62);background:#2dd36f2e;border-radius:999px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { 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: 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: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }] }); }
43770
+ }
43771
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SessionListModalComponent, decorators: [{
43772
+ type: Component,
43773
+ args: [{ selector: 'val-session-list-modal', standalone: true, imports: [
43774
+ CommonModule,
43775
+ IonContent,
43776
+ IonHeader,
43777
+ IonToolbar,
43778
+ IonButtons,
43779
+ IonButton,
43780
+ IonIcon,
43781
+ DisplayComponent,
43782
+ TextComponent,
43783
+ ButtonComponent,
43784
+ ], template: `
43785
+ <ion-header>
43786
+ <ion-toolbar>
43787
+ <ion-buttons slot="end">
43788
+ <ion-button fill="clear" color="dark" (click)="close()">
43789
+ <strong>{{ closeLabel }}</strong>
43790
+ </ion-button>
43791
+ </ion-buttons>
43792
+ </ion-toolbar>
43793
+ </ion-header>
43794
+ <ion-content class="ion-padding">
43795
+ <val-display [props]="{ content: title, size: 'small', color: 'dark' }" />
43796
+ <ul class="sessions">
43797
+ @for (s of sessionsLocal(); track s.sessionId) {
43798
+ <li class="session" [class.session--current]="s.isCurrent">
43799
+ <ion-icon class="session__icon" [name]="deviceIcon(s)" aria-hidden="true" />
43800
+ <div class="session__body">
43801
+ <val-text
43802
+ [props]="{
43803
+ content: s.deviceInfo || s.deviceId || '—',
43804
+ size: 'medium',
43805
+ color: 'dark',
43806
+ bold: true,
43807
+ }"
43808
+ />
43809
+ <val-text
43810
+ [props]="{
43811
+ content: formatMeta(s),
43812
+ size: 'small',
43813
+ color: 'medium',
43814
+ bold: false,
43815
+ }"
43816
+ />
43817
+ </div>
43818
+ <div class="session__end">
43819
+ @if (s.isCurrent) {
43820
+ <span class="session__badge">{{ thisLabel }}</span>
43821
+ } @else {
43822
+ <val-button [props]="revokeBtn" (click)="handleRevoke(s.sessionId)" />
43823
+ }
43824
+ </div>
43825
+ </li>
43826
+ }
43827
+ </ul>
43828
+ </ion-content>
43829
+ `, styles: [":host{display:block;height:100%}.sessions{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}.session{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ion-color-light, rgba(0, 0, 0, .04));border:1px solid transparent}.session--current{background:#2dd36f1a;border-color:#2dd36f4d}.session__icon{font-size:26px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0}.session--current .session__icon{color:var(--ion-color-success-shade, #28ba62)}.session__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.session__end{flex-shrink:0}.session__badge{display:inline-block;padding:4px 10px;font-size:11px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--ion-color-success-shade, #28ba62);background:#2dd36f2e;border-radius:999px}\n"] }]
43830
+ }], propDecorators: { sessions: [{
43831
+ type: Input
43832
+ }], title: [{
43833
+ type: Input
43834
+ }], closeLabel: [{
43835
+ type: Input
43836
+ }], thisLabel: [{
43837
+ type: Input
43838
+ }], revokeLabel: [{
43839
+ type: Input
43840
+ }], onRevoke: [{
43841
+ type: Input
43842
+ }], _modalRef: [{
43843
+ type: Input
43844
+ }] } });
43845
+
43846
+ /**
43847
+ * Standard cross-app site links (env-aware)
43848
+ *
43849
+ * En el factory MyValtech cada producto satélite (showcase, sigify, …) enlaza a
43850
+ * `web` —la landing de marca— para soporte, contacto y contenido legal. `web`
43851
+ * es el dueño canónico de esas rutas. Este módulo centraliza:
43852
+ *
43853
+ * 1. El mapa ambiente → base URL de `web` (`VALTECH_WEB_BASE_URLS`).
43854
+ * 2. El catálogo de paths estándar que viven en `web` (`VALTECH_SITE_PATHS`).
43855
+ *
43856
+ * El ambiente se decide en BUILD TIME (campo `valtechEnv` en el `environment*.ts`
43857
+ * de cada app), igual que `apiUrl`. Es el mecanismo más confiable: detectar por
43858
+ * hostname en runtime se rompe con dominios custom / `web.app` / variantes de
43859
+ * localhost.
43860
+ *
43861
+ * La resolución real (join baseUrl + path, abrir en tab nueva, `?lang=`) la hace
43862
+ * `LegalLinkService`; este módulo solo le entrega el `baseUrl` correcto por env.
43863
+ *
43864
+ * @see provideValtechSite — wiring de una sola línea para apps satélite.
43865
+ */
43866
+ /**
43867
+ * Base URL de `web` (landing de marca) por ambiente.
43868
+ *
43869
+ * - `local` → `ng serve` de `web` (puerto 8100).
43870
+ * - `dev` → proyecto Firebase `myvaltech-dev`.
43871
+ * - `prod` → dominio custom de producción.
43872
+ *
43873
+ * Sin trailing slash (lo asume `LegalLinkService`).
43874
+ */
43875
+ const VALTECH_WEB_BASE_URLS = {
43876
+ local: 'http://localhost:8100',
43877
+ dev: 'https://myvaltech-dev.web.app',
43878
+ prod: 'https://myvaltech.com',
43879
+ };
43880
+ /**
43881
+ * Resuelve la base URL de `web` para un ambiente.
43882
+ *
43883
+ * Fallback a `prod` cuando el env es desconocido o falta: ante la duda, apuntar
43884
+ * a producción es lo seguro (nunca enlaza a un host local/dev inexistente).
43885
+ */
43886
+ function resolveWebBaseUrl(env) {
43887
+ if (env && env in VALTECH_WEB_BASE_URLS) {
43888
+ return VALTECH_WEB_BASE_URLS[env];
43889
+ }
43890
+ return VALTECH_WEB_BASE_URLS.prod;
43891
+ }
43892
+ /**
43893
+ * Catálogo de paths estándar que viven en `web`. Las apps resuelven estos
43894
+ * contra la base URL del ambiente vía `LegalLinkService.resolve(path)`.
43895
+ *
43896
+ * Mantener alineado con las rutas reales de `web/src/app/app.routes.ts` y con
43897
+ * `VALTECH_COMPANY_LINKS` (footer).
43898
+ */
43899
+ const VALTECH_SITE_PATHS = {
43900
+ home: '/',
43901
+ support: '/contact',
43902
+ contact: '/contact',
43903
+ faq: '/faq',
43904
+ about: '/about',
43905
+ terms: '/legal/terms',
43906
+ privacy: '/legal/privacy',
43907
+ cookies: '/legal/cookies',
43908
+ legalNotice: '/legal/legal-notice',
43909
+ };
43910
+
43911
+ const VALTECH_LEGAL_CONFIG = new InjectionToken('VALTECH_LEGAL_CONFIG');
43912
+ /**
43913
+ * Resolves legal/site paths against a configurable main-site base URL.
43914
+ *
43915
+ * - **Main site mode**: no `provideValtechLegal` called → `resolve('/legal/terms')` returns `/legal/terms` (relative).
43916
+ * - **Satellite mode**: `provideValtechLegal({ baseUrl: 'https://myvaltech.com' })` → `resolve('/legal/terms')` returns `'https://myvaltech.com/legal/terms'` and `isExternal('/legal/terms')` returns `true`.
43917
+ *
43918
+ * Absolute URLs passed in are returned unchanged (no double-prefix).
43919
+ */
43920
+ class LegalLinkService {
43921
+ constructor() {
43922
+ this.config = inject(VALTECH_LEGAL_CONFIG, { optional: true }) ?? {};
43923
+ }
43924
+ /** Effective base URL (null if running as main site). */
43925
+ get baseUrl() {
43926
+ return this.config.baseUrl?.replace(/\/$/, '') ?? null;
43927
+ }
43928
+ /** Whether resolved external links should open in a new tab. */
43929
+ get openInNewTab() {
43930
+ return this.config.openInNewTab ?? Boolean(this.config.baseUrl);
43931
+ }
43932
+ /**
43933
+ * Returns the URL to use for a given internal path. Absolute URLs pass through.
43934
+ *
43935
+ * When `locale` is provided AND the resolved URL would be cross-origin, appends
43936
+ * `?lang={locale}` so the main site can render the legal doc in the user's
43937
+ * active language (instead of its default). Locale is omitted for same-origin
43938
+ * resolves since the main site reads locale from its own `LocaleService`.
43939
+ *
43940
+ * @example
43941
+ * resolve('/legal/terms')
43942
+ * // main site: '/legal/terms'
43943
+ * // satellite: 'https://myvaltech.com/legal/terms'
43944
+ *
43945
+ * resolve('/legal/terms', { locale: 'pt' })
43946
+ * // main site: '/legal/terms' (locale stays implicit)
43947
+ * // satellite: 'https://myvaltech.com/legal/terms?lang=pt'
43948
+ *
43949
+ * resolve('https://x.com/y') // unchanged
43950
+ */
43951
+ resolve(path, options) {
43952
+ if (!path)
43953
+ return path;
43954
+ if (/^https?:\/\//i.test(path))
43955
+ return path;
43956
+ const base = this.baseUrl;
43957
+ if (!base)
43958
+ return path;
43959
+ const url = `${base}${path.startsWith('/') ? '' : '/'}${path}`;
43960
+ if (options?.locale) {
43961
+ const sep = url.includes('?') ? '&' : '?';
43962
+ return `${url}${sep}lang=${encodeURIComponent(options.locale)}`;
43963
+ }
43964
+ return url;
43965
+ }
43966
+ /** `true` if the path would be resolved to a cross-origin URL. */
43967
+ isExternal(path) {
43968
+ if (/^https?:\/\//i.test(path))
43969
+ return true;
43970
+ return this.baseUrl !== null;
43971
+ }
43972
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LegalLinkService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
43973
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LegalLinkService, providedIn: 'root' }); }
43974
+ }
43975
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LegalLinkService, decorators: [{
43976
+ type: Injectable,
43977
+ args: [{ providedIn: 'root' }]
43978
+ }] });
43979
+ /**
43980
+ * Wires `LegalLinkService` for satellite apps. Omit in the main site (the one
43981
+ * that hosts the canonical /legal/* routes) so links stay relative.
43982
+ *
43983
+ * @example
43984
+ * // main.ts of showcase / sigify / etc.
43985
+ * provideValtechLegal({
43986
+ * baseUrl: 'https://myvaltech.com',
43987
+ * openInNewTab: true,
43988
+ * }),
43989
+ */
43990
+ function provideValtechLegal(config) {
43991
+ return { provide: VALTECH_LEGAL_CONFIG, useValue: config };
43992
+ }
43993
+ /**
43994
+ * Wiring de una sola línea para que una app satélite apunte sus links de sitio
43995
+ * (legal, soporte, contacto, …) a `web` —la landing de marca— en el ambiente
43996
+ * correcto. Resuelve el `baseUrl` desde `VALTECH_WEB_BASE_URLS` según el `env`
43997
+ * baked en build-time (`environment.valtechEnv`) y delega en `provideValtechLegal`.
43998
+ *
43999
+ * Ambiente desconocido/ausente → fallback a `prod` (ver `resolveWebBaseUrl`).
44000
+ * El main site (`web`) NO llama esto — sus links quedan relativos.
44001
+ *
44002
+ * @example
44003
+ * // main.ts de showcase / sigify / …
44004
+ * import { environment } from './environments/environment';
44005
+ * provideValtechSite(environment.valtechEnv), // 'local' | 'dev' | 'prod'
44006
+ *
44007
+ * @see resolveWebBaseUrl
44008
+ * @see VALTECH_SITE_PATHS — catálogo de paths estándar que viven en `web`.
44009
+ */
44010
+ function provideValtechSite(env, options) {
44011
+ return provideValtechLegal({
44012
+ baseUrl: resolveWebBaseUrl(env),
44013
+ openInNewTab: options?.openInNewTab ?? true,
44014
+ });
44015
+ }
44016
+
44017
+ /**
44018
+ * Default modal sizes.
44019
+ */
44020
+ const MODAL_SIZES = {
44021
+ small: { width: '320px', height: '220px' },
44022
+ medium: { width: '500px', height: '400px' },
44023
+ large: { width: '800px', height: '80vh' },
44024
+ full: { width: '100%', height: '100%' },
44025
+ auto: { width: 'auto', height: '50vh' },
44026
+ };
44027
+ /**
44028
+ * Default modal buttons.
44029
+ */
44030
+ const DEFAULT_MODAL_CONFIRM_BUTTON = {
44031
+ text: 'Confirm',
44032
+ role: 'confirm',
44033
+ color: 'primary',
44034
+ fill: 'solid',
44035
+ };
44036
+ const DEFAULT_MODAL_CANCEL_BUTTON = {
44037
+ text: 'Cancel',
44038
+ role: 'cancel',
44039
+ color: 'medium',
44040
+ fill: 'clear',
44041
+ };
44042
+
44043
+ /**
44044
+ * Internal component for simple content modals.
44045
+ * Used by ModalService.openSimple()
44046
+ *
44047
+ * Header + título + close button delegados a `val-modal-shell` (estándar
44048
+ * canónico — ver Regla #5 en `frontend/CLAUDE.md`). El cuerpo (innerHTML) +
44049
+ * footer con botones quedan locales.
44050
+ */
44051
+ class SimpleModalContentComponent {
44052
+ constructor() {
44053
+ this.title = '';
44054
+ this.content = '';
44055
+ this.buttons = [];
44056
+ this.showCloseButton = true;
44057
+ }
44058
+ async onButtonClick(button) {
44059
+ if (button.handler) {
44060
+ const result = await button.handler();
44061
+ // If handler returns false, don't dismiss
44062
+ if (result === false) {
44063
+ return;
44064
+ }
44065
+ }
44066
+ this._modalRef?.dismiss(undefined, button.role || 'confirm');
44067
+ }
44068
+ onDismiss(role) {
44069
+ this._modalRef?.dismiss(undefined, role);
44070
+ }
44071
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleModalContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
44072
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SimpleModalContentComponent, isStandalone: true, selector: "val-simple-modal-content", inputs: { title: "title", content: "content", buttons: "buttons", showCloseButton: "showCloseButton", _modalRef: "_modalRef" }, ngImport: i0, template: `
44073
+ <val-modal-shell [title]="title" [showClose]="showCloseButton" (close)="onDismiss('close')">
44074
+ <div [innerHTML]="content"></div>
44075
+ </val-modal-shell>
44076
+
44077
+ @if (buttons && buttons.length > 0) {
44078
+ <ion-footer>
44079
+ <ion-toolbar>
44080
+ <div class="button-container">
44081
+ @for (button of buttons; track button.text) {
44082
+ <ion-button
44083
+ [color]="button.color || 'medium'"
44084
+ [fill]="button.fill || 'solid'"
44085
+ [size]="button.size || 'default'"
44086
+ [disabled]="button.disabled || button.loading"
44087
+ [class]="button.cssClass"
44088
+ (click)="onButtonClick(button)"
44089
+ >
44090
+ @if (button.loading) {
44091
+ <ion-spinner name="circular"></ion-spinner>
44092
+ } @else {
44093
+ {{ button.text }}
44094
+ }
44095
+ </ion-button>
44096
+ }
44097
+ </div>
44098
+ </ion-toolbar>
44099
+ </ion-footer>
44100
+ }
44101
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column;height:100%;min-height:0}ion-footer{flex-shrink:0}.button-container{display:flex;justify-content:flex-end;gap:8px;padding:8px 0}ion-footer ion-toolbar{--padding-start: 16px;--padding-end: 16px}:host ::ng-deep ul,:host ::ng-deep ol{padding-left:20px;margin:8px 0}:host ::ng-deep li{margin:4px 0}:host ::ng-deep p{margin:8px 0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { 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: IonFooter, selector: "ion-footer", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: ModalShellComponent, selector: "val-modal-shell", inputs: ["title", "subtitle", "closeLabel", "showClose"], outputs: ["close"] }] }); }
44102
+ }
44103
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleModalContentComponent, decorators: [{
44104
+ type: Component,
44105
+ args: [{ selector: 'val-simple-modal-content', standalone: true, imports: [CommonModule, IonToolbar, IonButton, IonFooter, IonSpinner, ModalShellComponent], template: `
44106
+ <val-modal-shell [title]="title" [showClose]="showCloseButton" (close)="onDismiss('close')">
44107
+ <div [innerHTML]="content"></div>
44108
+ </val-modal-shell>
44109
+
44110
+ @if (buttons && buttons.length > 0) {
44111
+ <ion-footer>
44112
+ <ion-toolbar>
44113
+ <div class="button-container">
44114
+ @for (button of buttons; track button.text) {
44115
+ <ion-button
44116
+ [color]="button.color || 'medium'"
44117
+ [fill]="button.fill || 'solid'"
44118
+ [size]="button.size || 'default'"
44119
+ [disabled]="button.disabled || button.loading"
44120
+ [class]="button.cssClass"
44121
+ (click)="onButtonClick(button)"
44122
+ >
44123
+ @if (button.loading) {
44124
+ <ion-spinner name="circular"></ion-spinner>
44125
+ } @else {
44126
+ {{ button.text }}
44127
+ }
44128
+ </ion-button>
44129
+ }
44130
+ </div>
44131
+ </ion-toolbar>
44132
+ </ion-footer>
44133
+ }
44134
+ `, styles: [":host{display:flex;flex-direction:column;height:100%;min-height:0}ion-footer{flex-shrink:0}.button-container{display:flex;justify-content:flex-end;gap:8px;padding:8px 0}ion-footer ion-toolbar{--padding-start: 16px;--padding-end: 16px}:host ::ng-deep ul,:host ::ng-deep ol{padding-left:20px;margin:8px 0}:host ::ng-deep li{margin:4px 0}:host ::ng-deep p{margin:8px 0}\n"] }]
44135
+ }], propDecorators: { title: [{
44136
+ type: Input
44137
+ }], content: [{
44138
+ type: Input
44139
+ }], buttons: [{
44140
+ type: Input
44141
+ }], showCloseButton: [{
44142
+ type: Input
44143
+ }], _modalRef: [{
44144
+ type: Input
44145
+ }] } });
44146
+
44147
+ /**
44148
+ * ModalService
44149
+ *
44150
+ * A service for creating and managing modals with various configurations.
44151
+ *
44152
+ * @example Basic component modal
44153
+ * ```typescript
44154
+ * const result = await modalService.open({
44155
+ * component: MyFormComponent,
44156
+ * componentProps: { userId: 123 },
44157
+ * header: { title: 'Edit User', showCloseButton: true },
44158
+ * size: 'medium'
44159
+ * });
44160
+ *
44161
+ * if (result.role === 'confirm') {
44162
+ * console.log('Saved:', result.data);
44163
+ * }
44164
+ * ```
44165
+ *
44166
+ * @example Simple content modal
44167
+ * ```typescript
44168
+ * await modalService.openSimple({
44169
+ * title: 'Terms of Service',
44170
+ * content: '<p>Terms content here...</p>',
44171
+ * size: 'large',
44172
+ * showCloseButton: true
44173
+ * });
44174
+ * ```
44175
+ *
44176
+ * @example Sheet modal (mobile)
44177
+ * ```typescript
44178
+ * const result = await modalService.openSheet({
44179
+ * component: FilterComponent,
44180
+ * breakpoints: {
44181
+ * initialBreakpoint: 0.5,
44182
+ * breakpoints: [0, 0.5, 1],
44183
+ * showHandle: true
44184
+ * }
44185
+ * });
44186
+ * ```
44187
+ */
44188
+ class ModalService {
44189
+ constructor(modalController) {
44190
+ this.modalController = modalController;
44191
+ }
44192
+ /**
44193
+ * Opens a modal with a component.
44194
+ * @param options - Modal configuration
44195
+ * @returns Promise resolving to the modal result
44196
+ */
44197
+ async open(options) {
44198
+ // A sheet modal (breakpoints present) gets its height from the breakpoints
44199
+ // and must span the full viewport width. Applying a size preset's
44200
+ // `--width`/`--height` here collapses the sheet (e.g. `auto` → shrink to
44201
+ // content) or fights the breakpoint height. So when it's a sheet we skip
44202
+ // the size preset entirely.
44203
+ const isSheet = !!options.breakpoints?.breakpoints?.length;
44204
+ const size = isSheet ? undefined : MODAL_SIZES[options.size || 'medium'];
44205
+ const cssClasses = this.buildCssClasses(options);
44206
+ const modal = await this.modalController.create({
44207
+ component: options.component,
44208
+ componentProps: {
44209
+ ...options.componentProps,
44210
+ // Pass modal reference for dismiss from within component
44211
+ _modalRef: {
44212
+ dismiss: (data, role) => modal.dismiss(data, role),
44213
+ },
44214
+ },
44215
+ id: options.id,
44216
+ showBackdrop: options.showBackdrop ?? true,
44217
+ backdropDismiss: options.backdropDismiss ?? true,
44218
+ keyboardClose: options.keyboardClose ?? true,
44219
+ cssClass: cssClasses,
44220
+ mode: options.mode,
44221
+ animated: options.animated ?? true,
44222
+ canDismiss: this.normalizeCanDismiss(options.canDismiss ?? true),
44223
+ presentingElement: options.presentingElement,
44224
+ // Sheet modal options
44225
+ initialBreakpoint: options.breakpoints?.initialBreakpoint,
44226
+ breakpoints: options.breakpoints?.breakpoints,
44227
+ handle: options.breakpoints?.showHandle,
44228
+ handleBehavior: options.breakpoints?.handleBehavior,
44229
+ });
44230
+ // Apply custom size styles. Skipped for sheet modals — their dimensions
44231
+ // come from the breakpoints (Ionic spans the sheet full-width and uses the
44232
+ // breakpoint values for height).
44233
+ if (!isSheet && (options.width || options.height || size)) {
44234
+ const modalEl = modal;
44235
+ modalEl.style.setProperty('--width', options.width || size?.width || 'auto');
44236
+ modalEl.style.setProperty('--height', options.height || size?.height || '50vh');
44237
+ }
44238
+ await modal.present();
44239
+ const { data, role } = await modal.onDidDismiss();
44240
+ return { data, role };
44241
+ }
44242
+ /**
44243
+ * Opens a simple content modal without a custom component.
44244
+ * Uses Ionic's alert-like styling but with modal capabilities.
44245
+ * @param options - Simple modal configuration
44246
+ * @returns Promise resolving to the modal result
44247
+ */
44248
+ async openSimple(options) {
44249
+ return this.open({
44250
+ component: SimpleModalContentComponent,
44251
+ componentProps: {
44252
+ title: options.title,
44253
+ content: options.content,
44254
+ buttons: options.buttons || [DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON],
44255
+ showCloseButton: options.showCloseButton ?? true,
44256
+ },
44257
+ size: options.size || 'medium',
44258
+ backdropDismiss: options.backdropDismiss ?? true,
44259
+ cssClass: ['simple-modal', options.cssClass].filter(Boolean).join(' '),
44260
+ });
44261
+ }
44262
+ /**
44263
+ * Opens a confirmation modal.
44264
+ * @param title - Modal title
44265
+ * @param message - Modal message
44266
+ * @param confirmButton - Confirm button config (optional)
44267
+ * @param cancelButton - Cancel button config (optional)
44268
+ * @returns Promise resolving to true if confirmed
44269
+ */
44270
+ async confirm(title, message, confirmButton, cancelButton) {
44271
+ const result = await this.openSimple({
44272
+ title,
44273
+ content: message,
44274
+ size: 'small',
44275
+ showCloseButton: false,
44276
+ backdropDismiss: false,
44277
+ buttons: [
44278
+ { ...DEFAULT_MODAL_CANCEL_BUTTON, ...cancelButton },
44279
+ { ...DEFAULT_MODAL_CONFIRM_BUTTON, ...confirmButton },
44280
+ ],
44281
+ });
44282
+ return result.role === 'confirm';
44283
+ }
44284
+ /**
44285
+ * Opens a sheet modal (iOS-style bottom sheet).
44286
+ * @param options - Modal configuration with sheet-specific options
44287
+ * @returns Promise resolving to the modal result
44288
+ */
44289
+ async openSheet(options) {
44290
+ return this.open({
44291
+ ...options,
44292
+ breakpoints: {
44293
+ initialBreakpoint: options.initialBreakpoint ?? 0.5,
44294
+ breakpoints: options.breakpoints ?? [0, 0.5, 1],
44295
+ showHandle: options.showHandle ?? true,
44296
+ },
44297
+ cssClass: ['sheet-modal', options.cssClass].filter(Boolean).join(' '),
44298
+ });
44299
+ }
44300
+ /**
44301
+ * Opens a fullscreen modal.
44302
+ * @param options - Modal configuration
44303
+ * @returns Promise resolving to the modal result
44304
+ */
44305
+ async openFullscreen(options) {
44306
+ return this.open({
44307
+ ...options,
44308
+ size: 'full',
44309
+ cssClass: ['fullscreen-modal', options.cssClass].filter(Boolean).join(' '),
44310
+ });
44311
+ }
44312
+ /**
44313
+ * Dismisses the top-most modal.
44314
+ * @param data - Optional data to return
44315
+ * @param role - Optional role
44316
+ */
44317
+ async dismiss(data, role) {
44318
+ return this.modalController.dismiss(data, role);
44319
+ }
44320
+ /**
44321
+ * Gets the top-most modal.
44322
+ */
44323
+ async getTop() {
44324
+ return this.modalController.getTop();
44325
+ }
44326
+ normalizeCanDismiss(canDismiss) {
44327
+ if (typeof canDismiss === 'boolean') {
44328
+ return canDismiss;
44329
+ }
44330
+ // Wrap the function to ensure it returns a Promise
44331
+ return async (data, role) => {
44332
+ const result = canDismiss();
44333
+ return Promise.resolve(result);
44334
+ };
44335
+ }
44336
+ buildCssClasses(options) {
44337
+ const classes = ['val-modal'];
44338
+ if (options.size) {
44339
+ classes.push(`modal-${options.size}`);
44340
+ }
44341
+ if (options.header) {
44342
+ classes.push('modal-with-header');
44343
+ }
44344
+ if (options.footer) {
44345
+ classes.push('modal-with-footer');
44346
+ }
44347
+ if (options.cssClass) {
44348
+ classes.push(options.cssClass);
44349
+ }
44350
+ return classes.join(' ');
44351
+ }
44352
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ModalService, deps: [{ token: i2$1.ModalController }], target: i0.ɵɵFactoryTarget.Injectable }); }
44353
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ModalService, providedIn: 'root' }); }
44354
+ }
44355
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ModalService, decorators: [{
44356
+ type: Injectable,
44357
+ args: [{
44358
+ providedIn: 'root',
44359
+ }]
44360
+ }], ctorParameters: () => [{ type: i2$1.ModalController }] });
44361
+
44362
+ /**
44363
+ * Defaults i18n (es/en) embebidos en `val-security-view`. Auto-registrados en el
44364
+ * constructor del componente si el consumer no proveyó el namespace
44365
+ * (`Settings.Security` por default). Garantiza que nunca haya una key faltante
44366
+ * evaluada por cada change-detection (ver nota en valtech-components/CLAUDE.md
44367
+ * sobre el storm de CD con `val-debug-console`). El consumer puede override
44368
+ * registrando el mismo namespace antes de que el componente monte.
44369
+ *
44370
+ * SOLO incluye las keys que la vista realmente usa.
44371
+ */
44372
+ const SECURITY_VIEW_I18N = {
44373
+ es: {
44374
+ pageTitle: 'Seguridad',
44375
+ pageDescription: 'Contraseñas, MFA y sesiones activas',
44376
+ blocked: 'Dispositivo bloqueado',
44377
+ tokenActionFailed: 'El enlace del email no es válido o expiró.',
44378
+ loginAttemptTitle: 'Confirmar inicio de sesión',
44379
+ loginAttemptBody: 'Detectamos un inicio de sesión en este dispositivo. Si fuiste tú, no tienes que hacer nada. Si no lo reconoces, bloquéalo.',
44380
+ loginAttemptMine: 'Sí, fui yo',
44381
+ loginAttemptBlock: 'No fui yo — bloquear',
44382
+ loginAttemptClose: 'Cerrar',
44383
+ loginAttemptDone: 'Dispositivo bloqueado. Restablece tu contraseña para reforzar tu cuenta.',
44384
+ loginAttemptUnknownDevice: 'Dispositivo desconocido',
44385
+ sessionsTitle: 'Sesiones activas',
44386
+ sessionsTitleCount: 'Sesiones activas ({n})',
44387
+ sessionsLoading: 'Cargando sesiones…',
44388
+ sessionsEmpty: 'No hay sesiones activas.',
44389
+ sessionsThis: 'Esta sesión',
44390
+ sessionsRevoke: 'Cerrar',
44391
+ sessionsRevokeAll: 'Cerrar todas las demás',
44392
+ sessionsViewAll: 'Ver todas',
44393
+ sessionsModalTitle: 'Todas las sesiones activas',
44394
+ sessionsRevoked: 'Sesión cerrada.',
44395
+ sessionsAllRevoked: 'Todas las demás sesiones fueron cerradas.',
44396
+ sessionsError: 'No se pudo completar la operación.',
44397
+ revokeAllConfirmTitle: 'Cerrar todas las otras sesiones',
44398
+ revokeAllConfirmMessage: 'Vas a cerrar todas tus sesiones excepto la actual. Tendrás que volver a iniciar sesión en los otros dispositivos.',
44399
+ revokeAllConfirm: 'Cerrar todas',
44400
+ cancel: 'Cancelar',
44401
+ quickActionsTitle: 'Otras opciones',
44402
+ changePassword: 'Cambiar contraseña',
44403
+ changePasswordDesc: 'Actualiza tu contraseña de acceso',
44404
+ changeEmail: 'Cambiar email',
44405
+ changeEmailDesc: 'Actualiza el correo electrónico de tu cuenta',
44406
+ mfa: 'Autenticación de dos pasos (MFA)',
44407
+ mfaDesc: 'Agrega un segundo factor a tu inicio de sesión',
44408
+ sessions: 'Sesiones activas',
44409
+ sessionsDesc: 'Revisa y cierra sesiones en otros dispositivos',
44410
+ policiesTitle: 'Políticas relacionadas',
44411
+ privacyLink: 'Política de Privacidad',
44412
+ cookiesLink: 'Política de Cookies',
44413
+ dataUsageLink: 'Uso de Datos',
44414
+ termsLink: 'Términos y Condiciones',
44415
+ thirdPartyLink: 'Software y Servicios de Terceros',
44416
+ legalNoticeLink: 'Aviso Legal',
44417
+ soonHint: 'Próximamente',
44418
+ },
44419
+ en: {
44420
+ pageTitle: 'Security',
44421
+ pageDescription: 'Passwords, MFA, and active sessions',
44422
+ blocked: 'Device blocked',
44423
+ tokenActionFailed: 'The email link is invalid or has expired.',
44424
+ loginAttemptTitle: 'Confirm sign-in',
44425
+ loginAttemptBody: "We detected a sign-in on this device. If it was you, there's nothing to do. If you don't recognize it, block it.",
44426
+ loginAttemptMine: 'Yes, it was me',
44427
+ loginAttemptBlock: 'Not me — block it',
44428
+ loginAttemptClose: 'Close',
44429
+ loginAttemptDone: 'Device blocked. Reset your password to secure your account.',
44430
+ loginAttemptUnknownDevice: 'Unknown device',
44431
+ sessionsTitle: 'Active sessions',
44432
+ sessionsTitleCount: 'Active sessions ({n})',
44433
+ sessionsLoading: 'Loading sessions…',
44434
+ sessionsEmpty: 'No active sessions.',
44435
+ sessionsThis: 'This session',
44436
+ sessionsRevoke: 'End',
44437
+ sessionsRevokeAll: 'End all others',
44438
+ sessionsViewAll: 'View all',
44439
+ sessionsModalTitle: 'All active sessions',
44440
+ sessionsRevoked: 'Session ended.',
44441
+ sessionsAllRevoked: 'All other sessions were ended.',
44442
+ sessionsError: 'Could not complete the operation.',
44443
+ revokeAllConfirmTitle: 'End all other sessions',
44444
+ revokeAllConfirmMessage: "You're about to end every session except this one. You'll need to sign in again on the other devices.",
44445
+ revokeAllConfirm: 'End all',
44446
+ cancel: 'Cancel',
44447
+ quickActionsTitle: 'Other options',
44448
+ changePassword: 'Change password',
44449
+ changePasswordDesc: 'Update your access password',
44450
+ changeEmail: 'Change email',
44451
+ changeEmailDesc: 'Update the email address on your account',
44452
+ mfa: 'Two-step verification (MFA)',
44453
+ mfaDesc: 'Add a second factor to your sign-in',
44454
+ sessions: 'Active sessions',
44455
+ sessionsDesc: 'Review and sign out from other devices',
44456
+ policiesTitle: 'Related policies',
44457
+ privacyLink: 'Privacy Policy',
44458
+ cookiesLink: 'Cookie Policy',
44459
+ dataUsageLink: 'Data Usage',
44460
+ termsLink: 'Terms and Conditions',
44461
+ thirdPartyLink: 'Third-Party Software and Services',
44462
+ legalNoticeLink: 'Legal Notice',
44463
+ soonHint: 'Coming soon',
44464
+ },
44465
+ };
44466
+
44467
+ addIcons({ laptopOutline, phonePortraitOutline });
44468
+ const DEFAULT_NAMESPACE = 'Settings.Security';
44469
+ /**
44470
+ * `val-security-view` — vista Seguridad full-feature autocontenida (organism).
44471
+ *
44472
+ * Promovida desde `showcase` bajo el proceso de ADR-021. Reúne en una sola vista:
44473
+ * - **Sesiones activas** — única vista user-facing de "dónde tengo sesión".
44474
+ * Lista inline (limit 3) + modal "Ver todas" (`val-session-list-modal`) +
44475
+ * revoke individual / revoke-all (con confirmación).
44476
+ * - **Action-cards** — cambiar contraseña, cambiar email y MFA (cada una abre
44477
+ * el modal correspondiente de la lib).
44478
+ * - **Políticas legales** — links resueltos vía `LegalLinkService` (apps
44479
+ * satélite apuntan al main site).
44480
+ *
44481
+ * Deep-links del email (gateados por `config.handleDeepLinks`, default `true`):
44482
+ * - `?token=...&action=...` → modal `val-login-attempt-modal` (autorizar/bloquear
44483
+ * el nuevo acceso).
44484
+ * - `?mfaCode=...` → abre el modal MFA con el código pre-cargado.
44485
+ *
44486
+ * NO renderiza ion-content — vive dentro de val-page-wrapper.
44487
+ *
44488
+ * Auto-registra sus defaults i18n (es/en) en el constructor si el consumer no
44489
+ * proveyó el namespace configurado (default `Settings.Security`).
44490
+ */
44491
+ class SecurityViewComponent {
44492
+ /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
44493
+ get ns() {
44494
+ return this.resolvedConfig().i18nNamespace;
44495
+ }
44496
+ constructor() {
44497
+ this.nav = inject(NavigationService);
44498
+ this.i18n = inject(I18nService);
44499
+ this.deviceService = inject(DeviceService);
44500
+ this.legalLink = inject(LegalLinkService);
44501
+ this.toast = inject(ToastService);
44502
+ this.errors = inject(ValtechErrorService);
44503
+ this.router = inject(Router, { optional: true });
44504
+ this.route = inject(ActivatedRoute, { optional: true });
44505
+ this.sessionService = inject(SessionService);
44506
+ this.modalService = inject(ModalService);
44507
+ this.confirmDialog = inject(ConfirmationDialogService);
44508
+ this.resolvedConfig = computed(() => {
44509
+ const fromRoute = (this.route?.snapshot.data['securityConfig'] ?? {});
44510
+ const merged = { ...fromRoute, ...(this.config ?? {}) };
44511
+ return {
44512
+ showSessions: merged.showSessions ?? true,
44513
+ showChangePassword: merged.showChangePassword ?? true,
44514
+ showChangeEmail: merged.showChangeEmail ?? true,
44515
+ showMfa: merged.showMfa ?? true,
44516
+ showPolicies: merged.showPolicies ?? true,
44517
+ i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE,
44518
+ handleDeepLinks: merged.handleDeepLinks ?? true,
44519
+ homeRoute: merged.homeRoute ?? '/app/home',
44520
+ onMfaCompleted: merged.onMfaCompleted,
44521
+ };
44522
+ });
44523
+ /**
44524
+ * Lista de dispositivos del user. No se renderiza — solo alimenta el modal
44525
+ * de confirmación de inicio de sesión (`handleActionTokenFromUrl`) para
44526
+ * resolver un label legible desde el `targetId` del token.
44527
+ */
44528
+ this.devices = signal([]);
44529
+ this.sessions = signal([]);
44530
+ this.sessionsLoading = signal(true);
44531
+ this.isChangeEmailModalOpen = signal(false);
44532
+ this.isChangePasswordModalOpen = signal(false);
44533
+ this.isMfaModalOpen = signal(false);
44534
+ /** Código MFA del deep-link `/verify` — pre-carga el modal en confirmación. */
44535
+ this.mfaPrefillCode = signal(undefined);
44536
+ this.pageTitle = computed(() => this.tt('pageTitle'));
44537
+ this.pageDescription = computed(() => this.tt('pageDescription'));
44538
+ this.quickActionsTitle = computed(() => this.tt('quickActionsTitle'));
44539
+ this.policiesTitle = computed(() => this.tt('policiesTitle'));
44540
+ this.hasActionCards = computed(() => this.actionCards().length > 0);
44541
+ this.actionCards = computed(() => {
44542
+ this.i18n.lang();
44543
+ const cfg = this.resolvedConfig();
44544
+ const cards = [];
44545
+ if (cfg.showChangePassword) {
44546
+ cards.push({
44547
+ token: 'action-change-password',
44548
+ title: this.tt('changePassword'),
44549
+ description: this.tt('changePasswordDesc'),
44550
+ icon: { ionicon: 'key-outline', color: 'dark', backgroundColor: 'light' },
44551
+ showChevron: true,
44552
+ shadowed: true,
44553
+ });
44554
+ }
44555
+ if (cfg.showChangeEmail) {
44556
+ cards.push({
44557
+ token: 'action-change-email',
44558
+ title: this.tt('changeEmail'),
44559
+ description: this.tt('changeEmailDesc'),
44560
+ icon: { ionicon: 'mail-outline', color: 'dark', backgroundColor: 'light' },
44561
+ showChevron: true,
44562
+ shadowed: true,
44563
+ });
44564
+ }
44565
+ if (cfg.showMfa) {
44566
+ cards.push({
44567
+ token: 'action-mfa',
44568
+ title: this.tt('mfa'),
44569
+ description: this.tt('mfaDesc'),
44570
+ icon: { ionicon: 'shield-checkmark-outline', color: 'dark', backgroundColor: 'light' },
44571
+ showChevron: true,
44572
+ shadowed: true,
44573
+ });
44574
+ }
44575
+ return cards;
44576
+ });
44577
+ /**
44578
+ * Fuente única de links legales del producto. Se resuelven vía
44579
+ * `LegalLinkService` — apps satélite apuntan al main site, el main site los
44580
+ * deja relativos.
44581
+ */
44582
+ this.policyLinksProps = computed(() => {
44583
+ const locale = this.i18n.lang();
44584
+ const link = (path, labelKey) => ({
44585
+ label: this.tt(labelKey),
44586
+ href: this.legalLink.resolve(path, { locale }),
44587
+ external: this.legalLink.isExternal(path),
44588
+ });
44589
+ return {
44590
+ links: [
44591
+ link('/legal/terms', 'termsLink'),
44592
+ link('/legal/privacy', 'privacyLink'),
44593
+ link('/legal/cookies', 'cookiesLink'),
44594
+ link('/legal/data-usage', 'dataUsageLink'),
44595
+ link('/legal/third-party', 'thirdPartyLink'),
44596
+ link('/legal/legal-notice', 'legalNoticeLink'),
44597
+ ],
44598
+ showDividers: true,
44599
+ };
44600
+ });
44601
+ // ===========================================================================
44602
+ // Sessions
44603
+ // ===========================================================================
44604
+ this.sessionsTitle = computed(() => this.tt('sessionsTitle'));
44605
+ this.sessionsEmptyLabel = computed(() => this.tt('sessionsEmpty'));
44606
+ this.sessionsThis = computed(() => this.tt('sessionsThis'));
44607
+ this.hasOtherSessions = computed(() => this.sessions().some(s => !s.isCurrent));
44608
+ /** Sesiones ordenadas: current primero, resto por createdAt desc. */
44609
+ this.sortedSessions = computed(() => {
44610
+ const all = [...this.sessions()];
44611
+ return all.sort((a, b) => {
44612
+ if (a.isCurrent && !b.isCurrent)
44613
+ return -1;
44614
+ if (!a.isCurrent && b.isCurrent)
44615
+ return 1;
44616
+ return (b.createdAt || '').localeCompare(a.createdAt || '');
44617
+ });
44618
+ });
44619
+ /** Limit de sesiones mostradas inline. Si total > limit, "Ver todas" abre modal. */
44620
+ this.VISIBLE_LIMIT = 3;
44621
+ this.visibleSessions = computed(() => this.sortedSessions().slice(0, this.VISIBLE_LIMIT));
44622
+ this.hasMoreSessions = computed(() => this.sortedSessions().length > this.VISIBLE_LIMIT);
44623
+ /** Titulo con count: "Sesiones activas (4)". Sin count si N=0. */
44624
+ this.sessionsTitleWithCount = computed(() => {
44625
+ const n = this.sortedSessions().length;
44626
+ if (n === 0)
44627
+ return this.sessionsTitle();
44628
+ return this.tt('sessionsTitleCount').replace('{n}', String(n));
44629
+ });
44630
+ this.revokeOneButtonProps = computed(() => ({
44631
+ text: this.tt('sessionsRevoke'),
44632
+ color: 'dark',
44633
+ fill: 'clear',
44634
+ size: 'small',
44635
+ type: 'button',
44636
+ token: 'session-revoke',
44637
+ }));
44638
+ this.viewAllButtonProps = computed(() => ({
44639
+ text: this.tt('sessionsViewAll'),
44640
+ color: 'dark',
44641
+ fill: 'clear',
44642
+ size: 'small',
44643
+ type: 'button',
44644
+ state: ComponentStates.ENABLED,
44645
+ }));
44646
+ /** Action-header de Sesiones: title + count + boton "Cerrar todas las demas". */
44647
+ this.sessionsHeader = computed(() => ({
44648
+ headingKind: 'title',
44649
+ title: {
44650
+ size: 'medium',
44651
+ color: 'dark',
44652
+ bold: true,
44653
+ content: this.sessionsTitleWithCount(),
44654
+ },
44655
+ action: {
44656
+ text: this.tt('sessionsRevokeAll'),
44657
+ color: 'dark',
44658
+ fill: 'outline',
44659
+ size: 'small',
44660
+ type: 'button',
44661
+ state: ComponentStates.ENABLED,
44662
+ token: 'session-revoke-all',
44663
+ handler: () => this.onRevokeAll(),
44664
+ },
44665
+ }));
44666
+ const ns = this.ns;
44667
+ if (!this.i18n.hasNamespace(ns)) {
44668
+ this.i18n.registerContent(ns, SECURITY_VIEW_I18N);
44669
+ }
44670
+ this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
44671
+ // Carga inicial de sesiones - recarga reactiva a cambio de locale para
44672
+ // que los textos y metadatos se mantengan frescos. Los devices solo se
44673
+ // cargan on-demand desde el flujo del modal (handleActionTokenFromUrl).
44674
+ effect(() => {
44675
+ this.i18n.lang();
44676
+ const showSessions = this.resolvedConfig().showSessions;
44677
+ untracked(() => {
44678
+ if (showSessions)
44679
+ void this.loadSessions();
44680
+ });
44681
+ }, { allowSignalWrites: true });
44682
+ // Pull-to-refresh estandar (Regla #3) - re-fetchea las sesiones activas.
44683
+ connectPageRefresh(() => this.loadSessions());
44684
+ // Deep-links del email - solo si estan habilitados Y hay ruta (montada como
44685
+ // route). En modo embebido sin ruta es no-op.
44686
+ if (this.resolvedConfig().handleDeepLinks && this.route) {
44687
+ // Si el user llego via link del email (?token=...&action=...), abre el
44688
+ // modal de confirmacion para autorizar/rechazar el intento.
44689
+ void this.handleActionTokenFromUrl();
44690
+ // Deep-link del email de setup MFA-email (?mfaCode=...).
44691
+ this.handleMfaCodeFromUrl();
44692
+ }
44693
+ }
44694
+ /**
44695
+ * Llega vía link del email de "nuevo acceso" (`?token=...`). Valida el token
44696
+ * (sin ejecutar) y levanta el modal de confirmación. El bloqueo solo se
44697
+ * ejecuta si el user confirma "no fui yo" dentro del modal.
44698
+ */
44699
+ async handleActionTokenFromUrl() {
44700
+ const token = this.route?.snapshot.queryParamMap.get('token');
44701
+ if (!token)
44702
+ return;
44703
+ // Limpia ?token/?action de la URL ya - el modal es la fuente de verdad;
44704
+ // un refresh no debe re-abrirlo ni re-validar.
44705
+ void this.router?.navigate([], {
44706
+ relativeTo: this.route ?? undefined,
44707
+ queryParams: { token: null, action: null },
44708
+ queryParamsHandling: 'merge',
44709
+ replaceUrl: true,
44710
+ });
44711
+ let validation;
44712
+ try {
44713
+ validation = await firstValueFrom(this.deviceService.validateAction(token));
44714
+ }
44715
+ catch (err) {
44716
+ this.errors.handle(err, {
44717
+ context: 'security.validateActionToken',
44718
+ fallbackKey: 'tokenActionFailed',
44719
+ i18nNamespace: this.ns,
44720
+ });
44721
+ return;
44722
+ }
44723
+ if (!validation.valid) {
44724
+ this.toastTokenInvalid();
44725
+ return;
44726
+ }
44727
+ // Resuelve el dispositivo objetivo desde la lista cargada para mostrarlo.
44728
+ await this.loadDevices();
44729
+ const target = this.devices().find(d => d.deviceId === validation.targetId);
44730
+ const isMobile = target?.platform === 'ios' || target?.platform === 'android';
44731
+ const onBlock = async () => {
44732
+ await firstValueFrom(this.deviceService.executeAction(token));
44733
+ await this.loadDevices();
44734
+ };
44735
+ const result = await this.modalService.open({
44736
+ component: LoginAttemptModalComponent,
44737
+ componentProps: {
44738
+ title: this.tt('loginAttemptTitle'),
44739
+ body: this.tt('loginAttemptBody'),
44740
+ deviceLabel: target ? this.describeDevice(target) : this.tt('loginAttemptUnknownDevice'),
44741
+ metaLabel: target ? this.formatDate(target.lastActive) : '',
44742
+ mineLabel: this.tt('loginAttemptMine'),
44743
+ blockLabel: this.tt('loginAttemptBlock'),
44744
+ closeLabel: this.tt('loginAttemptClose'),
44745
+ doneLabel: this.tt('loginAttemptDone'),
44746
+ isMobile,
44747
+ onBlock,
44748
+ },
44749
+ });
44750
+ if (result.role === 'blocked') {
44751
+ this.toastOk(this.tt('blocked'));
44752
+ }
44753
+ else if (result.role === 'error') {
44754
+ this.toastTokenInvalid();
44755
+ }
44756
+ }
44757
+ toastTokenInvalid() {
44758
+ this.toast.show({
44759
+ message: this.tt('tokenActionFailed'),
44760
+ duration: 3500,
44761
+ color: 'dark',
44762
+ position: 'top',
44763
+ });
44764
+ }
44765
+ async loadDevices() {
44766
+ try {
44767
+ const list = await firstValueFrom(this.deviceService.listDevices());
44768
+ this.devices.set(list || []);
44769
+ }
44770
+ catch {
44771
+ this.devices.set([]);
44772
+ }
44773
+ }
44774
+ onActionClick(action) {
44775
+ if (action.token === 'action-change-password') {
44776
+ this.isChangePasswordModalOpen.set(true);
44777
+ return;
44778
+ }
44779
+ if (action.token === 'action-change-email') {
44780
+ this.isChangeEmailModalOpen.set(true);
44781
+ return;
44782
+ }
44783
+ if (action.token === 'action-mfa') {
44784
+ this.mfaPrefillCode.set(undefined);
44785
+ this.isMfaModalOpen.set(true);
44786
+ }
44787
+ }
44788
+ onMfaModalDismissed() {
44789
+ this.isMfaModalOpen.set(false);
44790
+ this.mfaPrefillCode.set(undefined);
44791
+ }
44792
+ /**
44793
+ * El user llegó vía link del email, confirmó MFA en el modal. Cerramos el
44794
+ * modal y, si hay hook `onMfaCompleted`, lo invocamos; si no, navegamos al
44795
+ * `homeRoute` configurado — quedarse en Settings → Security daría la
44796
+ * sensación de "tarea inconclusa" cuando el flow ya terminó.
44797
+ */
44798
+ onMfaEnabledViaDeeplink() {
44799
+ this.isMfaModalOpen.set(false);
44800
+ this.mfaPrefillCode.set(undefined);
44801
+ const cfg = this.resolvedConfig();
44802
+ if (cfg.onMfaCompleted) {
44803
+ cfg.onMfaCompleted();
44804
+ }
44805
+ else {
44806
+ void this.router?.navigateByUrl(cfg.homeRoute);
44807
+ }
44808
+ }
44809
+ /**
44810
+ * Deep-link `/verify?...&verify-code=` → redirige a esta vista con `?mfaCode=`.
44811
+ * Abre el modal MFA con el código pre-cargado en el paso de confirmación.
44812
+ */
44813
+ handleMfaCodeFromUrl() {
44814
+ const code = this.route?.snapshot.queryParamMap.get('mfaCode');
44815
+ if (!code)
44816
+ return;
44817
+ // Limpia el query param - un refresh no debe re-abrir el modal.
44818
+ void this.router?.navigate([], {
44819
+ relativeTo: this.route ?? undefined,
44820
+ queryParams: { mfaCode: null },
44821
+ queryParamsHandling: 'merge',
44822
+ replaceUrl: true,
44823
+ });
44824
+ this.mfaPrefillCode.set(code);
44825
+ this.isMfaModalOpen.set(true);
44826
+ }
44827
+ describeDevice(d) {
44828
+ const parts = [d.platform, d.browser, d.location].filter(Boolean);
44829
+ return parts.join(' · ') || d.deviceId;
44830
+ }
44831
+ formatDate(iso) {
44832
+ if (!iso)
44833
+ return '';
44834
+ try {
44835
+ return new Date(iso).toLocaleString(this.i18n.lang());
44836
+ }
44837
+ catch {
44838
+ return iso;
44839
+ }
44840
+ }
44841
+ toastOk(msg) {
44842
+ this.toast.show({
44843
+ message: msg,
44844
+ duration: 2200,
44845
+ color: 'dark',
44846
+ position: 'top',
44847
+ });
44848
+ }
44849
+ // ===========================================================================
44850
+ // Sessions - métodos
44851
+ // ===========================================================================
44852
+ /** Icono según device: phone si UA mobile, sino laptop. */
44853
+ deviceIcon(s) {
44854
+ const ua = (s.deviceInfo || '').toLowerCase();
44855
+ const isMobile = /iphone|ipad|ipod|android|mobile/.test(ua);
44856
+ return isMobile ? 'phone-portrait-outline' : 'laptop-outline';
44857
+ }
44858
+ formatSessionMeta(s) {
44859
+ const parts = [];
44860
+ if (s.location)
44861
+ parts.push(s.location);
44862
+ else if (s.ip)
44863
+ parts.push(s.ip);
44864
+ if (s.createdAt)
44865
+ parts.push(new Date(s.createdAt).toLocaleString());
44866
+ return parts.join(' · ');
44867
+ }
44868
+ async onRevokeOne(sessionId) {
44869
+ try {
44870
+ await firstValueFrom(this.sessionService.revokeSession(sessionId));
44871
+ this.toast.show({
44872
+ message: this.tt('sessionsRevoked'),
44873
+ duration: 2500,
44874
+ color: 'dark',
44875
+ position: 'top',
44876
+ });
44877
+ await this.loadSessions();
44878
+ }
44879
+ catch (err) {
44880
+ this.errors.handle(err, {
44881
+ context: 'security.revokeSession',
44882
+ fallbackKey: 'sessionsError',
44883
+ i18nNamespace: this.ns,
44884
+ });
44885
+ }
44886
+ }
44887
+ async onRevokeAll() {
44888
+ const result = await this.confirmDialog.confirmDestructive({
44889
+ title: this.tt('revokeAllConfirmTitle'),
44890
+ message: this.tt('revokeAllConfirmMessage'),
44891
+ confirmButton: { text: this.tt('revokeAllConfirm') },
44892
+ cancelButton: { text: this.tt('cancel') },
44893
+ });
44894
+ if (!result.confirmed)
44895
+ return;
44896
+ try {
44897
+ await firstValueFrom(this.sessionService.revokeAllSessions());
44898
+ this.toast.show({
44899
+ message: this.tt('sessionsAllRevoked'),
44900
+ duration: 2500,
44901
+ color: 'dark',
44902
+ position: 'top',
44903
+ });
44904
+ await this.loadSessions();
44905
+ }
44906
+ catch (err) {
44907
+ this.errors.handle(err, {
44908
+ context: 'security.revokeAllSessions',
44909
+ fallbackKey: 'sessionsError',
44910
+ i18nNamespace: this.ns,
44911
+ });
44912
+ }
44913
+ }
44914
+ /** Abre modal con la lista completa. Cada revoke pasa por `onRevoke`. */
44915
+ async openSessionsModal() {
44916
+ const onRevoke = async (sessionId) => {
44917
+ try {
44918
+ await firstValueFrom(this.sessionService.revokeSession(sessionId));
44919
+ this.toast.show({
44920
+ message: this.tt('sessionsRevoked'),
44921
+ duration: 2500,
44922
+ color: 'dark',
44923
+ position: 'top',
44924
+ });
44925
+ }
44926
+ catch (err) {
44927
+ this.errors.handle(err, {
44928
+ context: 'security.revokeSession.modal',
44929
+ fallbackKey: 'sessionsError',
44930
+ i18nNamespace: this.ns,
44931
+ });
44932
+ throw err;
44933
+ }
44934
+ await this.loadSessions();
44935
+ return this.sortedSessions();
44936
+ };
44937
+ await this.modalService.openSheet({
44938
+ component: SessionListModalComponent,
44939
+ componentProps: {
44940
+ sessions: this.sortedSessions(),
44941
+ title: this.tt('sessionsModalTitle'),
44942
+ closeLabel: this.tt('loginAttemptClose'),
44943
+ thisLabel: this.sessionsThis(),
44944
+ revokeLabel: this.tt('sessionsRevoke'),
44945
+ onRevoke,
44946
+ },
44947
+ initialBreakpoint: 1,
44948
+ breakpoints: [0, 1],
44949
+ showHandle: false,
44950
+ });
44951
+ }
44952
+ async loadSessions() {
44953
+ this.sessionsLoading.set(true);
44954
+ try {
44955
+ const list = await firstValueFrom(this.sessionService.listSessions());
44956
+ this.sessions.set(list);
44957
+ }
44958
+ catch (err) {
44959
+ console.error('[Security] listSessions failed:', err);
44960
+ this.sessions.set([]);
44961
+ }
44962
+ finally {
44963
+ this.sessionsLoading.set(false);
44964
+ }
44965
+ }
44966
+ /** Lee del namespace configurado. */
44967
+ tt(key) {
44968
+ return this.i18n.t(key, this.ns);
44969
+ }
44970
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SecurityViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
44971
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SecurityViewComponent, isStandalone: true, selector: "val-security-view", inputs: { config: "config" }, ngImport: i0, template: `
44972
+ <div class="page">
44973
+ <header class="page-header">
44974
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
44975
+ <val-title
44976
+ [props]="{
44977
+ size: 'large',
44978
+ color: 'dark',
44979
+ bold: false,
44980
+ content: pageDescription(),
44981
+ }"
44982
+ />
44983
+ </header>
44984
+
44985
+ @if (resolvedConfig().showSessions) {
44986
+ <!-- 1. Active sessions - unica vista user-facing de donde tengo sesion. -->
44987
+ <section class="settings-section">
44988
+ @if (hasOtherSessions()) {
44989
+ <val-action-header [props]="sessionsHeader()" />
44990
+ } @else {
44991
+ <val-title
44992
+ [props]="{
44993
+ size: 'medium',
44994
+ color: 'dark',
44995
+ bold: true,
44996
+ content: sessionsTitleWithCount(),
44997
+ }"
44998
+ />
44999
+ }
45000
+
45001
+ <div class="section-body">
45002
+ @if (sessionsLoading()) {
45003
+ <val-skeleton-layout [props]="{ preset: 'list', rows: 3 }" />
45004
+ } @else if (sessions().length === 0) {
45005
+ <val-text
45006
+ [props]="{
45007
+ content: sessionsEmptyLabel(),
45008
+ size: 'small',
45009
+ color: 'medium',
45010
+ bold: false,
45011
+ }"
45012
+ />
45013
+ } @else {
45014
+ <ul class="sessions">
45015
+ @for (s of visibleSessions(); track s.sessionId) {
45016
+ <li class="session" [class.session--current]="s.isCurrent">
45017
+ <ion-icon class="session__icon" [name]="deviceIcon(s)" aria-hidden="true" />
45018
+ <div class="session__body">
45019
+ <val-text
45020
+ [props]="{
45021
+ content: s.deviceInfo || s.deviceId || '—',
45022
+ size: 'medium',
45023
+ color: 'dark',
45024
+ bold: true,
45025
+ }"
45026
+ />
45027
+ <val-text
45028
+ [props]="{
45029
+ content: formatSessionMeta(s),
45030
+ size: 'small',
45031
+ color: 'medium',
45032
+ bold: false,
45033
+ }"
45034
+ />
45035
+ </div>
45036
+ <div class="session__end">
45037
+ @if (s.isCurrent) {
45038
+ <span class="session__badge">{{ sessionsThis() }}</span>
45039
+ } @else {
45040
+ <val-button [props]="revokeOneButtonProps()" (click)="onRevokeOne(s.sessionId)" />
45041
+ }
45042
+ </div>
45043
+ </li>
45044
+ }
45045
+ </ul>
45046
+ @if (hasMoreSessions()) {
45047
+ <div class="row-actions row-actions--center">
45048
+ <val-button [props]="viewAllButtonProps()" (click)="openSessionsModal()" />
45049
+ </div>
45050
+ }
45051
+ }
45052
+ </div>
45053
+ </section>
45054
+ }
45055
+
45056
+ @if (hasActionCards()) {
45057
+ <!-- 2. Quick actions: change password, change email, MFA -->
45058
+ <section class="settings-section">
45059
+ <h2>{{ quickActionsTitle() }}</h2>
45060
+ <ion-grid class="cards-grid">
45061
+ <ion-row>
45062
+ @for (action of actionCards(); track action.token) {
45063
+ <ion-col size="12" size-sm="6">
45064
+ <val-action-card [props]="action" (onClick)="onActionClick(action)" />
45065
+ </ion-col>
45066
+ }
45067
+ </ion-row>
45068
+ </ion-grid>
45069
+ </section>
45070
+ }
45071
+
45072
+ @if (resolvedConfig().showPolicies) {
45073
+ <!-- 3. Related policies -->
45074
+ <section class="settings-section">
45075
+ <h2>{{ policiesTitle() }}</h2>
45076
+ <val-page-links [props]="policyLinksProps()" />
45077
+ </section>
45078
+ }
45079
+
45080
+ <val-change-email-modal
45081
+ [isOpen]="isChangeEmailModalOpen()"
45082
+ (changed)="isChangeEmailModalOpen.set(false)"
45083
+ (dismissed)="isChangeEmailModalOpen.set(false)"
45084
+ />
45085
+
45086
+ <val-change-password-modal
45087
+ [isOpen]="isChangePasswordModalOpen()"
45088
+ (changed)="isChangePasswordModalOpen.set(false)"
45089
+ (dismissed)="isChangePasswordModalOpen.set(false)"
45090
+ />
45091
+
45092
+ <val-mfa-modal
45093
+ [isOpen]="isMfaModalOpen()"
45094
+ [prefillCode]="mfaPrefillCode()"
45095
+ (dismissed)="onMfaModalDismissed()"
45096
+ (enabledViaDeeplink)="onMfaEnabledViaDeeplink()"
45097
+ />
45098
+ </div>
45099
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}h2{font-size:16px;font-weight:600;margin:0 0 4px}.cards-grid{margin:0 -4px;padding:0}.cards-grid ion-col{padding:4px;display:flex;flex-direction:column}.cards-grid ion-col val-action-card{flex:1;display:flex;flex-direction:column}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:4px}.sessions{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}.session{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ion-color-light, rgba(0, 0, 0, .04));border:1px solid transparent;transition:background .15s ease,border-color .15s ease}.session--current{background:#2dd36f1a;border-color:#2dd36f4d}.session__icon{font-size:26px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0}.session--current .session__icon{color:var(--ion-color-success-shade, #28ba62)}.session__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.session__end{flex-shrink:0}.session__badge{display:inline-block;padding:4px 10px;font-size:11px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--ion-color-success-shade, #28ba62);background:#2dd36f2e;border-radius:999px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonGrid, selector: "ion-grid", inputs: ["fixed"] }, { kind: "component", type: IonRow, selector: "ion-row" }, { kind: "component", type: IonCol, selector: "ion-col", inputs: ["offset", "offsetLg", "offsetMd", "offsetSm", "offsetXl", "offsetXs", "pull", "pullLg", "pullMd", "pullSm", "pullXl", "pullXs", "push", "pushLg", "pushMd", "pushSm", "pushXl", "pushXs", "size", "sizeLg", "sizeMd", "sizeSm", "sizeXl", "sizeXs"] }, { 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: ActionCardComponent, selector: "val-action-card", inputs: ["props"], outputs: ["onClick"] }, { kind: "component", type: ActionHeaderComponent, selector: "val-action-header", inputs: ["props"] }, { kind: "component", type: PageLinksComponent, selector: "val-page-links", inputs: ["props"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: ChangeEmailModalComponent, selector: "val-change-email-modal", inputs: ["isOpen"], outputs: ["changed", "dismissed"] }, { kind: "component", type: ChangePasswordModalComponent, selector: "val-change-password-modal", inputs: ["isOpen"], outputs: ["changed", "dismissed"] }, { kind: "component", type: MfaModalComponent, selector: "val-mfa-modal", inputs: ["isOpen", "prefillCode"], outputs: ["changed", "enabledViaDeeplink", "dismissed"] }] }); }
45100
+ }
45101
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SecurityViewComponent, decorators: [{
45102
+ type: Component,
45103
+ args: [{ selector: 'val-security-view', standalone: true, imports: [
45104
+ CommonModule,
45105
+ IonGrid,
45106
+ IonRow,
45107
+ IonCol,
45108
+ IonIcon,
45109
+ DisplayComponent,
45110
+ TitleComponent,
45111
+ TextComponent,
45112
+ ButtonComponent,
45113
+ ActionCardComponent,
45114
+ ActionHeaderComponent,
45115
+ PageLinksComponent,
45116
+ SkeletonLayoutComponent,
45117
+ ChangeEmailModalComponent,
45118
+ ChangePasswordModalComponent,
45119
+ MfaModalComponent,
45120
+ ], template: `
45121
+ <div class="page">
45122
+ <header class="page-header">
45123
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
45124
+ <val-title
45125
+ [props]="{
45126
+ size: 'large',
45127
+ color: 'dark',
45128
+ bold: false,
45129
+ content: pageDescription(),
45130
+ }"
45131
+ />
45132
+ </header>
45133
+
45134
+ @if (resolvedConfig().showSessions) {
45135
+ <!-- 1. Active sessions - unica vista user-facing de donde tengo sesion. -->
45136
+ <section class="settings-section">
45137
+ @if (hasOtherSessions()) {
45138
+ <val-action-header [props]="sessionsHeader()" />
45139
+ } @else {
45140
+ <val-title
45141
+ [props]="{
45142
+ size: 'medium',
45143
+ color: 'dark',
45144
+ bold: true,
45145
+ content: sessionsTitleWithCount(),
45146
+ }"
45147
+ />
45148
+ }
45149
+
45150
+ <div class="section-body">
45151
+ @if (sessionsLoading()) {
45152
+ <val-skeleton-layout [props]="{ preset: 'list', rows: 3 }" />
45153
+ } @else if (sessions().length === 0) {
45154
+ <val-text
45155
+ [props]="{
45156
+ content: sessionsEmptyLabel(),
45157
+ size: 'small',
45158
+ color: 'medium',
45159
+ bold: false,
45160
+ }"
45161
+ />
45162
+ } @else {
45163
+ <ul class="sessions">
45164
+ @for (s of visibleSessions(); track s.sessionId) {
45165
+ <li class="session" [class.session--current]="s.isCurrent">
45166
+ <ion-icon class="session__icon" [name]="deviceIcon(s)" aria-hidden="true" />
45167
+ <div class="session__body">
45168
+ <val-text
45169
+ [props]="{
45170
+ content: s.deviceInfo || s.deviceId || '—',
45171
+ size: 'medium',
45172
+ color: 'dark',
45173
+ bold: true,
45174
+ }"
45175
+ />
45176
+ <val-text
45177
+ [props]="{
45178
+ content: formatSessionMeta(s),
45179
+ size: 'small',
45180
+ color: 'medium',
45181
+ bold: false,
45182
+ }"
45183
+ />
45184
+ </div>
45185
+ <div class="session__end">
45186
+ @if (s.isCurrent) {
45187
+ <span class="session__badge">{{ sessionsThis() }}</span>
45188
+ } @else {
45189
+ <val-button [props]="revokeOneButtonProps()" (click)="onRevokeOne(s.sessionId)" />
45190
+ }
45191
+ </div>
45192
+ </li>
45193
+ }
45194
+ </ul>
45195
+ @if (hasMoreSessions()) {
45196
+ <div class="row-actions row-actions--center">
45197
+ <val-button [props]="viewAllButtonProps()" (click)="openSessionsModal()" />
45198
+ </div>
45199
+ }
45200
+ }
45201
+ </div>
45202
+ </section>
45203
+ }
45204
+
45205
+ @if (hasActionCards()) {
45206
+ <!-- 2. Quick actions: change password, change email, MFA -->
45207
+ <section class="settings-section">
45208
+ <h2>{{ quickActionsTitle() }}</h2>
45209
+ <ion-grid class="cards-grid">
45210
+ <ion-row>
45211
+ @for (action of actionCards(); track action.token) {
45212
+ <ion-col size="12" size-sm="6">
45213
+ <val-action-card [props]="action" (onClick)="onActionClick(action)" />
45214
+ </ion-col>
45215
+ }
45216
+ </ion-row>
45217
+ </ion-grid>
45218
+ </section>
45219
+ }
45220
+
45221
+ @if (resolvedConfig().showPolicies) {
45222
+ <!-- 3. Related policies -->
45223
+ <section class="settings-section">
45224
+ <h2>{{ policiesTitle() }}</h2>
45225
+ <val-page-links [props]="policyLinksProps()" />
45226
+ </section>
45227
+ }
45228
+
45229
+ <val-change-email-modal
45230
+ [isOpen]="isChangeEmailModalOpen()"
45231
+ (changed)="isChangeEmailModalOpen.set(false)"
45232
+ (dismissed)="isChangeEmailModalOpen.set(false)"
45233
+ />
45234
+
45235
+ <val-change-password-modal
45236
+ [isOpen]="isChangePasswordModalOpen()"
45237
+ (changed)="isChangePasswordModalOpen.set(false)"
45238
+ (dismissed)="isChangePasswordModalOpen.set(false)"
45239
+ />
45240
+
45241
+ <val-mfa-modal
45242
+ [isOpen]="isMfaModalOpen()"
45243
+ [prefillCode]="mfaPrefillCode()"
45244
+ (dismissed)="onMfaModalDismissed()"
45245
+ (enabledViaDeeplink)="onMfaEnabledViaDeeplink()"
45246
+ />
45247
+ </div>
45248
+ `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}h2{font-size:16px;font-weight:600;margin:0 0 4px}.cards-grid{margin:0 -4px;padding:0}.cards-grid ion-col{padding:4px;display:flex;flex-direction:column}.cards-grid ion-col val-action-card{flex:1;display:flex;flex-direction:column}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:4px}.sessions{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}.session{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ion-color-light, rgba(0, 0, 0, .04));border:1px solid transparent;transition:background .15s ease,border-color .15s ease}.session--current{background:#2dd36f1a;border-color:#2dd36f4d}.session__icon{font-size:26px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0}.session--current .session__icon{color:var(--ion-color-success-shade, #28ba62)}.session__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.session__end{flex-shrink:0}.session__badge{display:inline-block;padding:4px 10px;font-size:11px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--ion-color-success-shade, #28ba62);background:#2dd36f2e;border-radius:999px}\n"] }]
45249
+ }], ctorParameters: () => [], propDecorators: { config: [{
45250
+ type: Input
45251
+ }] } });
45252
+
45253
+ /**
45254
+ * Helper para montar la vista Seguridad (`val-security-view`) como ruta en una
45255
+ * app del factory. El `config` se pasa por route `data` (`securityConfig`) y el
45256
+ * componente lo lee como fallback de su `@Input() config`.
45257
+ *
45258
+ * @example
45259
+ * ```ts
45260
+ * // settings.routes.ts
45261
+ * export const settingsRoutes: Routes = [
45262
+ * ...provideValtechSecurityRoutes(),
45263
+ * { path: 'preferences', loadComponent: () => ... },
45264
+ * ];
45265
+ *
45266
+ * // con config acotada:
45267
+ * ...provideValtechSecurityRoutes({ config: { showPolicies: false } }),
45268
+ * ...provideValtechSecurityRoutes({ config: { homeRoute: '/app/dashboard' } }),
45269
+ * ```
45270
+ */
45271
+ function provideValtechSecurityRoutes(opts) {
45272
+ return [
45273
+ {
45274
+ path: opts?.path ?? 'security',
45275
+ component: SecurityViewComponent,
45276
+ data: { securityConfig: opts?.config },
45277
+ },
45278
+ ];
45279
+ }
45280
+
45281
+ /** Built-in label sets for the three platform locales. */
45282
+ const CALLOUT_LABELS = {
45283
+ es: {
45284
+ NOTE: 'Nota',
45285
+ TIP: 'Tip',
45286
+ INFO: 'Info',
45287
+ IMPORTANT: 'Importante',
45288
+ WARNING: 'Atención',
45289
+ CAUTION: 'Precaución',
45290
+ },
45291
+ en: {
45292
+ NOTE: 'Note',
45293
+ TIP: 'Tip',
45294
+ INFO: 'Info',
45295
+ IMPORTANT: 'Important',
45296
+ WARNING: 'Warning',
45297
+ CAUTION: 'Caution',
45298
+ },
45299
+ pt: {
45300
+ NOTE: 'Nota',
45301
+ TIP: 'Dica',
45302
+ INFO: 'Info',
45303
+ IMPORTANT: 'Importante',
45304
+ WARNING: 'Atenção',
45305
+ CAUTION: 'Cuidado',
45306
+ },
45307
+ };
45308
+ /**
45309
+ * Per-callout-kind color (Ionic semantic color). Fixed across locales since
45310
+ * red = danger no matter what language you speak.
45311
+ */
45312
+ const CALLOUT_COLORS = {
45313
+ NOTE: 'primary',
45314
+ TIP: 'success',
45315
+ INFO: 'tertiary',
45316
+ IMPORTANT: 'warning',
45317
+ WARNING: 'warning',
45318
+ CAUTION: 'danger',
45319
+ };
45320
+ const BOX_DRAWING = /[┌┐└┘├┤┬┴┼─│╔╗╚╝═║]/;
45321
+ const CALLOUT_RE = /^>\s*\[!(NOTE|TIP|INFO|WARNING|CAUTION|IMPORTANT)\]\s*(.*)$/i;
45322
+ const FENCE_RE = /^```([\w-]*)\s*$/;
45323
+ const HEADING_RE = /^(#{1,6})\s+(.+)$/;
45324
+ const SEPARATOR_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
45325
+ const UNORDERED_RE = /^\s*[-*+]\s+(.+)$/;
45326
+ const CHECKLIST_RE = /^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/;
45327
+ const ORDERED_RE = /^\s*\d+\.\s+(.+)$/;
45328
+ const TABLE_DIVIDER_RE = /^\s*\|?[\s:|-]+\|?\s*$/;
45329
+ const TABLE_ROW_RE = /^\s*\|(.+)\|\s*$/;
45330
+ /**
45331
+ * Pure Markdown → ArticleMetadata parser. No Angular deps — usable from Node scripts
45332
+ * (build-time generation) and from the Angular `MarkdownArticleParserService` wrapper.
45333
+ *
45334
+ * Supported syntax:
45335
+ * - Headings (#, ##, ### …) → title / subtitle
45336
+ * - Paragraphs → paragraph (with `processLinks` + `allowPartialBold`)
45337
+ * - Lists (-, *, 1.) → unordered / ordered list (- [ ] / - [x] for checklist)
45338
+ * - Blockquotes (>) → quote, with GitHub callouts `> [!NOTE|TIP|INFO|WARNING|CAUTION|IMPORTANT]` mapped to notes
45339
+ * - Code fences (```lang) → code
45340
+ * - Box-drawing ASCII art (┌┐└┘│─) auto-wrapped as code
45341
+ * - Tables → flattened into paragraphs with bold keys
45342
+ * - Horizontal rules (---, ***, ___) → separator
45343
+ */
45344
+ function parseMarkdownArticle(markdown, options) {
45345
+ const lines = normalize(markdown).split('\n');
45346
+ const elements = [];
45347
+ const labels = options?.calloutLabels ??
45348
+ (options?.locale ? CALLOUT_LABELS[options.locale] : CALLOUT_LABELS.es);
45349
+ let i = 0;
45350
+ while (i < lines.length) {
45351
+ const line = lines[i];
45352
+ if (line.trim() === '') {
45353
+ i++;
45354
+ continue;
45355
+ }
45356
+ const fence = line.match(FENCE_RE);
45357
+ if (fence) {
45358
+ const lang = fence[1] || undefined;
45359
+ const body = [];
45360
+ i++;
45361
+ while (i < lines.length && !FENCE_RE.test(lines[i])) {
45362
+ body.push(lines[i]);
45363
+ i++;
45364
+ }
45365
+ i++;
45366
+ elements.push({
45367
+ type: 'code',
45368
+ props: { code: body.join('\n'), language: lang, theme: 'dark' },
45369
+ });
45370
+ continue;
45371
+ }
45372
+ if (BOX_DRAWING.test(line)) {
45373
+ const body = [];
45374
+ while (i < lines.length && (lines[i].trim() === '' || BOX_DRAWING.test(lines[i]))) {
45375
+ body.push(lines[i]);
45376
+ i++;
45377
+ }
45378
+ while (body.length && body[body.length - 1].trim() === '')
45379
+ body.pop();
45380
+ elements.push({
45381
+ type: 'code',
45382
+ props: { code: body.join('\n'), language: 'text', theme: 'dark' },
45383
+ });
45384
+ continue;
45385
+ }
45386
+ if (SEPARATOR_RE.test(line)) {
45387
+ elements.push({ type: 'separator', props: { style: 'line' } });
45388
+ i++;
45389
+ continue;
45390
+ }
45391
+ const heading = line.match(HEADING_RE);
45392
+ if (heading) {
45393
+ elements.push(makeHeading(heading[1].length, heading[2].trim()));
45394
+ i++;
45395
+ continue;
45396
+ }
45397
+ if (line.startsWith('>')) {
45398
+ const block = [];
45399
+ while (i < lines.length && lines[i].startsWith('>')) {
45400
+ block.push(lines[i]);
45401
+ i++;
45402
+ }
45403
+ elements.push(makeQuoteOrCallout(block, labels));
45404
+ continue;
45405
+ }
45406
+ if (TABLE_ROW_RE.test(line)) {
45407
+ const rows = [];
45408
+ while (i < lines.length && TABLE_ROW_RE.test(lines[i])) {
45409
+ rows.push(lines[i]);
45410
+ i++;
45411
+ }
45412
+ elements.push(...makeTable(rows));
45413
+ continue;
45414
+ }
45415
+ if (CHECKLIST_RE.test(line) || UNORDERED_RE.test(line) || ORDERED_RE.test(line)) {
45416
+ const listType = CHECKLIST_RE.test(line)
45417
+ ? 'checklist'
45418
+ : ORDERED_RE.test(line)
45419
+ ? 'ordered'
45420
+ : 'unordered';
45421
+ const items = [];
45422
+ while (i < lines.length && lines[i].trim() !== '') {
45423
+ const cur = lines[i];
45424
+ const check = cur.match(CHECKLIST_RE);
45425
+ const unord = cur.match(UNORDERED_RE);
45426
+ const ord = cur.match(ORDERED_RE);
45427
+ if (check)
45428
+ items.push({ text: processInlineBold(check[2].trim()) });
45429
+ else if (ord && listType === 'ordered')
45430
+ items.push({ text: processInlineBold(ord[1].trim()) });
45431
+ else if (unord && listType !== 'ordered')
45432
+ items.push({ text: processInlineBold(unord[1].trim()) });
45433
+ else
45434
+ break;
45435
+ i++;
45436
+ }
45437
+ elements.push({ type: 'list', props: { items, listType } });
45438
+ continue;
45439
+ }
45440
+ const paragraph = [];
45441
+ while (i < lines.length && lines[i].trim() !== '' && !startsNewBlock(lines[i])) {
45442
+ paragraph.push(lines[i]);
45443
+ i++;
45444
+ }
45445
+ const text = processInlineBold(paragraph.join(' ').trim());
45446
+ if (text) {
45447
+ elements.push({
45448
+ type: 'paragraph',
45449
+ props: {
45450
+ content: text,
45451
+ size: 'medium',
45452
+ color: 'dark',
45453
+ bold: false,
45454
+ processLinks: true,
45455
+ allowPartialBold: true,
45456
+ },
45457
+ });
45458
+ }
45459
+ }
45460
+ // Strip parser-only options before merging into ArticleMetadata
45461
+ const { locale: _l, calloutLabels: _c, ...metadataOverrides } = options ?? {};
45462
+ return {
45463
+ elements,
45464
+ maxWidth: '900px',
45465
+ centered: true,
45466
+ theme: 'auto',
45467
+ ...metadataOverrides,
45468
+ };
45469
+ }
45470
+ function normalize(md) {
45471
+ return md.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
45472
+ }
45473
+ function startsNewBlock(line) {
45474
+ return (HEADING_RE.test(line) ||
45475
+ FENCE_RE.test(line) ||
45476
+ SEPARATOR_RE.test(line) ||
45477
+ TABLE_ROW_RE.test(line) ||
45478
+ UNORDERED_RE.test(line) ||
45479
+ ORDERED_RE.test(line) ||
45480
+ CHECKLIST_RE.test(line) ||
45481
+ line.startsWith('>') ||
45482
+ BOX_DRAWING.test(line));
45483
+ }
45484
+ function processInlineBold(text) {
45485
+ return text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
45486
+ }
45487
+ function makeHeading(level, content) {
45488
+ if (level === 1) {
45489
+ return {
45490
+ type: 'title',
45491
+ props: { content, size: 'xlarge', color: 'dark', bold: true },
45492
+ };
45493
+ }
45494
+ const size = level === 2 ? 'large' : level === 3 ? 'medium' : 'small';
45495
+ return {
45496
+ type: 'subtitle',
45497
+ props: { content, size, color: 'dark', bold: true },
45498
+ };
45499
+ }
45500
+ function makeQuoteOrCallout(block, labels) {
45501
+ const first = block[0];
45502
+ const callout = first.match(CALLOUT_RE);
45503
+ const lines = block.map(l => l.replace(/^>\s?/, ''));
45504
+ if (callout) {
45505
+ const type = callout[1].toUpperCase();
45506
+ const firstLineRest = callout[2] || '';
45507
+ const rest = lines.slice(1).join(' ').trim();
45508
+ const text = processInlineBold([firstLineRest, rest].filter(Boolean).join(' ').trim());
45509
+ return makeNote(type, text, labels);
45510
+ }
45511
+ const text = processInlineBold(lines.join(' ').trim());
45512
+ return {
45513
+ type: 'quote',
45514
+ props: {
45515
+ content: text,
45516
+ size: 'medium',
45517
+ color: 'medium',
45518
+ bold: false,
45519
+ showQuoteMark: true,
45520
+ alignment: 'left',
45521
+ },
45522
+ };
45523
+ }
45524
+ function makeNote(kind, text, labels) {
45525
+ return {
45526
+ type: 'note',
45527
+ props: {
45528
+ text,
45529
+ prefix: `${labels[kind]}:`,
45530
+ color: CALLOUT_COLORS[kind],
45531
+ textColor: 'dark',
45532
+ size: 'medium',
45533
+ rounded: true,
45534
+ allowPartialBold: true,
45535
+ processLinks: true,
45536
+ },
45537
+ };
45538
+ }
45539
+ function makeTable(rows) {
45540
+ const parsed = rows
45541
+ .filter(r => !TABLE_DIVIDER_RE.test(r))
45542
+ .map(r => r
45543
+ .trim()
45544
+ .replace(/^\|/, '')
45545
+ .replace(/\|$/, '')
45546
+ .split('|')
45547
+ .map(c => c.trim()));
45548
+ if (parsed.length === 0)
45549
+ return [];
45550
+ const header = parsed[0];
45551
+ const dataRows = parsed.slice(1);
45552
+ if (dataRows.length === 0) {
45553
+ return [
45554
+ {
45555
+ type: 'paragraph',
45556
+ props: {
45557
+ content: header.join(' · '),
45558
+ size: 'medium',
45559
+ color: 'dark',
45560
+ bold: false,
45561
+ processLinks: true,
45562
+ allowPartialBold: true,
45563
+ },
45564
+ },
45565
+ ];
45566
+ }
45567
+ return dataRows.map(row => {
45568
+ const pairs = row.map((cell, idx) => {
45569
+ const key = header[idx] ?? '';
45570
+ return key ? `<strong>${key}:</strong> ${cell}` : cell;
45571
+ });
45572
+ return {
45573
+ type: 'paragraph',
45574
+ props: {
45575
+ content: pairs.join(' · '),
45576
+ size: 'medium',
45577
+ color: 'dark',
45578
+ bold: false,
45579
+ processLinks: true,
45580
+ allowPartialBold: true,
45581
+ },
45582
+ };
45583
+ });
45584
+ }
45585
+
45586
+ /**
45587
+ * `val-faq`
45588
+ *
45589
+ * Organism de preguntas frecuentes. Q&A categorizado con accordion colapsable
45590
+ * y buscador en vivo. Pensado para vistas FAQ públicas — el caller puede
45591
+ * reusar la misma data (`FaqMetadata.categories`) para emitir el structured
45592
+ * data `FAQPage` (JSON-LD) y ganar rich results en buscadores.
45593
+ *
45594
+ * Las respuestas (`FaqItem.answer`) son **Markdown** — se parsean con
45595
+ * `parseMarkdownArticle` y se renderizan vía `val-article`. Soportan links,
45596
+ * listas, negritas, tablas, etc.
45597
+ *
45598
+ * @example
45599
+ * ```html
45600
+ * <val-faq [props]="{ categories: faqCategories, searchable: true }" />
45601
+ * ```
45602
+ */
45603
+ class FaqComponent {
45604
+ constructor() {
45605
+ this.i18n = inject(I18nService);
45606
+ this.props_ = signal({ categories: [] });
45607
+ /** Término de búsqueda actual. */
45608
+ this.query = signal('');
45609
+ /**
45610
+ * Cache de respuestas parseadas (Markdown → ArticleMetadata). Se reconstruye
45611
+ * solo cuando cambian las categorías — el parseo NO corre por keystroke.
45612
+ * Keyed por id de item (los ids son únicos en toda la FAQ).
45613
+ */
45614
+ this.parsedAnswers = computed(() => {
45615
+ const locale = (this.i18n.lang() ?? 'es');
45616
+ const map = new Map();
45617
+ for (const cat of this.props_().categories ?? []) {
45618
+ for (const item of cat.items) {
45619
+ map.set(item.id, parseMarkdownArticle(item.answer, { locale }));
45620
+ }
45621
+ }
45622
+ return map;
45623
+ });
45624
+ /**
45625
+ * Categorías visibles tras el filtro. Una categoría desaparece si ninguna
45626
+ * de sus preguntas matchea. Match sobre pregunta + respuesta (raw markdown),
45627
+ * case-insensitive.
45628
+ */
45629
+ this.visibleCategories = computed(() => {
45630
+ const q = this.query().trim().toLowerCase();
45631
+ const cats = this.props_().categories ?? [];
45632
+ if (!q)
45633
+ return cats;
45634
+ return cats
45635
+ .map(cat => ({
45636
+ ...cat,
45637
+ items: cat.items.filter(it => it.question.toLowerCase().includes(q) || it.answer.toLowerCase().includes(q)),
45638
+ }))
45639
+ .filter(cat => cat.items.length > 0);
45640
+ });
45641
+ /** `true` si se debe mostrar el label de cada categoría. */
45642
+ this.showCategoryLabel = computed(() => {
45643
+ const p = this.props_();
45644
+ if (p.hideSingleCategoryLabel === false)
45645
+ return true;
45646
+ return (p.categories?.length ?? 0) > 1;
45647
+ });
45648
+ }
45649
+ set props(value) {
45650
+ if (value)
45651
+ this.props_.set(value);
45652
+ }
45653
+ get props() {
45654
+ return this.props_();
45655
+ }
45656
+ /** Respuesta parseada de un item — desde el cache. */
45657
+ answerOf(item) {
45658
+ return this.parsedAnswers().get(item.id) ?? { elements: [] };
45659
+ }
45660
+ onSearch(term) {
45661
+ this.query.set(term ?? '');
43741
45662
  }
43742
45663
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FaqComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
43743
45664
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FaqComponent, isStandalone: true, selector: "val-faq", inputs: { props: "props" }, ngImport: i0, template: `
@@ -44957,489 +46878,144 @@ class AuthBackgroundComponent {
44957
46878
  [attr.stroke-width]="wave.thickness"
44958
46879
  [class.travel-left]="wave.travelDirection === 'left'"
44959
46880
  [class.travel-right]="wave.travelDirection === 'right'"
44960
- [style.opacity]="wave.opacity"
44961
- [style.--pulse-duration]="wave.pulseDuration + 's'"
44962
- [style.--pulse-delay]="wave.pulseDelay + 's'"
44963
- [style.--travel-duration]="wave.travelDuration + 's'"
44964
- [style.--travel-delay]="wave.travelDelay + 's'"
44965
- />
44966
- }
44967
- </svg>
44968
- </div>
44969
-
44970
- <div class="content-layer">
44971
- <ng-content></ng-content>
44972
- </div>
44973
- </section>
44974
- `, isInline: true, styles: [":host{display:block;--auth-bg-start: #ffffff;--auth-bg-mid: #f8f9fa;--auth-bg-end: #f0f0f0;--auth-glass-bg: rgba(255, 255, 255, .3);--auth-grain-opacity: .035;--auth-wave-color: var(--ion-color-primary, #6366f1)}:host-context(body.dark),:host-context(.dark),:host-context(.ion-palette-dark),:host-context(html.ion-palette-dark),:host-context([data-theme=\"dark\"]){--auth-bg-start: #0f0f1a;--auth-bg-mid: #1a1a2e;--auth-bg-end: #16213e;--auth-glass-bg: rgba(0, 0, 0, .3);--auth-grain-opacity: .05;--auth-wave-color: var(--ion-color-primary, #818cf8)}.auth-background{position:relative;display:flex;align-items:center;padding:0;background:linear-gradient(135deg,var(--auth-bg-start) 0%,var(--auth-bg-mid) 50%,var(--auth-bg-end) 100%);overflow:hidden}.grain{position:absolute;inset:0;pointer-events:none;z-index:0;opacity:var(--auth-grain-opacity);background-image:url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.5' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\")}.glass-layer{position:absolute;inset:0;pointer-events:none;z-index:1;background:var(--auth-glass-bg);backdrop-filter:blur(3px) saturate(120%);-webkit-backdrop-filter:blur(3px) saturate(120%)}.wave-bg{position:absolute;inset:0;pointer-events:none;z-index:0;transition:filter .3s ease}:host-context(body.dark) .wave-bg,:host-context(.dark) .wave-bg,:host-context(.ion-palette-dark) .wave-bg,:host-context(html.ion-palette-dark) .wave-bg,:host-context([data-theme=\"dark\"]) .wave-bg{filter:invert(1) opacity(.6)}.waves{width:100%;height:100%;overflow:visible}.waves path{fill:none;transform-box:fill-box;transform-origin:center;will-change:transform;animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-right var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}.waves path.travel-left{animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-left var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}@keyframes wave-pulse{0%{transform:scaleY(.85)}50%{transform:scaleY(1.25)}to{transform:scaleY(.85)}}@keyframes wave-travel-right{0%{transform:translate(-300px)}to{transform:translate(300px)}}@keyframes wave-travel-left{0%{transform:translate(300px)}to{transform:translate(-300px)}}@media (prefers-reduced-motion: reduce){.waves path{animation:none}}.content-layer{position:relative;z-index:2;width:100%;height:100%;display:flex;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] }); }
44975
- }
44976
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthBackgroundComponent, decorators: [{
44977
- type: Component,
44978
- args: [{ selector: 'val-auth-background', standalone: true, imports: [CommonModule], template: `
44979
- <section class="auth-background" [style.min-height]="config().minHeight">
44980
- @if (config().showGrain) {
44981
- <div class="grain"></div>
44982
- }
44983
- @if (config().showGlass) {
44984
- <div class="glass-layer"></div>
44985
- }
44986
- <div class="wave-bg">
44987
- <svg class="waves" viewBox="0 0 1440 800" preserveAspectRatio="none">
44988
- @for (wave of waves(); track wave.id) {
44989
- <path
44990
- [attr.d]="wave.path"
44991
- [attr.stroke]="wave.color"
44992
- [attr.stroke-width]="wave.thickness"
44993
- [class.travel-left]="wave.travelDirection === 'left'"
44994
- [class.travel-right]="wave.travelDirection === 'right'"
44995
- [style.opacity]="wave.opacity"
44996
- [style.--pulse-duration]="wave.pulseDuration + 's'"
44997
- [style.--pulse-delay]="wave.pulseDelay + 's'"
44998
- [style.--travel-duration]="wave.travelDuration + 's'"
44999
- [style.--travel-delay]="wave.travelDelay + 's'"
45000
- />
45001
- }
45002
- </svg>
45003
- </div>
45004
-
45005
- <div class="content-layer">
45006
- <ng-content></ng-content>
45007
- </div>
45008
- </section>
45009
- `, styles: [":host{display:block;--auth-bg-start: #ffffff;--auth-bg-mid: #f8f9fa;--auth-bg-end: #f0f0f0;--auth-glass-bg: rgba(255, 255, 255, .3);--auth-grain-opacity: .035;--auth-wave-color: var(--ion-color-primary, #6366f1)}:host-context(body.dark),:host-context(.dark),:host-context(.ion-palette-dark),:host-context(html.ion-palette-dark),:host-context([data-theme=\"dark\"]){--auth-bg-start: #0f0f1a;--auth-bg-mid: #1a1a2e;--auth-bg-end: #16213e;--auth-glass-bg: rgba(0, 0, 0, .3);--auth-grain-opacity: .05;--auth-wave-color: var(--ion-color-primary, #818cf8)}.auth-background{position:relative;display:flex;align-items:center;padding:0;background:linear-gradient(135deg,var(--auth-bg-start) 0%,var(--auth-bg-mid) 50%,var(--auth-bg-end) 100%);overflow:hidden}.grain{position:absolute;inset:0;pointer-events:none;z-index:0;opacity:var(--auth-grain-opacity);background-image:url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.5' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\")}.glass-layer{position:absolute;inset:0;pointer-events:none;z-index:1;background:var(--auth-glass-bg);backdrop-filter:blur(3px) saturate(120%);-webkit-backdrop-filter:blur(3px) saturate(120%)}.wave-bg{position:absolute;inset:0;pointer-events:none;z-index:0;transition:filter .3s ease}:host-context(body.dark) .wave-bg,:host-context(.dark) .wave-bg,:host-context(.ion-palette-dark) .wave-bg,:host-context(html.ion-palette-dark) .wave-bg,:host-context([data-theme=\"dark\"]) .wave-bg{filter:invert(1) opacity(.6)}.waves{width:100%;height:100%;overflow:visible}.waves path{fill:none;transform-box:fill-box;transform-origin:center;will-change:transform;animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-right var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}.waves path.travel-left{animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-left var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}@keyframes wave-pulse{0%{transform:scaleY(.85)}50%{transform:scaleY(1.25)}to{transform:scaleY(.85)}}@keyframes wave-travel-right{0%{transform:translate(-300px)}to{transform:translate(300px)}}@keyframes wave-travel-left{0%{transform:translate(300px)}to{transform:translate(-300px)}}@media (prefers-reduced-motion: reduce){.waves path{animation:none}}.content-layer{position:relative;z-index:2;width:100%;height:100%;display:flex;align-items:center}\n"] }]
45010
- }], ctorParameters: () => [], propDecorators: { props: [{
45011
- type: Input
45012
- }] } });
45013
-
45014
- /**
45015
- * Clave para persistir el idioma en localStorage
45016
- */
45017
- const LANG_STORAGE_KEY = 'app_lang';
45018
- /**
45019
- * Idioma por defecto
45020
- */
45021
- const DEFAULT_LANG = 'es';
45022
- /**
45023
- * Servicio mínimo de localización.
45024
- *
45025
- * - Lee el idioma desde localStorage al iniciar
45026
- * - Proporciona método para cambiar idioma (recarga la página)
45027
- * - Sin observables ni subscripciones
45028
- *
45029
- * @example
45030
- * // En un componente
45031
- * locale = inject(LocaleService);
45032
- *
45033
- * // Obtener idioma actual
45034
- * console.log(this.locale.lang); // 'es' o 'en'
45035
- *
45036
- * // Cambiar idioma (recarga la página)
45037
- * this.locale.setLang('en');
45038
- */
45039
- class LocaleService {
45040
- constructor(platformId) {
45041
- this.isBrowser = isPlatformBrowser(platformId);
45042
- this.lang = this.getStoredLang();
45043
- }
45044
- /**
45045
- * Cambia el idioma de la aplicación.
45046
- * Guarda en localStorage y recarga la página para aplicar el cambio.
45047
- *
45048
- * @param lang Nuevo idioma ('es' o 'en')
45049
- */
45050
- setLang(lang) {
45051
- if (lang === this.lang || !this.isBrowser) {
45052
- return;
45053
- }
45054
- localStorage.setItem(LANG_STORAGE_KEY, lang);
45055
- window.location.reload();
45056
- }
45057
- /**
45058
- * Obtiene el idioma almacenado en localStorage.
45059
- * Si no existe o no es válido, retorna el idioma por defecto.
45060
- * En SSR (sin localStorage) retorna el default.
45061
- */
45062
- getStoredLang() {
45063
- if (!this.isBrowser) {
45064
- return DEFAULT_LANG;
45065
- }
45066
- const stored = localStorage.getItem(LANG_STORAGE_KEY);
45067
- if (stored === 'es' || stored === 'en') {
45068
- return stored;
45069
- }
45070
- return DEFAULT_LANG;
45071
- }
45072
- /**
45073
- * Verifica si el idioma actual es español
45074
- */
45075
- get isSpanish() {
45076
- return this.lang === 'es';
45077
- }
45078
- /**
45079
- * Verifica si el idioma actual es inglés
45080
- */
45081
- get isEnglish() {
45082
- return this.lang === 'en';
45083
- }
45084
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LocaleService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable }); }
45085
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LocaleService, providedIn: 'root' }); }
45086
- }
45087
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LocaleService, decorators: [{
45088
- type: Injectable,
45089
- args: [{ providedIn: 'root' }]
45090
- }], ctorParameters: () => [{ type: Object, decorators: [{
45091
- type: Inject,
45092
- args: [PLATFORM_ID]
45093
- }] }] });
45094
-
45095
- // Types for valtech-components services
45096
- // ValtechConfig and LangProvider have been removed in v3.0.0
45097
- // Use LocaleService for language management instead
45098
-
45099
- /**
45100
- * Default modal sizes.
45101
- */
45102
- const MODAL_SIZES = {
45103
- small: { width: '320px', height: '220px' },
45104
- medium: { width: '500px', height: '400px' },
45105
- large: { width: '800px', height: '80vh' },
45106
- full: { width: '100%', height: '100%' },
45107
- auto: { width: 'auto', height: '50vh' },
45108
- };
45109
- /**
45110
- * Default modal buttons.
45111
- */
45112
- const DEFAULT_MODAL_CONFIRM_BUTTON = {
45113
- text: 'Confirm',
45114
- role: 'confirm',
45115
- color: 'primary',
45116
- fill: 'solid',
45117
- };
45118
- const DEFAULT_MODAL_CANCEL_BUTTON = {
45119
- text: 'Cancel',
45120
- role: 'cancel',
45121
- color: 'medium',
45122
- fill: 'clear',
45123
- };
45124
-
45125
- /**
45126
- * Internal component for simple content modals.
45127
- * Used by ModalService.openSimple()
45128
- *
45129
- * Header + título + close button delegados a `val-modal-shell` (estándar
45130
- * canónico — ver Regla #5 en `frontend/CLAUDE.md`). El cuerpo (innerHTML) +
45131
- * footer con botones quedan locales.
45132
- */
45133
- class SimpleModalContentComponent {
45134
- constructor() {
45135
- this.title = '';
45136
- this.content = '';
45137
- this.buttons = [];
45138
- this.showCloseButton = true;
45139
- }
45140
- async onButtonClick(button) {
45141
- if (button.handler) {
45142
- const result = await button.handler();
45143
- // If handler returns false, don't dismiss
45144
- if (result === false) {
45145
- return;
45146
- }
45147
- }
45148
- this._modalRef?.dismiss(undefined, button.role || 'confirm');
45149
- }
45150
- onDismiss(role) {
45151
- this._modalRef?.dismiss(undefined, role);
45152
- }
45153
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleModalContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
45154
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SimpleModalContentComponent, isStandalone: true, selector: "val-simple-modal-content", inputs: { title: "title", content: "content", buttons: "buttons", showCloseButton: "showCloseButton", _modalRef: "_modalRef" }, ngImport: i0, template: `
45155
- <val-modal-shell [title]="title" [showClose]="showCloseButton" (close)="onDismiss('close')">
45156
- <div [innerHTML]="content"></div>
45157
- </val-modal-shell>
45158
-
45159
- @if (buttons && buttons.length > 0) {
45160
- <ion-footer>
45161
- <ion-toolbar>
45162
- <div class="button-container">
45163
- @for (button of buttons; track button.text) {
45164
- <ion-button
45165
- [color]="button.color || 'medium'"
45166
- [fill]="button.fill || 'solid'"
45167
- [size]="button.size || 'default'"
45168
- [disabled]="button.disabled || button.loading"
45169
- [class]="button.cssClass"
45170
- (click)="onButtonClick(button)"
45171
- >
45172
- @if (button.loading) {
45173
- <ion-spinner name="circular"></ion-spinner>
45174
- } @else {
45175
- {{ button.text }}
45176
- }
45177
- </ion-button>
45178
- }
45179
- </div>
45180
- </ion-toolbar>
45181
- </ion-footer>
45182
- }
45183
- `, isInline: true, styles: [":host{display:flex;flex-direction:column;height:100%;min-height:0}ion-footer{flex-shrink:0}.button-container{display:flex;justify-content:flex-end;gap:8px;padding:8px 0}ion-footer ion-toolbar{--padding-start: 16px;--padding-end: 16px}:host ::ng-deep ul,:host ::ng-deep ol{padding-left:20px;margin:8px 0}:host ::ng-deep li{margin:4px 0}:host ::ng-deep p{margin:8px 0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { 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: IonFooter, selector: "ion-footer", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: ModalShellComponent, selector: "val-modal-shell", inputs: ["title", "subtitle", "closeLabel", "showClose"], outputs: ["close"] }] }); }
46881
+ [style.opacity]="wave.opacity"
46882
+ [style.--pulse-duration]="wave.pulseDuration + 's'"
46883
+ [style.--pulse-delay]="wave.pulseDelay + 's'"
46884
+ [style.--travel-duration]="wave.travelDuration + 's'"
46885
+ [style.--travel-delay]="wave.travelDelay + 's'"
46886
+ />
46887
+ }
46888
+ </svg>
46889
+ </div>
46890
+
46891
+ <div class="content-layer">
46892
+ <ng-content></ng-content>
46893
+ </div>
46894
+ </section>
46895
+ `, isInline: true, styles: [":host{display:block;--auth-bg-start: #ffffff;--auth-bg-mid: #f8f9fa;--auth-bg-end: #f0f0f0;--auth-glass-bg: rgba(255, 255, 255, .3);--auth-grain-opacity: .035;--auth-wave-color: var(--ion-color-primary, #6366f1)}:host-context(body.dark),:host-context(.dark),:host-context(.ion-palette-dark),:host-context(html.ion-palette-dark),:host-context([data-theme=\"dark\"]){--auth-bg-start: #0f0f1a;--auth-bg-mid: #1a1a2e;--auth-bg-end: #16213e;--auth-glass-bg: rgba(0, 0, 0, .3);--auth-grain-opacity: .05;--auth-wave-color: var(--ion-color-primary, #818cf8)}.auth-background{position:relative;display:flex;align-items:center;padding:0;background:linear-gradient(135deg,var(--auth-bg-start) 0%,var(--auth-bg-mid) 50%,var(--auth-bg-end) 100%);overflow:hidden}.grain{position:absolute;inset:0;pointer-events:none;z-index:0;opacity:var(--auth-grain-opacity);background-image:url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.5' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\")}.glass-layer{position:absolute;inset:0;pointer-events:none;z-index:1;background:var(--auth-glass-bg);backdrop-filter:blur(3px) saturate(120%);-webkit-backdrop-filter:blur(3px) saturate(120%)}.wave-bg{position:absolute;inset:0;pointer-events:none;z-index:0;transition:filter .3s ease}:host-context(body.dark) .wave-bg,:host-context(.dark) .wave-bg,:host-context(.ion-palette-dark) .wave-bg,:host-context(html.ion-palette-dark) .wave-bg,:host-context([data-theme=\"dark\"]) .wave-bg{filter:invert(1) opacity(.6)}.waves{width:100%;height:100%;overflow:visible}.waves path{fill:none;transform-box:fill-box;transform-origin:center;will-change:transform;animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-right var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}.waves path.travel-left{animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-left var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}@keyframes wave-pulse{0%{transform:scaleY(.85)}50%{transform:scaleY(1.25)}to{transform:scaleY(.85)}}@keyframes wave-travel-right{0%{transform:translate(-300px)}to{transform:translate(300px)}}@keyframes wave-travel-left{0%{transform:translate(300px)}to{transform:translate(-300px)}}@media (prefers-reduced-motion: reduce){.waves path{animation:none}}.content-layer{position:relative;z-index:2;width:100%;height:100%;display:flex;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] }); }
45184
46896
  }
45185
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SimpleModalContentComponent, decorators: [{
46897
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthBackgroundComponent, decorators: [{
45186
46898
  type: Component,
45187
- args: [{ selector: 'val-simple-modal-content', standalone: true, imports: [CommonModule, IonToolbar, IonButton, IonFooter, IonSpinner, ModalShellComponent], template: `
45188
- <val-modal-shell [title]="title" [showClose]="showCloseButton" (close)="onDismiss('close')">
45189
- <div [innerHTML]="content"></div>
45190
- </val-modal-shell>
46899
+ args: [{ selector: 'val-auth-background', standalone: true, imports: [CommonModule], template: `
46900
+ <section class="auth-background" [style.min-height]="config().minHeight">
46901
+ @if (config().showGrain) {
46902
+ <div class="grain"></div>
46903
+ }
46904
+ @if (config().showGlass) {
46905
+ <div class="glass-layer"></div>
46906
+ }
46907
+ <div class="wave-bg">
46908
+ <svg class="waves" viewBox="0 0 1440 800" preserveAspectRatio="none">
46909
+ @for (wave of waves(); track wave.id) {
46910
+ <path
46911
+ [attr.d]="wave.path"
46912
+ [attr.stroke]="wave.color"
46913
+ [attr.stroke-width]="wave.thickness"
46914
+ [class.travel-left]="wave.travelDirection === 'left'"
46915
+ [class.travel-right]="wave.travelDirection === 'right'"
46916
+ [style.opacity]="wave.opacity"
46917
+ [style.--pulse-duration]="wave.pulseDuration + 's'"
46918
+ [style.--pulse-delay]="wave.pulseDelay + 's'"
46919
+ [style.--travel-duration]="wave.travelDuration + 's'"
46920
+ [style.--travel-delay]="wave.travelDelay + 's'"
46921
+ />
46922
+ }
46923
+ </svg>
46924
+ </div>
45191
46925
 
45192
- @if (buttons && buttons.length > 0) {
45193
- <ion-footer>
45194
- <ion-toolbar>
45195
- <div class="button-container">
45196
- @for (button of buttons; track button.text) {
45197
- <ion-button
45198
- [color]="button.color || 'medium'"
45199
- [fill]="button.fill || 'solid'"
45200
- [size]="button.size || 'default'"
45201
- [disabled]="button.disabled || button.loading"
45202
- [class]="button.cssClass"
45203
- (click)="onButtonClick(button)"
45204
- >
45205
- @if (button.loading) {
45206
- <ion-spinner name="circular"></ion-spinner>
45207
- } @else {
45208
- {{ button.text }}
45209
- }
45210
- </ion-button>
45211
- }
45212
- </div>
45213
- </ion-toolbar>
45214
- </ion-footer>
45215
- }
45216
- `, styles: [":host{display:flex;flex-direction:column;height:100%;min-height:0}ion-footer{flex-shrink:0}.button-container{display:flex;justify-content:flex-end;gap:8px;padding:8px 0}ion-footer ion-toolbar{--padding-start: 16px;--padding-end: 16px}:host ::ng-deep ul,:host ::ng-deep ol{padding-left:20px;margin:8px 0}:host ::ng-deep li{margin:4px 0}:host ::ng-deep p{margin:8px 0}\n"] }]
45217
- }], propDecorators: { title: [{
45218
- type: Input
45219
- }], content: [{
45220
- type: Input
45221
- }], buttons: [{
45222
- type: Input
45223
- }], showCloseButton: [{
45224
- type: Input
45225
- }], _modalRef: [{
46926
+ <div class="content-layer">
46927
+ <ng-content></ng-content>
46928
+ </div>
46929
+ </section>
46930
+ `, styles: [":host{display:block;--auth-bg-start: #ffffff;--auth-bg-mid: #f8f9fa;--auth-bg-end: #f0f0f0;--auth-glass-bg: rgba(255, 255, 255, .3);--auth-grain-opacity: .035;--auth-wave-color: var(--ion-color-primary, #6366f1)}:host-context(body.dark),:host-context(.dark),:host-context(.ion-palette-dark),:host-context(html.ion-palette-dark),:host-context([data-theme=\"dark\"]){--auth-bg-start: #0f0f1a;--auth-bg-mid: #1a1a2e;--auth-bg-end: #16213e;--auth-glass-bg: rgba(0, 0, 0, .3);--auth-grain-opacity: .05;--auth-wave-color: var(--ion-color-primary, #818cf8)}.auth-background{position:relative;display:flex;align-items:center;padding:0;background:linear-gradient(135deg,var(--auth-bg-start) 0%,var(--auth-bg-mid) 50%,var(--auth-bg-end) 100%);overflow:hidden}.grain{position:absolute;inset:0;pointer-events:none;z-index:0;opacity:var(--auth-grain-opacity);background-image:url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.5' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\")}.glass-layer{position:absolute;inset:0;pointer-events:none;z-index:1;background:var(--auth-glass-bg);backdrop-filter:blur(3px) saturate(120%);-webkit-backdrop-filter:blur(3px) saturate(120%)}.wave-bg{position:absolute;inset:0;pointer-events:none;z-index:0;transition:filter .3s ease}:host-context(body.dark) .wave-bg,:host-context(.dark) .wave-bg,:host-context(.ion-palette-dark) .wave-bg,:host-context(html.ion-palette-dark) .wave-bg,:host-context([data-theme=\"dark\"]) .wave-bg{filter:invert(1) opacity(.6)}.waves{width:100%;height:100%;overflow:visible}.waves path{fill:none;transform-box:fill-box;transform-origin:center;will-change:transform;animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-right var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}.waves path.travel-left{animation:wave-pulse var(--pulse-duration, 3s) ease-in-out var(--pulse-delay, 0s) infinite alternate,wave-travel-left var(--travel-duration, 12s) linear var(--travel-delay, 0s) infinite}@keyframes wave-pulse{0%{transform:scaleY(.85)}50%{transform:scaleY(1.25)}to{transform:scaleY(.85)}}@keyframes wave-travel-right{0%{transform:translate(-300px)}to{transform:translate(300px)}}@keyframes wave-travel-left{0%{transform:translate(300px)}to{transform:translate(-300px)}}@media (prefers-reduced-motion: reduce){.waves path{animation:none}}.content-layer{position:relative;z-index:2;width:100%;height:100%;display:flex;align-items:center}\n"] }]
46931
+ }], ctorParameters: () => [], propDecorators: { props: [{
45226
46932
  type: Input
45227
46933
  }] } });
45228
46934
 
45229
46935
  /**
45230
- * ModalService
45231
- *
45232
- * A service for creating and managing modals with various configurations.
46936
+ * Clave para persistir el idioma en localStorage
46937
+ */
46938
+ const LANG_STORAGE_KEY = 'app_lang';
46939
+ /**
46940
+ * Idioma por defecto
46941
+ */
46942
+ const DEFAULT_LANG = 'es';
46943
+ /**
46944
+ * Servicio mínimo de localización.
45233
46945
  *
45234
- * @example Basic component modal
45235
- * ```typescript
45236
- * const result = await modalService.open({
45237
- * component: MyFormComponent,
45238
- * componentProps: { userId: 123 },
45239
- * header: { title: 'Edit User', showCloseButton: true },
45240
- * size: 'medium'
45241
- * });
46946
+ * - Lee el idioma desde localStorage al iniciar
46947
+ * - Proporciona método para cambiar idioma (recarga la página)
46948
+ * - Sin observables ni subscripciones
45242
46949
  *
45243
- * if (result.role === 'confirm') {
45244
- * console.log('Saved:', result.data);
45245
- * }
45246
- * ```
46950
+ * @example
46951
+ * // En un componente
46952
+ * locale = inject(LocaleService);
45247
46953
  *
45248
- * @example Simple content modal
45249
- * ```typescript
45250
- * await modalService.openSimple({
45251
- * title: 'Terms of Service',
45252
- * content: '<p>Terms content here...</p>',
45253
- * size: 'large',
45254
- * showCloseButton: true
45255
- * });
45256
- * ```
46954
+ * // Obtener idioma actual
46955
+ * console.log(this.locale.lang); // 'es' o 'en'
45257
46956
  *
45258
- * @example Sheet modal (mobile)
45259
- * ```typescript
45260
- * const result = await modalService.openSheet({
45261
- * component: FilterComponent,
45262
- * breakpoints: {
45263
- * initialBreakpoint: 0.5,
45264
- * breakpoints: [0, 0.5, 1],
45265
- * showHandle: true
45266
- * }
45267
- * });
45268
- * ```
46957
+ * // Cambiar idioma (recarga la página)
46958
+ * this.locale.setLang('en');
45269
46959
  */
45270
- class ModalService {
45271
- constructor(modalController) {
45272
- this.modalController = modalController;
46960
+ class LocaleService {
46961
+ constructor(platformId) {
46962
+ this.isBrowser = isPlatformBrowser(platformId);
46963
+ this.lang = this.getStoredLang();
45273
46964
  }
45274
46965
  /**
45275
- * Opens a modal with a component.
45276
- * @param options - Modal configuration
45277
- * @returns Promise resolving to the modal result
46966
+ * Cambia el idioma de la aplicación.
46967
+ * Guarda en localStorage y recarga la página para aplicar el cambio.
46968
+ *
46969
+ * @param lang Nuevo idioma ('es' o 'en')
45278
46970
  */
45279
- async open(options) {
45280
- // A sheet modal (breakpoints present) gets its height from the breakpoints
45281
- // and must span the full viewport width. Applying a size preset's
45282
- // `--width`/`--height` here collapses the sheet (e.g. `auto` → shrink to
45283
- // content) or fights the breakpoint height. So when it's a sheet we skip
45284
- // the size preset entirely.
45285
- const isSheet = !!options.breakpoints?.breakpoints?.length;
45286
- const size = isSheet ? undefined : MODAL_SIZES[options.size || 'medium'];
45287
- const cssClasses = this.buildCssClasses(options);
45288
- const modal = await this.modalController.create({
45289
- component: options.component,
45290
- componentProps: {
45291
- ...options.componentProps,
45292
- // Pass modal reference for dismiss from within component
45293
- _modalRef: {
45294
- dismiss: (data, role) => modal.dismiss(data, role),
45295
- },
45296
- },
45297
- id: options.id,
45298
- showBackdrop: options.showBackdrop ?? true,
45299
- backdropDismiss: options.backdropDismiss ?? true,
45300
- keyboardClose: options.keyboardClose ?? true,
45301
- cssClass: cssClasses,
45302
- mode: options.mode,
45303
- animated: options.animated ?? true,
45304
- canDismiss: this.normalizeCanDismiss(options.canDismiss ?? true),
45305
- presentingElement: options.presentingElement,
45306
- // Sheet modal options
45307
- initialBreakpoint: options.breakpoints?.initialBreakpoint,
45308
- breakpoints: options.breakpoints?.breakpoints,
45309
- handle: options.breakpoints?.showHandle,
45310
- handleBehavior: options.breakpoints?.handleBehavior,
45311
- });
45312
- // Apply custom size styles. Skipped for sheet modals — their dimensions
45313
- // come from the breakpoints (Ionic spans the sheet full-width and uses the
45314
- // breakpoint values for height).
45315
- if (!isSheet && (options.width || options.height || size)) {
45316
- const modalEl = modal;
45317
- modalEl.style.setProperty('--width', options.width || size?.width || 'auto');
45318
- modalEl.style.setProperty('--height', options.height || size?.height || '50vh');
46971
+ setLang(lang) {
46972
+ if (lang === this.lang || !this.isBrowser) {
46973
+ return;
45319
46974
  }
45320
- await modal.present();
45321
- const { data, role } = await modal.onDidDismiss();
45322
- return { data, role };
45323
- }
45324
- /**
45325
- * Opens a simple content modal without a custom component.
45326
- * Uses Ionic's alert-like styling but with modal capabilities.
45327
- * @param options - Simple modal configuration
45328
- * @returns Promise resolving to the modal result
45329
- */
45330
- async openSimple(options) {
45331
- return this.open({
45332
- component: SimpleModalContentComponent,
45333
- componentProps: {
45334
- title: options.title,
45335
- content: options.content,
45336
- buttons: options.buttons || [DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON],
45337
- showCloseButton: options.showCloseButton ?? true,
45338
- },
45339
- size: options.size || 'medium',
45340
- backdropDismiss: options.backdropDismiss ?? true,
45341
- cssClass: ['simple-modal', options.cssClass].filter(Boolean).join(' '),
45342
- });
45343
- }
45344
- /**
45345
- * Opens a confirmation modal.
45346
- * @param title - Modal title
45347
- * @param message - Modal message
45348
- * @param confirmButton - Confirm button config (optional)
45349
- * @param cancelButton - Cancel button config (optional)
45350
- * @returns Promise resolving to true if confirmed
45351
- */
45352
- async confirm(title, message, confirmButton, cancelButton) {
45353
- const result = await this.openSimple({
45354
- title,
45355
- content: message,
45356
- size: 'small',
45357
- showCloseButton: false,
45358
- backdropDismiss: false,
45359
- buttons: [
45360
- { ...DEFAULT_MODAL_CANCEL_BUTTON, ...cancelButton },
45361
- { ...DEFAULT_MODAL_CONFIRM_BUTTON, ...confirmButton },
45362
- ],
45363
- });
45364
- return result.role === 'confirm';
45365
- }
45366
- /**
45367
- * Opens a sheet modal (iOS-style bottom sheet).
45368
- * @param options - Modal configuration with sheet-specific options
45369
- * @returns Promise resolving to the modal result
45370
- */
45371
- async openSheet(options) {
45372
- return this.open({
45373
- ...options,
45374
- breakpoints: {
45375
- initialBreakpoint: options.initialBreakpoint ?? 0.5,
45376
- breakpoints: options.breakpoints ?? [0, 0.5, 1],
45377
- showHandle: options.showHandle ?? true,
45378
- },
45379
- cssClass: ['sheet-modal', options.cssClass].filter(Boolean).join(' '),
45380
- });
46975
+ localStorage.setItem(LANG_STORAGE_KEY, lang);
46976
+ window.location.reload();
45381
46977
  }
45382
46978
  /**
45383
- * Opens a fullscreen modal.
45384
- * @param options - Modal configuration
45385
- * @returns Promise resolving to the modal result
46979
+ * Obtiene el idioma almacenado en localStorage.
46980
+ * Si no existe o no es válido, retorna el idioma por defecto.
46981
+ * En SSR (sin localStorage) retorna el default.
45386
46982
  */
45387
- async openFullscreen(options) {
45388
- return this.open({
45389
- ...options,
45390
- size: 'full',
45391
- cssClass: ['fullscreen-modal', options.cssClass].filter(Boolean).join(' '),
45392
- });
46983
+ getStoredLang() {
46984
+ if (!this.isBrowser) {
46985
+ return DEFAULT_LANG;
46986
+ }
46987
+ const stored = localStorage.getItem(LANG_STORAGE_KEY);
46988
+ if (stored === 'es' || stored === 'en') {
46989
+ return stored;
46990
+ }
46991
+ return DEFAULT_LANG;
45393
46992
  }
45394
46993
  /**
45395
- * Dismisses the top-most modal.
45396
- * @param data - Optional data to return
45397
- * @param role - Optional role
46994
+ * Verifica si el idioma actual es español
45398
46995
  */
45399
- async dismiss(data, role) {
45400
- return this.modalController.dismiss(data, role);
46996
+ get isSpanish() {
46997
+ return this.lang === 'es';
45401
46998
  }
45402
46999
  /**
45403
- * Gets the top-most modal.
47000
+ * Verifica si el idioma actual es inglés
45404
47001
  */
45405
- async getTop() {
45406
- return this.modalController.getTop();
45407
- }
45408
- normalizeCanDismiss(canDismiss) {
45409
- if (typeof canDismiss === 'boolean') {
45410
- return canDismiss;
45411
- }
45412
- // Wrap the function to ensure it returns a Promise
45413
- return async (data, role) => {
45414
- const result = canDismiss();
45415
- return Promise.resolve(result);
45416
- };
45417
- }
45418
- buildCssClasses(options) {
45419
- const classes = ['val-modal'];
45420
- if (options.size) {
45421
- classes.push(`modal-${options.size}`);
45422
- }
45423
- if (options.header) {
45424
- classes.push('modal-with-header');
45425
- }
45426
- if (options.footer) {
45427
- classes.push('modal-with-footer');
45428
- }
45429
- if (options.cssClass) {
45430
- classes.push(options.cssClass);
45431
- }
45432
- return classes.join(' ');
47002
+ get isEnglish() {
47003
+ return this.lang === 'en';
45433
47004
  }
45434
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ModalService, deps: [{ token: i2$1.ModalController }], target: i0.ɵɵFactoryTarget.Injectable }); }
45435
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ModalService, providedIn: 'root' }); }
47005
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LocaleService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable }); }
47006
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LocaleService, providedIn: 'root' }); }
45436
47007
  }
45437
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ModalService, decorators: [{
47008
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LocaleService, decorators: [{
45438
47009
  type: Injectable,
45439
- args: [{
45440
- providedIn: 'root',
45441
- }]
45442
- }], ctorParameters: () => [{ type: i2$1.ModalController }] });
47010
+ args: [{ providedIn: 'root' }]
47011
+ }], ctorParameters: () => [{ type: Object, decorators: [{
47012
+ type: Inject,
47013
+ args: [PLATFORM_ID]
47014
+ }] }] });
47015
+
47016
+ // Types for valtech-components services
47017
+ // ValtechConfig and LangProvider have been removed in v3.0.0
47018
+ // Use LocaleService for language management instead
45443
47019
 
45444
47020
  class MetaService {
45445
47021
  constructor() {
@@ -45738,177 +47314,6 @@ function beautifyLegalArticle(article) {
45738
47314
  };
45739
47315
  }
45740
47316
 
45741
- /**
45742
- * Standard cross-app site links (env-aware)
45743
- *
45744
- * En el factory MyValtech cada producto satélite (showcase, sigify, …) enlaza a
45745
- * `web` —la landing de marca— para soporte, contacto y contenido legal. `web`
45746
- * es el dueño canónico de esas rutas. Este módulo centraliza:
45747
- *
45748
- * 1. El mapa ambiente → base URL de `web` (`VALTECH_WEB_BASE_URLS`).
45749
- * 2. El catálogo de paths estándar que viven en `web` (`VALTECH_SITE_PATHS`).
45750
- *
45751
- * El ambiente se decide en BUILD TIME (campo `valtechEnv` en el `environment*.ts`
45752
- * de cada app), igual que `apiUrl`. Es el mecanismo más confiable: detectar por
45753
- * hostname en runtime se rompe con dominios custom / `web.app` / variantes de
45754
- * localhost.
45755
- *
45756
- * La resolución real (join baseUrl + path, abrir en tab nueva, `?lang=`) la hace
45757
- * `LegalLinkService`; este módulo solo le entrega el `baseUrl` correcto por env.
45758
- *
45759
- * @see provideValtechSite — wiring de una sola línea para apps satélite.
45760
- */
45761
- /**
45762
- * Base URL de `web` (landing de marca) por ambiente.
45763
- *
45764
- * - `local` → `ng serve` de `web` (puerto 8100).
45765
- * - `dev` → proyecto Firebase `myvaltech-dev`.
45766
- * - `prod` → dominio custom de producción.
45767
- *
45768
- * Sin trailing slash (lo asume `LegalLinkService`).
45769
- */
45770
- const VALTECH_WEB_BASE_URLS = {
45771
- local: 'http://localhost:8100',
45772
- dev: 'https://myvaltech-dev.web.app',
45773
- prod: 'https://myvaltech.com',
45774
- };
45775
- /**
45776
- * Resuelve la base URL de `web` para un ambiente.
45777
- *
45778
- * Fallback a `prod` cuando el env es desconocido o falta: ante la duda, apuntar
45779
- * a producción es lo seguro (nunca enlaza a un host local/dev inexistente).
45780
- */
45781
- function resolveWebBaseUrl(env) {
45782
- if (env && env in VALTECH_WEB_BASE_URLS) {
45783
- return VALTECH_WEB_BASE_URLS[env];
45784
- }
45785
- return VALTECH_WEB_BASE_URLS.prod;
45786
- }
45787
- /**
45788
- * Catálogo de paths estándar que viven en `web`. Las apps resuelven estos
45789
- * contra la base URL del ambiente vía `LegalLinkService.resolve(path)`.
45790
- *
45791
- * Mantener alineado con las rutas reales de `web/src/app/app.routes.ts` y con
45792
- * `VALTECH_COMPANY_LINKS` (footer).
45793
- */
45794
- const VALTECH_SITE_PATHS = {
45795
- home: '/',
45796
- support: '/contact',
45797
- contact: '/contact',
45798
- faq: '/faq',
45799
- about: '/about',
45800
- terms: '/legal/terms',
45801
- privacy: '/legal/privacy',
45802
- cookies: '/legal/cookies',
45803
- legalNotice: '/legal/legal-notice',
45804
- };
45805
-
45806
- const VALTECH_LEGAL_CONFIG = new InjectionToken('VALTECH_LEGAL_CONFIG');
45807
- /**
45808
- * Resolves legal/site paths against a configurable main-site base URL.
45809
- *
45810
- * - **Main site mode**: no `provideValtechLegal` called → `resolve('/legal/terms')` returns `/legal/terms` (relative).
45811
- * - **Satellite mode**: `provideValtechLegal({ baseUrl: 'https://myvaltech.com' })` → `resolve('/legal/terms')` returns `'https://myvaltech.com/legal/terms'` and `isExternal('/legal/terms')` returns `true`.
45812
- *
45813
- * Absolute URLs passed in are returned unchanged (no double-prefix).
45814
- */
45815
- class LegalLinkService {
45816
- constructor() {
45817
- this.config = inject(VALTECH_LEGAL_CONFIG, { optional: true }) ?? {};
45818
- }
45819
- /** Effective base URL (null if running as main site). */
45820
- get baseUrl() {
45821
- return this.config.baseUrl?.replace(/\/$/, '') ?? null;
45822
- }
45823
- /** Whether resolved external links should open in a new tab. */
45824
- get openInNewTab() {
45825
- return this.config.openInNewTab ?? Boolean(this.config.baseUrl);
45826
- }
45827
- /**
45828
- * Returns the URL to use for a given internal path. Absolute URLs pass through.
45829
- *
45830
- * When `locale` is provided AND the resolved URL would be cross-origin, appends
45831
- * `?lang={locale}` so the main site can render the legal doc in the user's
45832
- * active language (instead of its default). Locale is omitted for same-origin
45833
- * resolves since the main site reads locale from its own `LocaleService`.
45834
- *
45835
- * @example
45836
- * resolve('/legal/terms')
45837
- * // main site: '/legal/terms'
45838
- * // satellite: 'https://myvaltech.com/legal/terms'
45839
- *
45840
- * resolve('/legal/terms', { locale: 'pt' })
45841
- * // main site: '/legal/terms' (locale stays implicit)
45842
- * // satellite: 'https://myvaltech.com/legal/terms?lang=pt'
45843
- *
45844
- * resolve('https://x.com/y') // unchanged
45845
- */
45846
- resolve(path, options) {
45847
- if (!path)
45848
- return path;
45849
- if (/^https?:\/\//i.test(path))
45850
- return path;
45851
- const base = this.baseUrl;
45852
- if (!base)
45853
- return path;
45854
- const url = `${base}${path.startsWith('/') ? '' : '/'}${path}`;
45855
- if (options?.locale) {
45856
- const sep = url.includes('?') ? '&' : '?';
45857
- return `${url}${sep}lang=${encodeURIComponent(options.locale)}`;
45858
- }
45859
- return url;
45860
- }
45861
- /** `true` if the path would be resolved to a cross-origin URL. */
45862
- isExternal(path) {
45863
- if (/^https?:\/\//i.test(path))
45864
- return true;
45865
- return this.baseUrl !== null;
45866
- }
45867
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LegalLinkService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
45868
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LegalLinkService, providedIn: 'root' }); }
45869
- }
45870
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: LegalLinkService, decorators: [{
45871
- type: Injectable,
45872
- args: [{ providedIn: 'root' }]
45873
- }] });
45874
- /**
45875
- * Wires `LegalLinkService` for satellite apps. Omit in the main site (the one
45876
- * that hosts the canonical /legal/* routes) so links stay relative.
45877
- *
45878
- * @example
45879
- * // main.ts of showcase / sigify / etc.
45880
- * provideValtechLegal({
45881
- * baseUrl: 'https://myvaltech.com',
45882
- * openInNewTab: true,
45883
- * }),
45884
- */
45885
- function provideValtechLegal(config) {
45886
- return { provide: VALTECH_LEGAL_CONFIG, useValue: config };
45887
- }
45888
- /**
45889
- * Wiring de una sola línea para que una app satélite apunte sus links de sitio
45890
- * (legal, soporte, contacto, …) a `web` —la landing de marca— en el ambiente
45891
- * correcto. Resuelve el `baseUrl` desde `VALTECH_WEB_BASE_URLS` según el `env`
45892
- * baked en build-time (`environment.valtechEnv`) y delega en `provideValtechLegal`.
45893
- *
45894
- * Ambiente desconocido/ausente → fallback a `prod` (ver `resolveWebBaseUrl`).
45895
- * El main site (`web`) NO llama esto — sus links quedan relativos.
45896
- *
45897
- * @example
45898
- * // main.ts de showcase / sigify / …
45899
- * import { environment } from './environments/environment';
45900
- * provideValtechSite(environment.valtechEnv), // 'local' | 'dev' | 'prod'
45901
- *
45902
- * @see resolveWebBaseUrl
45903
- * @see VALTECH_SITE_PATHS — catálogo de paths estándar que viven en `web`.
45904
- */
45905
- function provideValtechSite(env, options) {
45906
- return provideValtechLegal({
45907
- baseUrl: resolveWebBaseUrl(env),
45908
- openInNewTab: options?.openInNewTab ?? true,
45909
- });
45910
- }
45911
-
45912
47317
  class OrgService {
45913
47318
  constructor(config, http) {
45914
47319
  this.config = config;
@@ -54293,5 +55698,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
54293
55698
  * Generated bundle index. Do not edit.
54294
55699
  */
54295
55700
 
54296
- export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, ARTICLE_CARD_DEFAULTS, ARTICLE_SPACING, ARTICLE_STRIP_DEFAULTS, AUTH_CTA_DEFAULTS, AVATAR_UPLOAD_DEFAULTS, AccordionComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AnimatedTerminalComponent, AppConfigService, AppVersionService, ArticleBuilder, ArticleCardComponent, ArticleComponent, ArticleStripComponent, AttachmentUploaderComponent, AuthBackgroundComponent, AuthCtaComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BaseDefault, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, CIRCLE_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CONTENT_CARD_DEFAULTS, CORNER_KEYS, CTA_CARD_DEFAULTS, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangeEmailModalComponent, ChangePasswordModalComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentCardComponent, ContentLoaderComponent, ContentReactionComponent, ContentService, ContentTransformer, CookieBannerComponent, CountdownComponent, CtaCardComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DEFAULT_SPLASH_SCREEN_CONFIG, DataTableComponent, DateInputComponent, DateRangeInputComponent, DebugConsoleComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EmailInputComponent, EmptyStateComponent, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FUN_MODAL_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FontSizeOption, FontSizeService, FooterComponent, FooterLinksComponent, FormComponent, FormSkeletonComponent, FunHeaderComponent, FunModalComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HasPermissionDirective, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, I18nService, IMAGE_DEFAULTS, INFO_CARD_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, INVITATION_CARD_DEFAULTS, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoCardComponent, InfoComponent, InputI18nHelper, InputType, InvitationCardComponent, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGIN_DEFAULTS, LandingSplitComponent, LandingStepsComponent, LanguageSelectorComponent, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadingDirective, LocalStorageService, LocaleService, LoginComponent, MEDIA_OBJECT_DEFAULTS, MEMBER_CARD_DEFAULTS, METRIC_CARD_DEFAULTS, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MediaObjectComponent, MemberCardComponent, MenuComponent, MessagingService, MetaService, MetricCardComponent, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NUM_KEYS, NavigationService, NetworkBannerComponent, NetworkStatusService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationsService, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OrgService, OrgSwitchService, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PATTERN_MOTIFS, PATTERN_PALETTES, PATTERN_STYLE_CONFIGS, PLATFORM_CONFIGS, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWavesComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PreferencesViewComponent, PresetService, PriceTagComponent, PrimarySolidBlockButton, PrimarySolidBlockHrefButton, PrimarySolidBlockIconButton, PrimarySolidBlockIconHrefButton, PrimarySolidDefaultRoundButton, PrimarySolidDefaultRoundHrefButton, PrimarySolidDefaultRoundIconButton, PrimarySolidDefaultRoundIconHrefButton, PrimarySolidFullButton, PrimarySolidFullHrefButton, PrimarySolidFullIconButton, PrimarySolidFullIconHrefButton, PrimarySolidLargeRoundButton, PrimarySolidLargeRoundHrefButton, PrimarySolidLargeRoundIconButton, PrimarySolidLargeRoundIconHrefButton, PrimarySolidSmallRoundButton, PrimarySolidSmallRoundHrefButton, PrimarySolidSmallRoundIconButton, PrimarySolidSmallRoundIconHrefButton, ProcessLinksPipe, ProfileSkeletonComponent, ProfileViewComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QueryBuilder, QuoteBoxComponent, REQUEST_STATUSES, RadioInputComponent, RangeInputComponent, RatingComponent, RefresherComponent, RequestFirestoreService, RequestFormBuilderService, RequestService, RightsFooterComponent, RotatingTextComponent, SEARCH_HEADER_DEFAULTS, SETTINGS_SECTIONS_CATALOG, SHAPE_KEYS, SHARE_PROFILE_MODAL_DEFAULTS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, STATS_BAR_DEFAULTS, STROKE_KEYS, SearchHeaderComponent, SearchSelectorComponent, SearchbarComponent, SecondarySolidBlockButton, SecondarySolidBlockHrefButton, SecondarySolidBlockIconButton, SecondarySolidBlockIconHrefButton, SecondarySolidDefaultRoundButton, SecondarySolidDefaultRoundHrefButton, SecondarySolidDefaultRoundIconButton, SecondarySolidDefaultRoundIconHrefButton, SecondarySolidFullButton, SecondarySolidFullHrefButton, SecondarySolidFullIconButton, SecondarySolidFullIconHrefButton, SecondarySolidLargeRoundButton, SecondarySolidLargeRoundHrefButton, SecondarySolidLargeRoundIconButton, SecondarySolidLargeRoundIconHrefButton, SecondarySolidSmallRoundButton, SecondarySolidSmallRoundHrefButton, SecondarySolidSmallRoundIconButton, SecondarySolidSmallRoundIconHrefButton, SegmentControlComponent, SelectSearchComponent, SessionService, SettingsHubComponent, ShareButtonsComponent, ShareProfileModalComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, SplashScreenService, StatsBarComponent, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TranslatePipe, TypedCollection, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_CONTENT_CONFIG, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_SITE_PATHS, VALTECH_SOCIAL_LINKS, VALTECH_SPLASH_SCREEN, VALTECH_WEB_BASE_URLS, VERSION, ValtechErrorService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, authPasswordValidator, beautifyLegalArticle, blogPost, buildCompanyFooterProps, buildFooterLinks, buildLegalLinkResolver, buildPath, buildSettingsCards, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, guestGuard, hasEmulators, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideSplashScreen, provideValtechAds, provideValtechAppConfig, provideValtechAppVersion, provideValtechAuth, provideValtechAuthInterceptor, provideValtechContent, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechPreferencesRoutes, provideValtechPresets, provideValtechProfileRoutes, provideValtechSettingsRoutes, provideValtechSite, provideValtechSkeleton, query, renderPatternSvgInner, replaceSpecialChars, resolveColor$1 as resolveColor, resolveInputDefaultValue, resolveWebBaseUrl, roleGuard, storagePaths, superAdminGuard, toArticle };
55701
+ export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, ARTICLE_CARD_DEFAULTS, ARTICLE_SPACING, ARTICLE_STRIP_DEFAULTS, AUTH_CTA_DEFAULTS, AVATAR_UPLOAD_DEFAULTS, AccordionComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AnimatedTerminalComponent, AppConfigService, AppVersionService, ArticleBuilder, ArticleCardComponent, ArticleComponent, ArticleStripComponent, AttachmentUploaderComponent, AuthBackgroundComponent, AuthCtaComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BaseDefault, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, CIRCLE_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CONTENT_CARD_DEFAULTS, CORNER_KEYS, CTA_CARD_DEFAULTS, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangeEmailModalComponent, ChangePasswordModalComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentCardComponent, ContentLoaderComponent, ContentReactionComponent, ContentService, ContentTransformer, CookieBannerComponent, CountdownComponent, CtaCardComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DEFAULT_SPLASH_SCREEN_CONFIG, DataTableComponent, DateInputComponent, DateRangeInputComponent, DebugConsoleComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EmailInputComponent, EmptyStateComponent, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FUN_MODAL_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FontSizeOption, FontSizeService, FooterComponent, FooterLinksComponent, FormComponent, FormSkeletonComponent, FunHeaderComponent, FunModalComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HasPermissionDirective, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, I18nService, IMAGE_DEFAULTS, INFO_CARD_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, INVITATION_CARD_DEFAULTS, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoCardComponent, InfoComponent, InputI18nHelper, InputType, InvitationCardComponent, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGIN_DEFAULTS, LandingSplitComponent, LandingStepsComponent, LanguageSelectorComponent, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadingDirective, LocalStorageService, LocaleService, LoginAttemptModalComponent, LoginComponent, MEDIA_OBJECT_DEFAULTS, MEMBER_CARD_DEFAULTS, METRIC_CARD_DEFAULTS, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MediaObjectComponent, MemberCardComponent, MenuComponent, MessagingService, MetaService, MetricCardComponent, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NUM_KEYS, NavigationService, NetworkBannerComponent, NetworkStatusService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationsService, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OrgService, OrgSwitchService, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PATTERN_MOTIFS, PATTERN_PALETTES, PATTERN_STYLE_CONFIGS, PLATFORM_CONFIGS, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWavesComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PreferencesViewComponent, PresetService, PriceTagComponent, PrimarySolidBlockButton, PrimarySolidBlockHrefButton, PrimarySolidBlockIconButton, PrimarySolidBlockIconHrefButton, PrimarySolidDefaultRoundButton, PrimarySolidDefaultRoundHrefButton, PrimarySolidDefaultRoundIconButton, PrimarySolidDefaultRoundIconHrefButton, PrimarySolidFullButton, PrimarySolidFullHrefButton, PrimarySolidFullIconButton, PrimarySolidFullIconHrefButton, PrimarySolidLargeRoundButton, PrimarySolidLargeRoundHrefButton, PrimarySolidLargeRoundIconButton, PrimarySolidLargeRoundIconHrefButton, PrimarySolidSmallRoundButton, PrimarySolidSmallRoundHrefButton, PrimarySolidSmallRoundIconButton, PrimarySolidSmallRoundIconHrefButton, ProcessLinksPipe, ProfileSkeletonComponent, ProfileViewComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QueryBuilder, QuoteBoxComponent, REQUEST_STATUSES, RadioInputComponent, RangeInputComponent, RatingComponent, RefresherComponent, RequestFirestoreService, RequestFormBuilderService, RequestService, RightsFooterComponent, RotatingTextComponent, SEARCH_HEADER_DEFAULTS, SETTINGS_SECTIONS_CATALOG, SHAPE_KEYS, SHARE_PROFILE_MODAL_DEFAULTS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, STATS_BAR_DEFAULTS, STROKE_KEYS, SearchHeaderComponent, SearchSelectorComponent, SearchbarComponent, SecondarySolidBlockButton, SecondarySolidBlockHrefButton, SecondarySolidBlockIconButton, SecondarySolidBlockIconHrefButton, SecondarySolidDefaultRoundButton, SecondarySolidDefaultRoundHrefButton, SecondarySolidDefaultRoundIconButton, SecondarySolidDefaultRoundIconHrefButton, SecondarySolidFullButton, SecondarySolidFullHrefButton, SecondarySolidFullIconButton, SecondarySolidFullIconHrefButton, SecondarySolidLargeRoundButton, SecondarySolidLargeRoundHrefButton, SecondarySolidLargeRoundIconButton, SecondarySolidLargeRoundIconHrefButton, SecondarySolidSmallRoundButton, SecondarySolidSmallRoundHrefButton, SecondarySolidSmallRoundIconButton, SecondarySolidSmallRoundIconHrefButton, SecurityViewComponent, SegmentControlComponent, SelectSearchComponent, SessionListModalComponent, SessionService, SettingsHubComponent, ShareButtonsComponent, ShareProfileModalComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, SplashScreenService, StatsBarComponent, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TranslatePipe, TypedCollection, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_CONTENT_CONFIG, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_SITE_PATHS, VALTECH_SOCIAL_LINKS, VALTECH_SPLASH_SCREEN, VALTECH_WEB_BASE_URLS, VERSION, ValtechErrorService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, authPasswordValidator, beautifyLegalArticle, blogPost, buildCompanyFooterProps, buildFooterLinks, buildLegalLinkResolver, buildPath, buildSettingsCards, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, guestGuard, hasEmulators, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideSplashScreen, provideValtechAds, provideValtechAppConfig, provideValtechAppVersion, provideValtechAuth, provideValtechAuthInterceptor, provideValtechContent, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechPreferencesRoutes, provideValtechPresets, provideValtechProfileRoutes, provideValtechSecurityRoutes, provideValtechSettingsRoutes, provideValtechSite, provideValtechSkeleton, query, renderPatternSvgInner, replaceSpecialChars, resolveColor$1 as resolveColor, resolveInputDefaultValue, resolveWebBaseUrl, roleGuard, storagePaths, superAdminGuard, toArticle };
54297
55702
  //# sourceMappingURL=valtech-components.mjs.map