PageSourceSearch

https://cortex-cashflow.onrender.com/auth/pages-register

html cortex-cashflow.onrender.com collected 2026-10-03 11:39:41 UTC 55,864 bytes, 1,264 lines download raw bytes

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5  
5<script>
6  /* ─── Tema: FOUC prevention — se ejecuta antes del render ───────────────────
7     Prioridad al abrir la app:
8       1. Regla horaria: 19:00-08:59 → dark (siempre, override de manual)
9       2. Preferencia manual del usuario  (cashflow_theme_manual)
10       3. Preferencia del sistema operativo (prefers-color-scheme)
11     ──────────────────────────────────────────────────────────────────────────── */
12  (function() {
13    var h = new Date().getHours();
14    var esNoche = (h >= 19 || h < 9);
15    var theme;
16    if (esNoche) {
17      theme = 'dark';
18    } else {
19      var manual = localStorage.getItem('cashflow_theme_manual')
20                || localStorage.getItem('cashflow_theme'); /* backward compat */
21      theme = manual
22        ? manual
23        : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
24    }
25    document.documentElement.setAttribute('data-theme', theme);
26  })();
27  
28  /* Helper global de colores theme-aware para gráficos (canvas no entiende var()).
29     Los gráficos leen estos valores al construirse y se re-tematizan vía 'themechange'. */
30  window.cfChartColors = function () {
31    var cs = getComputedStyle(document.documentElement);
32    var dark = document.documentElement.getAttribute('data-theme') === 'dark';
33    var get = function (name, fallback) {
34      var v = cs.getPropertyValue(name);
35      return (v && v.trim()) ? v.trim() : fallback;
36    };
37    return {
38      text:    get('--text-secondary', dark ? '#A8BAD0' : '#4B5563'),
39      heading: get('--text-heading',   dark ? '#F5F7FA' : '#0A0A0A'),
40      surface: get('--bg-surface',     dark ? '#18202F' : '#F8F9FA'),
41      border:  get('--border-color',   dark ? 'rgba(255,255,255,0.08)' : '#E5E7EB'),
42      grid:    dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)',
43      tooltipTheme: dark ? 'dark' : 'light'
44    };
45  };
46  
47  /* Re-tematiza una instancia de Chart.js (mutación de paths estándar + update). */
48  window.cfRetintChart = function (ch) {
49    if (!ch || !ch.options) return;
50    var c = window.cfChartColors();
51    var o = ch.options;
52    if (o.plugins && o.plugins.legend && o.plugins.legend.labels) o.plugins.legend.labels.color = c.heading;
53    if (o.scales) {
54      Object.keys(o.scales).forEach(function (k) {
55        var s = o.scales[k];
56        if (!s) return;
57        if (s.ticks) s.ticks.color = c.text;
58        if (s.title && 'color' in s.title) s.title.color = c.text;
59        if (s.grid && s.grid.color) s.grid.color = c.grid;
60      });
61    }
62    ch.update('none');
63  };
64  
65  /* Registro de callbacks de re-tematización; cada gráfico se anota acá. */
66  window.cfChartThemers = window.cfChartThemers || [];
67  window.addEventListener('themechange', function () {
68    (window.cfChartThemers || []).forEach(function (fn) { try { fn(); } catch (e) {} });
69  });
70  </script>
70
71  <meta charset="utf-8">
72  <!-- viewport-fit=cover: habilita env(safe-area-inset-*) en iPhone (PWA/notch)
73       maximum-scale=1 + user-scalable=no: bloquea el zoom (pinch/doble-tap) para
74       que la PWA se sienta como una app nativa. -->
75  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" name="viewport">
76  <!-- ── PWA (instalable + comportamiento app-like) ────────────────────────────── -->
77  <link rel="manifest" href="/manifest.json">
78  <meta name="theme-color" content="#0F1420">
79  <meta name="mobile-web-app-capable" content="yes">
80  <meta name="apple-mobile-web-app-capable" content="yes">
81  <!-- 'default' (no 'black-translucent'): en modo standalone iOS reserva el espacio
82       de la barra de estado, así el header NO queda tapado por el reloj/hora. -->
83  <meta name="apple-mobile-web-app-status-bar-style" content="default">
84  <meta name="apple-mobile-web-app-title" content="CashFlow">
85  <style>
86    /* App-like: sin overscroll/bounce ni tirón de recarga que dispare navegación. */
87    html { overscroll-behavior: none; }
88    body {
89      overscroll-behavior: none;
90      touch-action: manipulation;      /* elimina el zoom por doble-tap */
91      -webkit-tap-highlight-color: transparent;
92    }
93    /* App-like: sin selección de texto (las apps nativas no tienen "seleccionar
94       texto" salvo en campos editables). El clic derecho se bloquea en pwa.js;
95       queda reservado para el menú contextual propio de CashFlow (próximamente). */
96    html, body {
97      -webkit-user-select: none;
98      -moz-user-select: none;
99      -ms-user-select: none;
100      user-select: none;
101    }
102    input, textarea, select,
103    [contenteditable="true"], [contenteditable=""] {
104      -webkit-user-select: text;
105      -moz-user-select: text;
106      -ms-user-select: text;
107      user-select: text;
108    }
109  </style>
110  <!-- Nombre - Autoria - Descripcion -->
111  <title>CashFlow</title>
112  <meta name="author" content="CortexDev | Acosta, Simón Martín." />
113  <meta name="copyright" content="Acosta, Simón Martín. | Cortex Digital" />
114  <meta
115    content="Sistema de gestión financiera integral que permite registrar usuarios, agendar pagos, registrar salarios y gestionar diversas operaciones financieras. Ideal para organizar y controlar tus finanzas personales y empresariales."
116    name="description">
117  <meta
118    content="gestión financiera, registro de usuarios, agendar pagos, registrar salarios, operaciones financieras, categorías, bancos, autenticación de usuarios, finanzas personales, finanzas empresariales"
119    name="keywords">
120  <meta name="robots" content="index, follow">
121  <!-- Open Graph Meta Tags (para redes sociales) -->
122  <meta property="og:title" content="CashFlow by Cortex" />
123  <meta property="og:description" content="Sistema integral para gestionar finanzas personales y empresariales de manera eficiente." />
124  <meta property="og:image" content="/assets/img/favicon.png" />
125  <meta property="og:url" content="https://cortexdigital.com.ar/" />
126  <meta property="og:type" content="website" />
127  <!-- Twitter Card Meta Tags -->
128  <meta name="twitter:card" content="summary_large_image">
129  <meta name="twitter:title" content="CashFlow by Cortex">
130  <meta name="twitter:description" content="Sistema integral para la gestión de finanzas personales y empresariales.">
131  <meta name="twitter:image" content="/assets/img/favicon.png">
132  <!-- Canonical URL -->
133  <link rel="canonical" href="https://cortexdigital.com.ar/" />
134  <!-- Favicons -->
135  <link href="/assets/img/favicon.png" rel="icon">
136  <!-- Ícono de pantalla de inicio iOS: cuadrado 180x180 (el navegador lo recorta redondeado) -->
137  <link href="/assets/img/pwa-apple-touch-180.png" rel="apple-touch-icon" sizes="180x180">
138  <!-- Google Fonts — Inter (loaded via variables.css @import) -->
139  <link rel="preconnect" href="https://fonts.googleapis.com">
140  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
141  <!-- Vendor CSS Files -->
142  <link href="/assets/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
143  <link href="/assets/vendor/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
144  <!-- simple-datatables CSS se carga via CDN abajo -->
145  <!-- Variables CSS (Design Tokens) -->
146  <link href="/assets/css/variables.css" rel="stylesheet">
147  <!-- Template Main CSS File -->
148  <link href="/assets/css/style.css" rel="stylesheet">
149  <!-- Lógica de chat compartida (chat-ia + widget flotante) -->
150  
150<script src="/m/cortexia/js/cortex-chat.js"></script>
150
151  <!-- Render de los gráficos que devuelve CortexIA como datos (Chart.js / ApexCharts).
152       Las librerías se cargan al final del body: este archivo solo las usa al dibujar. -->
153  
153<script src="/m/cortexia/js/cortex-viz.js"></script>
153
154  <!-- Estilos globales del layout: cards, dropdowns, breadcrumbs, ApexCharts -->
155  <link href="/assets/css/pages/layout.css" rel="stylesheet">
156  <!-- Chip de modelo/límites que va pegado a los inputs de IA (window.AiModelo) -->
157  <link href="/assets/css/ai-modelo.css" rel="stylesheet">
158  <!-- CSS específico de cada página se carga via <link> al inicio de cada HBS -->
159  <!-- Toasts (Sileo, port vanilla) — estilos de las notificaciones -->
160  <link href="/assets/vendor/sileo/styles.css" rel="stylesheet">
161  <link rel="stylesheet" href="https://cdn.datatables.net/2.1.4/css/dataTables.dataTables.css" />
162    
163  
163<script src="
164  https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/simple-datatables.min.js
165  "></script>
165
166  <link href="
167  https://cdn.jsdelivr.net/npm/[email protected]/dist/style.min.css
168  " rel="stylesheet">
169  
170  <!-- Google tag (gtag.js) -->
vendor: 66 bytes, lines 170-171
170
171  <script async src="https://www.googletagmanager.com/gtag/js?id=
171G-7NZP6V6WLB
vendor: 14 bytes, lines 171-172
171"></script>
172  
172<script>
173    
vendor: 142 bytes, lines 173-177
173window.dataLayer = window.dataLayer || [];
174    function gtag(){dataLayer.push(arguments);}
175    gtag('js', new Date());
176  
177    gtag('config', '
177G-7NZP6V6WLB
vendor: 6 bytes, lines 177-178
177');
178  
178</script>
178
179</head>
180
181<body>
182  
183  <link rel="stylesheet" href="/assets/css/pages/pages-register.css">
184<main id="pages-register" class="pagesViews">
185  <!-- 🎬 Fondo de video -->
186  <video autoplay muted loop id="background-video">
187    <source src="/assets/videos/video2.mp4" type="video/mp4">
188    Tu navegador no soporta videos HTML5.
189  </video>
190  <div class="overlay"></div>
191
192  <div class="container">
193    <section class="section register min-vh-100 d-flex flex-column align-items-center justify-content-center py-4">
194      <div class="container">
195        <div class="row justify-content-center">
196          <div class="col-lg-4 col-md-6 d-flex flex-column align-items-center justify-content-center">
197
198            <div class="card mb-3 register-card">
199              <div class="card-body">
200
201                <!-- Logo + título -->
202                <div class="pt-4 pb-2 text-center">
203                  <div class="d-flex justify-content-center mb-3">
204                    <a href="https://cortexdigital.com.ar/web/" class="logo d-flex align-items-center w-auto register-logo">
205                      <img src="https://cortexdigital.com.ar/acoslegacy/media/wirtschaft.png" alt="Logo CashFlow">
206                      <span>CashFlow</span>
207                    </a>
208                  </div>
209
210                  <h5 class="card-title register-title">Registro de usuario</h5>
211                  <p class="register-subtitle">Ingresa tus datos para crear una cuenta nueva.</p>
212                  <div class="register-trial">
213                    🎁 Al registrarte te regalamos <strong>30 días de Premium</strong><br>
214                    <span>Todos los módulos desbloqueados. Sin tarjeta, sin compromiso.</span>
215                  </div>
216                </div>
217
218                <!-- 🧾 Formulario de registro -->
219                <form action="/auth/create" method="post" class="row g-3 needs-validation" novalidate>
220                  <!-- 🛡️ Honeypot (campo invisible, debe quedar vacío) -->
221                  <div class="hp-field" aria-hidden="true">
222                    <label>Dejar en blanco</label>
223                    <input type="text" name="website_hp" tabindex="-1" autocomplete="off" />
224                  </div>
225
226                  <!-- 🕒 Timestamp para validación de tiempo mínimo -->
227                  <input type="hidden" name="formStartedAt" id="formStartedAt" value="" />
228
229                  <div class="col-6">
230                    <label for="name" class="form-label register-label">
231                      Nombre <span class="text-danger">*</span>
232                    </label>
233                    <input
234                      type="text"
235                      name="nombre"
236                      class="form-control register-input"
237                      placeholder="Tu nombre"
238                      id="name"
239                      required
240                    >
241                    <div class="invalid-feedback">Por favor, ingresa tu nombre.</div>
242                  </div>
243
244                  <div class="col-6">
245                    <label for="surname" class="form-label register-label">
246                      Apellido <span class="text-danger">*</span>
247                    </label>
248                    <input
249                      type="text"
250                      name="apellido"
251                      placeholder="Tu apellido"
252                      class="form-control register-input"
253                      id="surname"
254                      required
255                    >
256                    <div class="invalid-feedback">Por favor, ingresa tu apellido.</div>
257                  </div>
258
259                  <div class="col-12">
260                    <label for="nacimiento" class="form-label register-label">
261                      Nacimiento <span class="text-danger">*</span>
262                    </label>
263                    <input
264                      type="date"
265                      name="nacimiento"
266                      class="form-control register-input"
267                      id="nacimiento"
268                      required
269                    >
270                    <div class="invalid-feedback">Por favor, ingresa una fecha válida.</div>
271                  </div>
272
273                  <div class="col-12">
274                    <label for="telefono" class="form-label register-label">
275                      Celular <span class="text-danger">*</span>
276                    </label>
277                    <input
278                      type="tel"
279                      placeholder="+54 341 201-3655"
280                      name="telefono"
281                      class="form-control register-input"
282                      id="telefono"
283                      autocomplete="tel"
284                      inputmode="tel"
285                      pattern="[\d\+\-\s\(\)]{6,}"
286                      required
287                    >
288                    <div class="invalid-feedback">
288Por favor, ingresa un número de celular válido.</div>
289                  </div>
290
291                  <div class="col-12">
292                    <label for="yourEmail" class="form-label register-label">
293                      Correo electrónico <span class="text-danger">*</span>
294                    </label>
295                    <input
296                      type="email"
297                      placeholder="[email protected]"
298                      name="email"
299                      class="form-control register-input"
300                      id="yourEmail"
301                      required
302                    >
303                    <div class="invalid-feedback">Por favor, ingresa un correo electrónico válido.</div>
304                  </div>
305
306                  <div class="col-12">
307                    <label for="yourPassword" class="form-label register-label">
308                      Contraseña <span class="text-danger">*</span>
309                    </label>
310                    <input
311                      type="password"
312                      placeholder="********"
313                      name="password"
314                      class="form-control register-input"
315                      id="yourPassword"
316                      required
317                      minlength="8"
318                      autocomplete="new-password"
319                    >
320                    <div class="invalid-feedback">La contraseña debe tener al menos 8 caracteres.</div>
321                  </div>
322
323                  <div class="col-12">
324                    <label for="yourConfirmPassword" class="form-label register-label">
325                      Confirmar contraseña <span class="text-danger">*</span>
326                    </label>
327                    <input
328                      type="password"
329                      placeholder="********"
330                      name="yourConfirmPassword"
331                      class="form-control register-input"
332                      id="yourConfirmPassword"
333                      required
334                      autocomplete="new-password"
335                    >
336                    <div class="invalid-feedback">Por favor, confirmá la contraseña.</div>
337                  </div>
338
339                  <div class="col-12">
340                    <div class="form-check register-check">
341                      <input
342                        class="form-check-input"
343                        name="acceptTerms"
344                        type="checkbox"
345                        value="true"
346                        id="acceptTerms"
347                        required
348                      >
349                      <label class="form-check-label" for="acceptTerms">
350                        Acepto todos los
351                        <a href="https://cortexdigital.com.ar/web/privacy-policy/" target="_blank">
352                          términos y condiciones
353                        </a>.
354                        <span class="text-danger">*</span>
355                      </label>
356                      <div class="invalid-feedback">
357                        Para crear una cuenta debes aceptar los términos y condiciones.
358                      </div>
359                    </div>
360                  </div>
361
362                  <div class="col-12 mt-1">
363                    <button class="btn btn-primary w-100 register-btn" type="submit" id="btnSubmit">
364                      Crear cuenta
365                    </button>
366                  </div>
367
368                  <div class="col-12 text-center register-links">
369                    <p class="small mb-0">
370                      ¿Tu cuenta ya existe?
371                      <a href="pages-login">Inicia sesión</a>
372                    </p>
373                  </div>
374
375                  <!-- Separador -->
376                  <div class="col-12">
377                    <div class="register-divider">
378                      <span>¿Problemas para registrarte?</span>
379                    </div>
380                  </div>
381
382                  <!-- Botón WhatsApp registro manual -->
383                  <div class="col-12">
384                    <a
385                      href="https://wa.me/5493412013655?text=Hola%20equipo%20de%20CashFlow%2C%20necesito%20registrarme%20de%20forma%20manual.%20Mis%20datos%3A%20%0ANombre%3A%20%0AApellido%3A%20%0ACorreo%3A%20%0ACelular%3A%20"
386                      target="_blank"
387                      class="btn register-btn-whatsapp w-100"
388                    >
389                      <i class="bi bi-whatsapp me-2"></i>
390                      Registrarme por WhatsApp
391                    </a>
392                    <p class="register-whatsapp-hint">
393                      El equipo te asistirá y activará tu cuenta manualmente.
394                    </p>
395                  </div>
396                </form>
397              </div>
398            </div>
399
400                <!-- <div class="copyright">
401                  &copy;
401 Copyright <strong><a href="https://cortexdigital.com.ar/" target="_blank"><span>AcosLegacy</span></a></strong>
402                </div>-->
403                <div class="credits">
404                  Cortex.Digital
405                  <a href="https://cortexdigital.com.ar/" target="_blank">Más info.</a>
406                </div>          </div>
407        </div>
408      </div>
409    </section>
410  </div>
411</main>
412
413<a href="#" class="back-to-top d-flex align-items-center justify-content-center"><i
414    class="bi bi-arrow-up-short"></i></a>
415
416<!-- /vendor JS Files -->
417<script src="/assets/vendor/apexcharts/apexcharts.min.js"></script>
vendor: 1 bytes, line 417
417
418<script src="/assets/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
vendor: 1 bytes, line 418
418
419<script src="/assets/vendor/chart.js/chart.min.js"></script>
vendor: 1 bytes, line 419
419
420<script src="/assets/vendor/echarts/echarts.min.js"></script>
420
421<!-- simple-datatables se carga via CDN en head.hbs -->
422<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
vendor: 1 bytes, line 422
422
423<script src="/assets/vendor/php-email-form/validate.js"></script>
423
424<!-- gravitec worker -->
425<script src="https://cdn.gravitec.net/storage/6eeb6c904a22d41fa69f564770a20f0a/client.js" async></script>
425
426
427
428<!-- Modules 
429    
429<script src="../js/login.js"></script>
429
430    
430<script src="../js/register.js"></script>
430
431  -->
432<!-- Motion One (vanilla) — motor de animaciones de UI (global window.Motion) -->
433<script src="/assets/vendor/motion/motion.min.js"></script>
433
434<!-- Sileo (port vanilla) — toasts con física de resorte (global window.sileo) -->
435<script src="/assets/vendor/sileo/sileo.iife.js"></script>
435
436<!-- Capa propia: notificar.* (toasts) y CFAnim (animaciones). Van después de
437     sus librerías y antes del resto, para estar disponibles en toda la app. -->
438<!-- Libreria compartida (src/shared/js/lib): formato es-AR unificado, wrapper de
439     fetch con manejo de 401/403, y helper de modales. Va ANTES del resto para
440     que window.fmt / window.http / window.modal esten disponibles en toda la app. -->
441<script src="/assets/js/lib/format.js"></script>
vendor: 1 bytes, line 441
441
442<script src="/assets/js/lib/modal.js"></script>
442
443<!-- Diagramas Mermaid (window.cfDiagramas): figuras con pantalla completa, editor y
444     versión papel para los PDF. Liviano: el motor Mermaid se baja recién cuando
445     una pantalla tiene un diagrama. -->
446<script src="/assets/js/lib/diagramas.js"></script>
vendor: 1 bytes, line 446
446
447<script src="/assets/js/notify.js"></script>
vendor: 1 bytes, line 447
447
448<script src="/assets/js/lib/http.js"></script>
448
449<!-- Dictado por voz reutilizable: cuelga un microfono de cualquier input de IA -->
450<script src="/assets/js/lib/voz.js"></script>
vendor: 1 bytes, line 450
450
451<script src="/assets/js/anim.js"></script>
451
452<!-- Overlay de carga GLOBAL: spinner bloqueante durante operaciones (evita cortar
453     procesos sin querer, ej. borrado masivo). Intercepta fetch/$.ajax de mutación. -->
454<script src="/assets/js/cargando.js"></script>
454
455<!-- Lee los avisos flash (cookie cf_flash + query params) y los muestra como toast -->
456<script src="/assets/js/flash.js"></script>
456
457<!-- Template Main JS File -->
458<script src="/assets/js/main.js"></script>
vendor: 1 bytes, line 458
458
459<script src="/assets/js/header.js"></script>
459
460<!-- Selector de modelo de IA (window.AiModelo): el chip que va pegado a los
461     inputs de IA con el LLM en uso, sus límites y el cambio de modelo. Va ANTES
462     del panel del header, que lo usa para las listas de modelos. -->
463<script src="/assets/js/ai-modelo.js"></script>
463
464<!-- Panel "Disponibilidad de IA" del header: una barra por LLM conectado, con
465     estado de cuota, orden de uso editable y test de conexión. -->
466<script src="/assets/js/ai-estado.js"></script>
466
467<!-- PWA: instalación, bloqueo de zoom y del gesto de back (app-like) -->
468<script src="/assets/js/pwa.js"></script>
468
469<!-- Caché offline: capa de acceso al service worker (botón Sincronizar del aside,
470     auto-sync cada 24 h, estado online/offline). Va después de pwa.js, que registra el SW. -->
471<script src="/assets/js/cache.js"></script>
vendor: 1 bytes, line 471
471
472<script src="/assets/js/sound.js"></script>
472
473<!-- Feedback de UI: sonidos de interacción (cuelume) + animación de contadores -->
474<script type="module" src="/assets/js/cuelume-init.js"></script>
vendor: 1 bytes, line 474
474
475<script src="/assets/js/count-up.js" defer></script>
475
476<!-- JS Paypal: TEST -->
477<script
478    src="https://www.paypal.com/sdk/js?client-id=AWeQ5E_4amzccWiMXexpFJcPRTsxPOKLQOdqGhmiugCcue6h3Y5jGDzRoJZfWJwBNy5EEjzO1TgeFYXJ&components=buttons"></script>
478
479
480    <!-- Incluir jQuery -->
481<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
481
482<!-- Incluir DataTables después de jQuery -->
483<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
vendor: 1 bytes, line 483
483
484<script>
485// ── Selects del sistema: orden alfabético + ancho ADAPTATIVO al contenido ──────
486// Cada <select> se ordena (placeholders arriba, orden natural es+numeric) y se le
487// fija el ancho de su opción MÁS LARGA → sin saltos de línea y sin ocupar de más
488// (cada dropdown mide distinto según su contenido). Cubre selects dinámicos
489// (cargados por fetch). Opt-out: data-no-sort (orden) y data-no-width (ancho).
490document.addEventListener("DOMContentLoaded", function () {
491  'use strict';
492  function ordenarSelect(sel) {
493    if (!sel || sel.dataset.noSort !== undefined || sel.dataset.sorting !== undefined) return;
494    if (sel.querySelector('optgroup')) return; // no tocamos selects con grupos
495    var opts = Array.prototype.slice.call(sel.options);
496    if (opts.length < 2) return;
497    var fijos = opts.filter(function (o) { return o.value === ''; });      // placeholders arriba
498    var resto = opts.filter(function (o) { return o.value !== ''; });
499    resto.sort(function (a, b) { return a.text.toLowerCase().localeCompare(b.text.toLowerCase(), 'es', { numeric: true }); });
500    var objetivo = fijos.concat(resto);
501    if (objetivo.every(function (o, i) { return o === opts[i]; })) return;  // ya ordenado → no tocar
502    var valor = sel.value;
503    sel.dataset.sorting = '1';
504    objetivo.forEach(function (o) { sel.appendChild(o); });                 // mueve nodos (conserva estado)
505    sel.value = valor;
506    delete sel.dataset.sorting;
507  }
508  // Mide texto sin tocar el DOM (canvas), con la tipografía real del select.
509  var _ctx = null;
510  function medirTexto(texto, font) {
511    if (!_ctx) { _ctx = document.createElement('canvas').getContext('2d'); }
512    _ctx.font = font;
513    return _ctx.measureText(texto || '').width;
514  }
515  // Fija el ancho del select al de su opción MÁS LARGA (adaptativo por select):
516  // ningún salto de línea y sin ocupar de más (cada uno mide distinto según su
517  // contenido). Opt-out: data-no-width.
518  function ajustarAncho(sel) {
519    if (!sel || sel.dataset.noWidth !== undefined || !sel.options.length) return;
520    var cs = getComputedStyle(sel);
521    var font = (cs.fontWeight || '400') + ' ' + (cs.fontSize || '14px') + ' ' + (cs.fontFamily || 'sans-serif');
522    var max = 0;
523    for (var i = 0; i < sel.options.length; i++) { var w = medirTexto(sel.options[i].text, font); if (w > max) max = w; }
524    if (!max) return;
525    var ancho = max + 26; // 26px ≈ flecha del select + respiro
526    if (cs.boxSizing === 'border-box') {
527      ancho += (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0)
528             + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
529    }
530    ancho = Math.ceil(ancho);
531    if (ancho < 56) ancho = 56;          // piso para selects muy cortos
532    sel.style.width = ancho + 'px';
533    sel.style.maxWidth = '100%';         // nunca desborda el contenedor
534  }
535  function procesarSelect(sel) { ordenarSelect(sel); ajustarAncho(sel); }
536  function procesarTodos(root) { (root || document).querySelectorAll('select').forEach(procesarSelect); }
537  window.ordenarSelects = procesarTodos; // por si un módulo quiere forzarlo tras poblar opciones
538
539  procesarTodos(document);
540  // Con web fonts el ancho exacto se conoce recién cuando cargan → recalculamos.
541  if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { procesarTodos(document); });
542
543  // Selects que aparecen/cambian después (cargados por fetch en los módulos).
544  if (window.MutationObserver) {
545    var t = null;
546    var obs = new MutationObserver(function (muts) {
547      var relevante = muts.some(function (m) {
548        return Array.prototype.some.call(m.addedNodes, function (n) {
549          return n.nodeType === 1 && (n.tagName === 'SELECT' || n.tagName === 'OPTION' || (n.querySelector && n.querySelector('select')));
550        });
551      });
552      if (!relevante) return;
553      clearTimeout(t);
554      t = setTimeout(function () { obs.disconnect(); procesarTodos(document); obs.observe(document.body, { childList: true, subtree: true }); }, 120);
555    });
556    obs.observe(document.body, { childList: true, subtree: true });
557  }
558});
559</script>
vendor: 1 bytes, line 559
559
560<script src="./assets/js/register.js"></script>
560
561
562  <footer id="footer" class="footer px-3 py-2">
563    <p class="small mb-1">© 2024 Cortex.Digital - <a href="https://cortexdigital.com.ar/" target="_blank">Más info</a> • <a target="_blank" href="https://www.linkedin.com/in/acostasimon/">Acosta Simón</a></p>
564  </footer>
565  <style>
566    /* ===== Footer CashFlow / Cortex Digital — Tema Claro ===== */
567  
568  footer.footer {
569    background-color: var(--bg-base);
570    border-top: 1px solid var(--border-color);
571    box-shadow: 0 -1px 0 var(--border-color);
572    color: var(--text-secondary);
573    text-align: center;
574    padding: 1rem 1rem;
575    font-size: 0.85rem;
576  }
577  
578  footer.footer p {
579    color: var(--text-secondary);
580    margin: 0.25rem 0;
581  }
582  
583  footer.footer a {
584    color: var(--color-lime-dark) !important;
585    font-weight: 600;
586    text-decoration: none;
587    transition: color 0.2s ease;
588  }
589  
590  footer.footer a:hover {
591    color: var(--color-lime) !important;
592  }
593  
594  @media (max-width: 768px) {
595    footer.footer {
596      font-size: 0.78rem;
597      padding: 0.8rem 0.5rem;
598    }
599  }
600  
601  </style>  <a href="#" class="back-to-top d-flex align-items-center justify-content-center"><i
602      class="bi bi-arrow-up-short"></i></a>
603  
604  <!-- /vendor JS Files -->
605  
605<script src="/assets/vendor/apexcharts/apexcharts.min.js"></script>
605
606  
606<script src="/assets/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
606
607  
607<script src="/assets/vendor/chart.js/chart.min.js"></script>
607
608  
608<script src="/assets/vendor/echarts/echarts.min.js"></script>
608
609  <!-- simple-datatables se carga via CDN en head.hbs -->
610  
610<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
610
611  
611<script src="/assets/vendor/php-email-form/validate.js"></script>
611
612  <!-- gravitec worker -->
613  
613<script src="https://cdn.gravitec.net/storage/6eeb6c904a22d41fa69f564770a20f0a/client.js" async></script>
613
614  
615  
616  <!-- Modules 
617      
617<script src="../js/login.js"></script>
617
618      
618<script src="../js/register.js"></script>
618
619    -->
620  <!-- Motion One (vanilla) — motor de animaciones de UI (global window.Motion) -->
621  
621<script src="/assets/vendor/motion/motion.min.js"></script>
621
622  <!-- Sileo (port vanilla) — toasts con física de resorte (global window.sileo) -->
623  
623<script src="/assets/vendor/sileo/sileo.iife.js"></script>
623
624  <!-- Capa propia: notificar.* (toasts) y CFAnim (animaciones). Van después de
625       sus librerías y antes del resto, para estar disponibles en toda la app. -->
626  <!-- Libreria compartida (src/shared/js/lib): formato es-AR unificado, wrapper de
627       fetch con manejo de 401/403, y helper de modales. Va ANTES del resto para
628       que window.fmt / window.http / window.modal esten disponibles en toda la app. -->
629  
629<script src="/assets/js/lib/format.js"></script>
629
630  
630<script src="/assets/js/lib/modal.js"></script>
630
631  <!-- Diagramas Mermaid (window.cfDiagramas): figuras con pantalla completa, editor y
632       versión papel para los PDF. Liviano: el motor Mermaid se baja recién cuando
633       una pantalla tiene un diagrama. -->
634  
634<script src="/assets/js/lib/diagramas.js"></script>
634
635  
635<script src="/assets/js/notify.js"></script>
635
636  
636<script src="/assets/js/lib/http.js"></script>
636
637  <!-- Dictado por voz reutilizable: cuelga un microfono de cualquier input de IA -->
638  
638<script src="/assets/js/lib/voz.js"></script>
638
639  
639<script src="/assets/js/anim.js"></script>
639
640  <!-- Overlay de carga GLOBAL: spinner bloqueante durante operaciones (evita cortar
641       procesos sin querer, ej. borrado masivo). Intercepta fetch/$.ajax de mutación. -->
642  
642<script src="/assets/js/cargando.js"></script>
642
643  <!-- Lee los avisos flash (cookie cf_flash + query params) y los muestra como toast -->
644  
644<script src="/assets/js/flash.js"></script>
644
645  <!-- Template Main JS File -->
646  
646<script src="/assets/js/main.js"></script>
646
647  
647<script src="/assets/js/header.js"></script>
647
648  <!-- Selector de modelo de IA (window.AiModelo): el chip que va pegado a los
649       inputs de IA con el LLM en uso, sus límites y el cambio de modelo. Va ANTES
650       del panel del header, que lo usa para las listas de modelos. -->
651  
651<script src="/assets/js/ai-modelo.js"></script>
651
652  <!-- Panel "Disponibilidad de IA" del header: una barra por LLM conectado, con
653       estado de cuota, orden de uso editable y test de conexión. -->
654  
654<script src="/assets/js/ai-estado.js"></script>
654
655  <!-- PWA: instalación, bloqueo de zoom y del gesto de back (app-like) -->
656  
656<script src="/assets/js/pwa.js"></script>
656
657  <!-- Caché offline: capa de acceso al service worker (botón Sincronizar del aside,
658       auto-sync cada 24 h, estado online/offline). Va después de pwa.js, que registra el SW. -->
659  
659<script src="/assets/js/cache.js"></script>
659
660  
660<script src="/assets/js/sound.js"></script>
660
661  <!-- Feedback de UI: sonidos de interacción (cuelume) + animación de contadores -->
662  
662<script type="module" src="/assets/js/cuelume-init.js"></script>
662
663  
663<script src="/assets/js/count-up.js" defer></script>
663
664  <!-- JS Paypal: TEST -->
665  
665<script
666      src="https://www.paypal.com/sdk/js?client-id=AWeQ5E_4amzccWiMXexpFJcPRTsxPOKLQOdqGhmiugCcue6h3Y5jGDzRoJZfWJwBNy5EEjzO1TgeFYXJ&components=buttons"></script>
666
667  
668      <!-- Incluir jQuery -->
669  
669<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
669
670  <!-- Incluir DataTables después de jQuery -->
671  
671<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
671
672  
672<script>
673  // ── Selects del sistema: orden alfabético + ancho ADAPTATIVO al contenido ──────
674  // Cada <select> se ordena (placeholders arriba, orden natural es+numeric) y se le
675  // fija el ancho de su opción MÁS LARGA → sin saltos de línea y sin ocupar de más
676  // (cada dropdown mide distinto según su contenido). Cubre selects dinámicos
677  // (cargados por fetch). Opt-out: data-no-sort (orden) y data-no-width (ancho).
678  document.addEventListener("DOMContentLoaded", function () {
679    'use strict';
680    function ordenarSelect(sel) {
681      if (!sel || sel.dataset.noSort !== undefined || sel.dataset.sorting !== undefined) return;
682      if (sel.querySelector('optgroup')) return; // no tocamos selects con grupos
683      var opts = Array.prototype.slice.call(sel.options);
684      if (opts.length < 2) return;
685      var fijos = opts.filter(function (o) { return o.value === ''; });      // placeholders arriba
686      var resto = opts.filter(function (o) { return o.value !== ''; });
687      resto.sort(function (a, b) { return a.text.toLowerCase().localeCompare(b.text.toLowerCase(), 'es', { numeric: true }); });
688      var objetivo = fijos.concat(resto);
689      if (objetivo.every(function (o, i) { return o === opts[i]; })) return;  // ya ordenado → no tocar
690      var valor = sel.value;
691      sel.dataset.sorting = '1';
692      objetivo.forEach(function (o) { sel.appendChild(o); });                 // mueve nodos (conserva estado)
693      sel.value = valor;
694      delete sel.dataset.sorting;
695    }
696    // Mide texto sin tocar el DOM (canvas), con la tipografía real del select.
697    var _ctx = null;
698    function medirTexto(texto, font) {
699      if (!_ctx) { _ctx = document.createElement('canvas').getContext('2d'); }
700      _ctx.font = font;
701      return _ctx.measureText(texto || '').width;
702    }
703    // Fija el ancho del select al de su opción MÁS LARGA (adaptativo por select):
704    // ningún salto de línea y sin ocupar de más (cada uno mide distinto según su
705    // contenido). Opt-out: data-no-width.
706    function ajustarAncho(sel) {
707      if (!sel || sel.dataset.noWidth !== undefined || !sel.options.length) return;
708      var cs = getComputedStyle(sel);
709      var font = (cs.fontWeight || '400') + ' ' + (cs.fontSize || '14px') + ' ' + (cs.fontFamily || 'sans-serif');
710      var max = 0;
711      for (var i = 0; i < sel.options.length; i++) { var w = medirTexto(sel.options[i].text, font); if (w > max) max = w; }
712      if (!max) return;
713      var ancho = max + 26; // 26px ≈ flecha del select + respiro
714      if (cs.boxSizing === 'border-box') {
715        ancho += (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0)
716               + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
717      }
718      ancho = Math.ceil(ancho);
719      if (ancho < 56) ancho = 56;          // piso para selects muy cortos
720      sel.style.width = ancho + 'px';
721      sel.style.maxWidth = '100%';         // nunca desborda el contenedor
722    }
723    function procesarSelect(sel) { ordenarSelect(sel); ajustarAncho(sel); }
724    function procesarTodos(root) { (root || document).querySelectorAll('select').forEach(procesarSelect); }
725    window.ordenarSelects = procesarTodos; // por si un módulo quiere forzarlo tras poblar opciones
726  
727    procesarTodos(document);
728    // Con web fonts el ancho exacto se conoce recién cuando cargan → recalculamos.
729    if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { procesarTodos(document); });
730  
731    // Selects que aparecen/cambian después (cargados por fetch en los módulos).
732    if (window.MutationObserver) {
733      var t = null;
734      var obs = new MutationObserver(function (muts) {
735        var relevante = muts.some(function (m) {
736          return Array.prototype.some.call(m.addedNodes, function (n) {
737            return n.nodeType === 1 && (n.tagName === 'SELECT' || n.tagName === 'OPTION' || (n.querySelector && n.querySelector('select')));
738          });
739        });
740        if (!relevante) return;
741        clearTimeout(t);
742        t = setTimeout(function () { obs.disconnect(); procesarTodos(document); obs.observe(document.body, { childList: true, subtree: true }); }, 120);
743      });
744      obs.observe(document.body, { childList: true, subtree: true });
745    }
746  });
747  </script>
747
748
749  
749<script>
750    /**
751 * ============================================================================
752 * CashFlow Sound System v1.0.0
753 * ============================================================================
754 * Sistema universal de sonidos para feedback de usuario
755 * 
756 * Autor: Acosta Simón Martín - [email protected]
757 * Proyecto: CashFlow v2.5.0+
758 * 
759 * UBICACIÓN: /views/assets/js/sounds.js
760 * 
761 * CARACTERÍSTICAS:
762 * - Reproducción automática en respuestas exitosas de formularios
763 * - Soporte para AJAX/Fetch nativo
764 * - Configuración via data-attributes
765 * - Excepciones personalizables (ej: chat IA)
766 * - API pública para llamadas manuales
767 * - Precarga de sonidos para mejor rendimiento
768 * - Control de volumen global
769 * - Respeto por preferencias de usuario (reducción de movimiento)
770 * ============================================================================
771 */
772console.log('Sound.js cargado');
773
774(function(window) {
775    'use strict';
776
777    // =========================================================================
778    // CONFIGURACIÓN PRINCIPAL
779    // =========================================================================
780    
781    const CONFIG = {
782        // Ruta base de los archivos de sonido
783        basePath: '/assets/sounds/',
784        
785        // Catálogo de sonidos disponibles
786        sounds: {
787            success: 'correcto.mp3',    // Formularios exitosos
788            chat: 'chat.mp3',           // Chat IA
789            pop: 'pop.mp3',             // Acciones generales (notificaciones, etc)
790            error: 'pop.mp3'            // Errores (usa pop por defecto, personalizable)
791        },
792        
793        // Volumen global (0.0 a 1.0)
794        volume: 0.5,
795        
796        // Habilitar/deshabilitar sonidos globalmente
797        enabled: true,
798        
799        // Selectores para identificar el chat IA (excepción)
800        chatSelectors: [
801            '[data-sound="chat"]',
802            '#chat-ia-form',
803            '#gemini-chat-form',
804            '.chat-ia-input',
805            '[data-module="chat-ia"]'
806        ],
807        
808        // Selectores a ignorar (no reproducir sonido)
809        ignoreSelectors: [
810            '[data-sound="none"]',
811            '[data-sound-disabled]',
812            '.no-sound'
813        ],
814        
815        // Tiempo mínimo entre sonidos (evita spam)
816        debounceMs: 300
817    };
818
819    // =========================================================================
820    // CLASE PRINCIPAL: CashFlowSounds
821    // =========================================================================
822    
823    class CashFlowSounds {
824        constructor(options = {}) {
825            // Merge configuración personalizada
826            this.config = { ...CONFIG, ...options };
827            
828            // Cache de elementos de audio
829            this.audioCache = new Map();
830            
831            // Control de debounce
832            this.lastPlayTime = 0;
833            
834            // Estado de inicialización
835            this.initialized = false;
836            
837            // Verificar preferencias de usuario
838            this.checkUserPreferences();
839            
840            // Inicializar
841            this.init();
842        }
843
844        // =====================================================================
845        // INICIALIZACIÓN
846        // =====================================================================
847        
848        init() {
849            if (this.initialized) return;
850            
851            // Precargar sonidos
852            this.preloadSounds();
853            
854            // Interceptar respuestas de formularios
855            this.interceptFormSubmissions();
856            
857            // Interceptar llamadas fetch/axios
858            this.interceptAjaxCalls();
859            
860            // Escuchar eventos personalizados
861            this.listenCustomEvents();
862            
863            this.initialized = true;
864            
865            console.log('🔊 CashFlow Sound System inicializado');
866        }
867
868        /**
869         * Verificar preferencias de accesibilidad del usuario
870         */
871        checkUserPreferences() {
872            // Respetar preferencia de reducción de movimiento/sonido
873            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
874                this.config.volume = 0.2; // Reducir volumen
875            }
876            
877            // Verificar localStorage para preferencias guardadas
878            const savedPrefs = localStorage.getItem('cashflow_sound_prefs');
879            if (savedPrefs) {
880                try {
881                    const prefs = JSON.parse(savedPrefs);
882                    this.config.enabled = prefs.enabled ?? this.config.enabled;
883                    this.config.volume = prefs.volume ?? this.config.volume;
884                } catch (e) {
885                    console.warn('Error al cargar preferencias de sonido:', e);
886                }
887            }
888        }
889
890        /**
891         * Precargar todos los sonidos en memoria
892         */
893        preloadSounds() {
894            Object.entries(this.config.sounds).forEach(([key, filename]) => {
895                const audio = new Audio(this.config.basePath + filename);
896                audio.preload = 'auto';
897                audio.volume = this.config.volume;
898                this.audioCache.set(key, audio);
899            });
900        }
901
902        // =====================================================================
903        // REPRODUCCIÓN DE SONIDOS
904        // =====================================================================
905        
906        /**
907         * Reproducir un sonido específico
908         * @param {string} soundKey - Clave del sonido (success, chat, pop, error)
909         * @param {object} options - Opciones adicionales
910         */
911        play(soundKey = 'success', options = {}) {
912            // Verificar si está habilitado
913            if (!this.config.enabled) return;
914            
915            // Control de debounce
916            const now = Date.now();
917            if (now - this.lastPlayTime < this.config.debounceMs) return;
918            this.lastPlayTime = now;
919            
920            // Obtener audio del cache o crear nuevo
921            let audio = this.audioCache.get(soundKey);
922            
923            if (!audio) {
924                // Si no existe la clave, usar 'success' por defecto
925                audio = this.audioCache.get('success');
926                console.warn(`Sonido '${soundKey}' no encontrado, usando 'success'`);
927            }
928            
929            if (!audio) return;
930            
931            // Configurar volumen (permite override)
932            audio.volume = options.volume ?? this.config.volume;
933            
934            // Reiniciar y reproducir
935            audio.currentTime = 0;
936            
937            // Usar promesa para manejar errores de autoplay
938            const playPromise = audio.play();
939            
940            if (playPromise !== undefined) {
941                playPromise.catch(error => {
942                    // Autoplay bloqueado por el navegador
943                    console.debug('Reproducción de sonido bloqueada:', error.message);
944                });
945            }
946        }
947
948        /**
949         * Atajos para sonidos específicos
950         */
951        success(options) { this.play('success', options); }
952        chat(options) { this.play('chat', options); }
953        pop(options) { this.play('pop', options); }
954        error(options) { this.play('error', options); }
955
956        // =====================================================================
957        // INTERCEPCIÓN DE FORMULARIOS
958        // =====================================================================
959        
960        /**
961         * Interceptar envíos de formularios tradicionales
962         */
963        interceptFormSubmissions() {
964            document.addEventListener('submit', (e) => {
965                const form = e.target;
966                
967                // Verificar si debe ignorarse
968                if (this.shouldIgnore(form)) return;
969                
970                // Determinar qué sonido usar
971                const soundKey = this.getSoundKeyForElement(form);
972                
973                // Guardar referencia para reproducir después del éxito
974                form._cashflowSound = soundKey;
975            });
976        }
977
978        /**
979         * Interceptar llamadas AJAX (fetch y axios)
980         */
981        interceptAjaxCalls() {
982            // ─────────────────────────────────────────────────────────────────
983            // Interceptor de Fetch nativo
984            // ─────────────────────────────────────────────────────────────────
985            const originalFetch = window.fetch;
986            const self = this;
987            
988            window.fetch = function(...args) {
989                // Opt-out por request: fetch(url, { sonido: false }) (refrescos automáticos)
990                if (args[1] && args[1].sonido === false) return originalFetch.apply(this, args);
991                return originalFetch.apply(this, args).then(response => {
992                    // Solo procesar si la respuesta es exitosa
993                    if (response.ok) {
994                        // Clonar respuesta para poder leerla
995                        const clonedResponse = response.clone();
996                        
997                        // Intentar determinar el contexto
998                        clonedResponse.json().then(data => {
999                            // Verificar si la respuesta indica éxito
1000                            if (self.isSuccessResponse(data)) {
1001                                // Determinar sonido basado en URL o contexto
1002                                const soundKey = self.getSoundKeyFromUrl(args[0]);
1003                                self.play(soundKey);
1004                            }
1005                        }).catch(() => {
1006                            // No es JSON, ignorar
1007                        });
1008                    }
1009                    return response;
1010                });
1011            };
1012
1013            // ─────────────────────────────────────────────────────────────────
1014            // Interceptor de Axios (si está disponible)
1015            // ─────────────────────────────────────────────────────────────────
1016            if (window.axios) {
1017                window.axios.interceptors.response.use(
1018                    (response) => {
1019                        if (this.isSuccessResponse(response.data)) {
1020                            const soundKey = this.getSoundKeyFromUrl(response.config.url);
1021                            this.play(soundKey);
1022                        }
1023                        return response;
1024                    },
1025                    (error) => {
1026                        // Opcionalmente reproducir sonido de error
1027                        // this.play('error');
1028                        return Promise.reject(error);
1029                    }
1030                );
1031            }
1032        }
1033
1034        /**
1035         * Verificar si una respuesta indica éxito
1036         * @param {object} data - Datos de la respuesta
1037         */
1038        isSuccessResponse(data) {
1039            if (!data || typeof data !== 'object') return false;
1040            
1041            // Patrones comunes de respuesta exitosa en CashFlow
1042            return (
1043                data.success === true ||
1044                data.status === 'success' ||
1045                data.ok === true ||
1046                data.created === true ||
1047                data.updated === true ||
1048                data.deleted === true ||
1049                data.saved === true
1050            );
1051        }
1052
1053        /**
1054         * Determinar sonido basado en URL
1055         * @param {string} url - URL de la petición
1056         */
1057        getSoundKeyFromUrl(url) {
1058            if (!url) return 'success';
1059            
1060            const urlStr = url.toString().toLowerCase();
1061            
1062            // Detectar rutas del chat IA
1063            const chatPatterns = [
1064                '/chat', '/ia', '/gemini', '/assistant', 
1065                '/ai', '/mensaje', '/message'
1066            ];
1067            
1068            if (chatPatterns.some(pattern => urlStr.includes(pattern))) {
1069                return 'chat';
1070            }
1071            
1072            return 'success';
1073        }
1074
1075        // =====================================================================
1076        // DETECCIÓN DE CONTEXTO
1077        // =====================================================================
1078        
1079        /**
1080         * Verificar si un elemento debe ignorarse
1081         * @param {Element} element - Elemento DOM
1082         */
1083        shouldIgnore(element) {
1084            return this.config.ignoreSelectors.some(selector => 
1085                element.matches(selector) || element.closest(selector)
1086            );
1087        }
1088
1089        /**
1090         * Determinar qué sonido usar para un elemento
1091         * @param {Element} element - Elemento DOM
1092         */
1093        getSoundKeyForElement(element) {
1094            // 1. Verificar data-sound explícito
1095            const explicitSound = element.dataset.sound;
1096            if (explicitSound && this.audioCache.has(explicitSound)) {
1097                return explicitSound;
1098            }
1099            
1100            // 2. Verificar si es contexto de chat IA
1101            const isChat = this.config.chatSelectors.some(selector =>
1102                element.matches(selector) || element.closest(selector)
1103            );
1104            
1105            if (isChat) return 'chat';
1106            
1107            // 3. Default: success
1108            return 'success';
1109        }
1110
1111        // =====================================================================
1112        // EVENTOS PERSONALIZADOS
1113        // =====================================================================
1114        
1115        /**
1116         * Escuchar eventos personalizados para reproducir sonidos
1117         */
1118        listenCustomEvents() {
1119            // Evento genérico para reproducir sonido
1120            document.addEventListener('cashflow:sound', (e) => {
1121                const soundKey = e.detail?.sound || 'success';
1122                const options = e.detail?.options || {};
1123                this.play(soundKey, options);
1124            });
1125            
1126            // Evento de éxito
1127            document.addEventListener('cashflow:success', () => {
1128                this.play('success');
1129            });
1130            
1131            // Evento de chat
1132            document.addEventListener('cashflow:chat', () => {
1133                this.play('chat');
1134            });
1135            
1136            // Evento de notificación
1137            document.addEventListener('cashflow:notification', () => {
1138                this.play('pop');
1139            });
1140        }
1141
1142        // =====================================================================
1143        // CONFIGURACIÓN EN TIEMPO DE EJECUCIÓN
1144        // =====================================================================
1145        
1146        /**
1147         * Habilitar/deshabilitar sonidos
1148         * @param {boolean} enabled
1149         */
1150        setEnabled(enabled) {
1151            this.config.enabled = enabled;
1152            this.savePreferences();
1153        }
1154
1155        /**
1156         * Cambiar volumen global
1157         * @param {number} volume - Valor entre 0.0 y 1.0
1158         */
1159        setVolume(volume) {
1160            this.config.volume = Math.max(0, Math.min(1, volume));
1161            
1162            // Actualizar volumen en todos los audios cacheados
1163            this.audioCache.forEach(audio => {
1164                audio.volume = this.config.volume;
1165            });
1166            
1167            this.savePreferences();
1168        }
1169
1170        /**
1171         * Guardar preferencias en localStorage
1172         */
1173        savePreferences() {
1174            localStorage.setItem('cashflow_sound_prefs', JSON.stringify({
1175                enabled: this.config.enabled,
1176                volume: this.config.volume
1177            }));
1178        }
1179
1180        /**
1181         * Toggle sonidos on/off
1182         */
1183        toggle() {
1184            this.setEnabled(!this.config.enabled);
1185            return this.config.enabled;
1186        }
1187
1188        /**
1189         * Obtener estado actual
1190         */
1191        getStatus() {
1192            return {
1193                enabled: this.config.enabled,
1194                volume: this.config.volume,
1195                soundsLoaded: this.audioCache.size
1196            };
1197        }
1198    }
1199
1200    // =========================================================================
1201    // FUNCIONES HELPER GLOBALES
1202    // =========================================================================
1203    
1204    /**
1205     * Función helper para reproducir sonido de éxito
1206     * Uso: playSuccessSound()
1207     */
1208    window.playSuccessSound = function(options) {
1209        if (window.CashFlowAudio) {
1210            window.CashFlowAudio.success(options);
1211        }
1212    };
1213
1214    /**
1215     * Función helper para reproducir sonido de chat
1216     * Uso: playChatSound()
1217     */
1218    window.playChatSound = function(options) {
1219        if (window.CashFlowAudio) {
1220            window.CashFlowAudio.chat(options);
1221        }
1222    };
1223
1224    /**
1225     * Función helper para reproducir sonido pop
1226     * Uso: playPopSound()
1227     */
1228    window.playPopSound = function(options) {
1229        if (window.CashFlowAudio) {
1230            window.CashFlowAudio.pop(options);
1231        }
1232    };
1233
1234    /**
1235     * Disparar evento de sonido personalizado
1236     * Uso: triggerSound('success') o triggerSound('chat', { volume: 0.8 })
1237     */
1238    window.triggerSound = function(soundKey, options = {}) {
1239        document.dispatchEvent(new CustomEvent('cashflow:sound', {
1240            detail: { sound: soundKey, options }
1241        }));
1242    };
1243
1244    // =========================================================================
1245    // INICIALIZACIÓN AUTOMÁTICA
1246    // =========================================================================
1247    
1248    // Crear instancia global cuando el DOM esté listo
1249    if (document.readyState === 'loading') {
1250        document.addEventListener('DOMContentLoaded', () => {
1251            window.CashFlowAudio = new CashFlowSounds();
1252        });
1253    } else {
1254        window.CashFlowAudio = new CashFlowSounds();
1255    }
1256
1257    // Exponer clase para uso avanzado
1258    window.CashFlowSounds = CashFlowSounds;
1259
1260})(window);
1261  </script>
1261
1262</body>
1263
1264</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.