PageSourceSearch

https://rapilog.com.ar/Content/landingsMarketing/rapilog/script.js?v=1

js rapilog.com.ar collected 2026-09-24 13:54:35 UTC 37,338 bytes, 913 lines download raw bytes

1/* ═══════════════════════════════════════════════════════════════════
2   RAPILOG · 2026 Home
3   ═══════════════════════════════════════════════════════════════════
4
5   CAMBIOS respecto de la versión anterior (solo bloque 6):
6
7   · Se eliminó el handler del 'submit'. Ese e.preventDefault() cancelaba
8     el envío del formulario, y con el envío cancelado la araña de
9     Clientify nunca capturaba el lead. Ahora el formulario se envía de
10     forma nativa y la validación la hace el navegador con los required.
11
12   · Los campos ocultos de UTM se completan apenas se ejecuta el script,
13     antes de que el usuario pueda tocar nada, y se guardan en
14     sessionStorage para que sobrevivan a la navegación del CTA del hero.
15
16   Requiere en el HTML:
17   · <form ... action="/exito" method="get">  ← sin novalidate
18   · los 4 <input type="hidden"> de UTM DENTRO del <form>
19   · el script de la araña de Clientify antes de </body>
20   ═══════════════════════════════════════════════════════════════════ */
21(function () {
22  'use strict';
23
24  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
25
26  /* ───────────── 0 · ATRIBUCIÓN UTM
27     Va primero a propósito: se resuelve antes que cualquier animación,
28     apenas el navegador ejecuta este archivo. Como el <script> está al
29     final del <body>, el formulario ya existe en el DOM en este punto.
30     (Si algún día se mueve el script al <head>, esto tiene que pasar a
31     un listener de DOMContentLoaded o los campos no van a existir.)
32
33     Por qué sessionStorage y no solo leer la URL: la página tiene un
34     <base href> y eso hace que los links con # naveguen de verdad en
35     lugar de hacer scroll. En esa navegación se pierde el query string.
36     Sin persistencia, el usuario que llega desde un anuncio y hace click
37     en "Quiero mejorar mi logística →" del hero llegaría al formulario
38     sin ninguna UTM. Guardándolas en la primera visita de la sesión, el
39     dato sobrevive a ese salto y a cualquier recarga posterior. */
40
41  const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_adsetid'];
42  const UTM_STORE = 'rapilog_utms';
43
44  function resolverUTMs() {
45    const qs = new URLSearchParams(window.location.search);
46    const enURL = {};
47
48    UTM_KEYS.forEach(k => {
49      const v = qs.get(k);
50      if (v && v.trim() !== '') enURL[k] = v.trim();
51    });
52
53    try {
54      // Si esta visita trae UTMs en la URL, mandan ellas y se guardan
55      // para el resto de la sesión.
56      if (Object.keys(enURL).length) {
57        sessionStorage.setItem(UTM_STORE, JSON.stringify(enURL));
58        return enURL;
59      }
60      // Si no, se recupera lo capturado al entrar.
61      const guardado = sessionStorage.getItem(UTM_STORE);
62      return guardado ? JSON.parse(guardado) : {};
63    } catch (e) {
64      // Modo incógnito o storage bloqueado: seguimos con lo de la URL.
65      return enURL;
66    }
67  }
68
69  (function completarUTMs() {
70    const utms = resolverUTMs();
71    UTM_KEYS.forEach(function (key) {
72      const field = document.getElementById(key);
73      if (!field) return;
74      // Si no vino, queda vacío. Nunca el string 'null': Clientify lo
75      // guardaría literal como texto en el campo del contacto.
76      field.value = utms[key] || '';
77    });
78  })();
79
80  /* ───────────── 1 · TOP BAR
81     Se esconde al bajar. Reaparece con cualquier scroll hacia arriba,
82     sin necesidad de volver al tope de la página. */
83  const topbar = document.getElementById('topbar');
84  const nav = document.getElementById('topbar-nav');
85  const burger = document.getElementById('burger');
86
87  let lastY = window.scrollY;
88  let ticking = false;
89
90  const SHOW_THRESHOLD = 6;   // px de scroll hacia arriba que la revelan
91  const HIDE_AFTER = 80;      // no se esconde antes de este punto
92
93  function onScroll() {
94    const y = window.scrollY;
95    const delta = y - lastY;
96
97    topbar.classList.toggle('is-stuck', y > 4);
98
99    if (nav.classList.contains('is-open')) { lastY = y; ticking = false; return; }
100
101    if (delta > 0 && y > HIDE_AFTER) {
102      topbar.classList.add('is-hidden');       // bajando
103    } else if (delta < -SHOW_THRESHOLD || y <= HIDE_AFTER) {
104      topbar.classList.remove('is-hidden');    // subiendo
105    }
106
107    lastY = y;
108    ticking = false;
109  }
110
111  window.addEventListener('scroll', () => {
112    if (!ticking) {
113      window.requestAnimationFrame(onScroll);
114      ticking = true;
115    }
116  }, { passive: true });
117
118  /* ───────────── 2 · MENÚ MOBILE ───────────── */
119  burger.addEventListener('click', () => {
120    const open = nav.classList.toggle('is-open');
121    burger.setAttribute('aria-expanded', String(open));
122    burger.setAttribute('aria-label', open ? 'Cerrar menú' : 'Abrir menú');
123  });
124
125  nav.querySelectorAll('a').forEach(a => {
126    a.addEventListener('click', () => {
127      nav.classList.remove('is-open');
128      burger.setAttribute('aria-expanded', 'false');
129    });
130  });
131
132  /* ───────────── 2b · LINKS INTERNOS
133     Los links con # (el logo, el menú, los CTA del hero, "Conocer
134     solución", "Consultar envíos nacionales") no deberían tocar la URL.
135
136     Sin esto no solo agregan el hash: como la página tiene un <base
137     href>, el navegador los resuelve contra esa ruta y termina haciendo
138     una NAVEGACIÓN real a /Content/landingsMarketing/rapilog/#empeza-hoy.
139     Se recarga todo y se pierde el estado de la página.
140
141     Los interceptamos y hacemos el scroll a mano, descontando el alto de
142     la topbar para que la sección no quede tapada. El historial no se
143     toca: la URL queda igual que antes del click. */
144  document.addEventListener('click', (e) => {
145    const a = e.target.closest('a');
146    if (!a) return;
147
148    // getAttribute y no a.href: el <base> nos devolvería la URL absoluta.
149    const href = a.getAttribute('href');
150    if (!href || href.charAt(0) !== '#' || href === '#') return;
151
152    const destino = document.getElementById(href.slice(1));
153    if (!destino) return;
154
155    e.preventDefault();
156
157    const offset = topbar ? topbar.offsetHeight : 0;
158    const top = destino.getBoundingClientRect().top + window.scrollY - offset;
159
160    window.scrollTo({ top: top < 0 ? 0 : top, behavior: reduced ? 'auto' : 'smooth' });
161  });
162
163
164  /* ───────────── 3 · CARRUSEL DE MARCAS
165     Duplico los slots para que el loop no tenga costura. */
166  const track = document.getElementById('marquee-track');
167  if (track && !reduced) {
168    track.innerHTML += track.innerHTML;
169    track.querySelectorAll(':scope > *').forEach((el, i) => {
170      if (i >= track.children.length / 2) el.setAttribute('aria-hidden', 'true');
171    });
172  }
173
174  /* ───────────── 4 · FAQ
175     <details> nativo. Solo agrego: cerrar los hermanos al abrir uno. */
176  const faqItems = document.querySelectorAll('.faq__item');
177  faqItems.forEach(item => {
178    item.addEventListener('toggle', () => {
179      if (!item.open) return;
180      faqItems.forEach(other => { if (other !== item) other.open = false; });
181    });
182  });
183
184  /* ───────────── 5 · FADE IN UP
185     Stagger de 80ms entre hermanos de una misma grilla. */
186  const anims = document.querySelectorAll('[data-anim]');
187
188  if (reduced || !('IntersectionObserver' in window)) {
189    anims.forEach(el => el.classList.add('is-in'));
190  } else {
191    const io = new IntersectionObserver((entries) => {
192      entries.forEach(entry => {
193        if (!entry.isIntersecting) return;
194
195        const el = entry.target;
196        const siblings = Array.from(el.parentElement.children).filter(n => n.hasAttribute('data-anim'));
197        const index = siblings.indexOf(el);
198        el.style.setProperty('--d', (index * 80) + 'ms');
199
200        el.classList.add('is-in');
201        io.unobserve(el);
202      });
203    }, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
204
205    anims.forEach(el => io.observe(el));
206  }
207
208  /* ───────────── 6 · FORMULARIO · ENVÍO SIN RECARGA
209     El problema a resolver: la araña de Clientify solo captura si el
210     formulario se envía de verdad, pero un envío normal recarga la
211     página y deja todos los datos del usuario en la URL.
212
213     La salida es un destino ciego: el formulario apunta a un iframe
214     oculto. El envío es 100% nativo —no hay preventDefault en ningún
215     lado, así que la araña lo escucha igual— pero quien navega es el
216     iframe, no la página. Resultado: la URL no cambia, no aparecen los
217     datos, no hay recarga y no se pierde el scroll.
218
219     Un preventDefault() acá, o un form.submit() por código (que ni
220     siquiera dispara el evento), volverían a romper la captura.
221
222     La atribución UTM se resuelve en el bloque 0, arriba de todo. */
223
224  const form = document.getElementById('lead-form');
225
226  if (form) {
227    /* El <form> trae el atributo novalidate, heredado de cuando la
228       validación la hacía este script a mano. Ese código ya no existe,
229       así que sin esto el formulario se enviaría vacío y Clientify
230       recibiría contactos sin email. Lo sacamos desde acá para no tener
231       que tocar el HTML: ahora valida el navegador con los required. */
232    form.noValidate = false;
233
234    /* ─── VALIDACIÓN DE FORMATO
235       Todo se apoya en la validación nativa: cuando un campo no cumple,
236       el navegador bloquea el envío y el evento 'submit' ni se dispara.
237       Por eso acá no hay un solo preventDefault y la araña sigue
238       capturando exactamente igual.
239
240       Los mensajes se escriben con setCustomValidity para que el globo
241       del navegador diga algo útil en castellano en vez del genérico
242       "Completa este campo". */
243
244    const fNombre = form.querySelector('#f-nombre');
245    const fEmail = form.querySelector('#f-email');
246    const fWsp = form.querySelector('#f-wsp');
247
248    // Teclado correcto en celular y autocompletado del navegador.
249    if (fNombre) { fNombre.autocomplete = 'name'; fNombre.setAttribute('autocapitalize', 'words'); }
250    if (fEmail) { fEmail.autocomplete = 'email'; fEmail.setAttribute('inputmode', 'email'); }
251    if (fWsp) { fWsp.autocomplete = 'tel'; fWsp.setAttribute('inputmode', 'tel'); }
252
253    /* Nombre y apellido: al menos dos palabras de 2+ letras. Acepta
254       tildes y ñ, apellidos con guion (García-López) y con apóstrofe
255       (D'Angelo, O'Brien), pero rechaza iniciales sueltas tipo "J G". */
256    const RE_NOMBRE = /^(?:[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]{2,}
256(?:['-][A-Za-zÁÉÍÓÚÜÑáéíóúüñ]{2,})*|[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]'[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]{2,})(?: (?:[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]{2,}(?:['-][A-Za-zÁÉÍÓÚÜÑáéíóúüñ]{2,})*|[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]'[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]{2,}))+$/;
257
258    // Email: exige dominio con punto y extensión. type="email" por sí
259    // solo da por válido "juan@mitienda", que no sirve para nada.
260    const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
261
262    /* Deja el teléfono en los 10 dígitos nacionales, sacando lo que la
263       gente suele escribir de más: el 54, el 9, el 0 inicial y el 15
264       después de la característica. */
265    function wspNacional(valor) {
266      let d = (valor || '').replace(/\D/g, '');
267      if (d.indexOf('54') === 0) d = d.slice(2);
268      if (d.indexOf('9') === 0) d = d.slice(1);
269      if (d.indexOf('0') === 0) d = d.slice(1);
270      const area = d.indexOf('11') === 0 ? 2 : 3;
271      if (d.slice(area, area + 2) === '15') d = d.slice(0, area) + d.slice(area + 2);
272      return d.slice(0, 10);
273    }
274
275    /* Devuelve el número completo, como el placeholder:
276       +54 9 11 4567-8900. El +54 9 lo pone siempre el código, no el
277       usuario: así el valor que se envía nunca puede quedar sin él. */
278    function wspFormateado(valor) {
279      const d = wspNacional(valor);
280      if (!d) return '';
281      const area = d.indexOf('11') === 0 ? 2 : 3;
282      const resto = d.slice(area);
283      let out = '+54 9 ' + d.slice(0, area);
284      if (resto) {
285        out += ' ' + (resto.length > 4 ? resto.slice(0, -4) + '-' + resto.slice(-4) : resto);
286      }
287      return out;
288    }
289
290    function mensajeDeError(f) {
291      const v = (f.value || '').trim();
292
293      if (f === fNombre) {
294        if (!v) return 'Escribí tu nombre y apellido.';
295        return RE_NOMBRE.test(v) ? '' : 'Escribí nombre y apellido, por ejemplo: Juan García.';
296      }
297      if (f === fEmail) {
298        if (!v) return 'Escribí tu email.';
299        return RE_EMAIL.test(v) ? '' : 'Revisá el email, por ejemplo: [email protected]';
300      }
301      if (f === fWsp) {
302        const d = wspNacional(v);
303        if (!d) return 'Escribí tu WhatsApp.';
304        return d.length === 10 ? '' : 'Falta la característica o algún dígito. Ejemplo: +54 9 11 4567-8900';
305      }
306      if (f.tagName === 'SELECT') {
307        return v ? '' : 'Elegí una opción.';
308      }
309      return '';
310    }
311
312    /* ─── WhatsApp con +54 9 fijo
313       Se mantiene UN SOLO campo, el de siempre, con su name="whatsapp"
314       intacto: es el que la araña de Clientify captura y no conviene
315       tocarlo. El +54 9 forma parte del valor y lo escribe el código,
316       no el usuario.
317
318       A la izquierda queda solo la bandera, como referencia visual. Va
319       como SVG y no como emoji 🇦🇷 porque en Windows los emojis de
320       bandera se ven como dos letras sueltas.
321
322       El valor se normaliza en cada tecla y otra vez al salir del campo.
323       Esa segunda pasada es la que garantiza que el +54 9 esté siempre:
324       aunque alguien borre el prefijo y deje el cursor en el medio, al
325       pasar al campo siguiente se reconstruye solo. */
326
327    const BANDERA_AR =
328      '<svg width="22" height="15" viewBox="0 0 22 15" aria-hidden="true" ' +
329      'style="border-radius:3px;display:block;flex:none">' +
330      '<rect width="22" height="15" fill="#74ACDF"/>' +
331      '<rect y="5" width="22" height="5" fill="#ffffff"/>' +
332      '<circle cx="11" cy="7.5" r="2" fill="#F6B40E"/>' +
333      '</svg>';
334
335    /* El bloque de la bandera copia los estilos ya calculados del input
336       (fondo, borde, radio) en vez de redefinirlos a mano. Así queda
337       igual aunque mañana cambie el CSS del formulario. */
338    function estilarPrefijo(chip, input) {
339      const cs = getComputedStyle(input);
340      chip.style.background = cs.backgroundColor;
341      chip.style.border = cs.borderTopWidth + ' ' + cs.borderTopStyle + ' ' + cs.borderTopColor;
342      chip.style.borderRadius = cs.borderRadius;
343      chip.style.padding = '0 ' + cs.paddingLeft;
344    }
345
346    function inyectarEstilosWsp() {
347      if (document.getElementById('wsp-styles')) return;
348      const st = document.createElement('style');
349      st.id = 'wsp-styles';
350      st.textContent = `
351        .wsp-combo { display: flex; align-items: stretch; gap: 8px; }
352        .wsp-combo > input { flex: 1 1 auto; min-width: 0; }
353        .wsp-combo__pais {
354          display: inline-flex;
355          align-items: center;
356          justify-content: center;
357          flex: none;
358          user-select: none;
359        }
360      `;
361      document.head.appendChild(st);
362    }
363
364    if (fWsp) {
365      inyectarEstilosWsp();
366
367      fWsp.placeholder = '+54 9 11 4567-8900';
368
369      const combo = document.createElement('div');
370      combo.className = 'wsp-combo';
371
372      const chip = document.createElement('span');
373      chip.className = 'wsp-combo__pais';
374      chip.innerHTML = BANDERA_AR;
375
376      fWsp.parentNode.insertBefore(combo, fWsp);
377      combo.appendChild(chip);
378      combo.appendChild(fWsp);
379
380      estilarPrefijo(chip, fWsp);
381
382      /* Autoformato mientras escribe, solo si el cursor está al final:
383         si está corrigiendo en el medio, reescribir el valor le movería
384         el cursor y sería insoportable. */
385      fWsp.addEventListener('input', () => {
386        if (fWsp.selectionStart !== fWsp.value.length) return;
387        const formateado = wspFormateado(fWsp.value);
388        if (formateado !== fWsp.value) fWsp.value = formateado;
389      });
390
391      // Al salir del campo se normaliza sí o sí, esté donde esté el cursor.
392      fWsp.addEventListener('blur', () => {
393        const formateado = wspFormateado(fWsp.value);
394        if (formateado !== fWsp.value) fWsp.value = formateado;
395      });
396
397      /* Y también si mandan el formulario con Enter desde este campo: ahí
398         no hay blur previo, y keydown corre antes del envío implícito. */
399      fWsp.addEventListener('keydown', (e) => {
400        if (e.key !== 'Enter') return;
401        const formateado = wspFormateado(fWsp.value);
402        if (formateado !== fWsp.value) fWsp.value = formateado;
403      });
404    }
405
406    const campos = Array.from(form.querySelectorAll('input:not([type="hidden"]), select'));
407    campos.forEach(f => {
408      const revisar = () => f.setCustomValidity(mensajeDeError(f));
409      ['input', 'change', 'blur', 'invalid'].forEach(ev => f.addEventListener(ev, revisar));
410      revisar();   // estado inicial: arrancan inválidos por estar vacíos
411    });
412
413
414    const SINK = 'rapilog-form-sink';
415
416    let sink = document.getElementsByName(SINK)[0];
417    if (!sink) {
418      sink = document.createElement('iframe');
419      sink.name = SINK;
420      sink.setAttribute('aria-hidden', 'true');
421      sink.setAttribute('tabindex', '-1');
422      sink.style.cssText = 'position:absolute;left:-9999px;width:0;height:0;border:0;';
423      document.body.appendChild(sink);
424    }
425    form.target = SINK;
426
427    /* El iframe carga lo que devuelva el action. Lo apuntamos a la hoja
428       de estilos —un archivo chico, propio y que siempre responde 200—
429       en vez de a la landing entera: si cargara la landing, tendríamos
430       un pageview fantasma en GTM y en el píxel de Clientify en cada
431       envío. Se toma la URL ya resuelta del <link> para no depender de
432       las rutas relativas, que en esta página las altera el <base>. */
433    const css = document.querySelector('link[rel="stylesheet"][href*="styles.css"]');
434    if (css) form.action = css.href;
435
436    form.addEventListener('submit', () => {
437      /* Sin preventDefault a propósito. El cambio de interfaz se difiere
438         un tick para no tocar el formulario mientras el navegador
439         todavía está serializando los campos para enviarlos. */
440      setTimeout(mostrarExito, 0);
441    });
442
443    /* Reemplaza el formulario por una tarjeta de confirmación.
444       Reusa la clase .form para heredar el fondo blanco, el radio y el
445       padding que ya tiene el formulario, así no hay que duplicar nada
446       del CSS. El alto se fija al que tenía el formulario para que la
447       sección no pegue un salto al achicarse. */
448    function mostrarExito() {
449      if (document.querySelector('.form--exito')) return;   // doble envío
450
451      /* Evento propio para GTM. Va acá y no en un trigger de "Envío de
452         formulario" porque el listener nativo de GTM es caprichoso con
453         formularios como este: valida nativo, se envía contra un iframe
454         y la página no recarga. Además dispara en el momento en que el
455         usuario ve la confirmación, no en el del click.
456         En GTM: activador de Evento personalizado → form_submit_rapilog. */
457      window.dataLayer = window.dataLayer || [];
458      window.dataLayer.push({ event: 'form_submit_rapilog', form_id: 'lead-form' });
459
460      inyectarEstilosExito();
461
462      const card = document.createElement('div');
463      card.className = 'form form--exito';
464      card.setAttribute('role', 'status');
465      card.style.minHeight = form.offsetHeight + 'px';
466      card.innerHTML =
467        '<span class="exito__check" aria-hidden="true">✓</span>' +
468        '<h3 class="form__title">¡Listo!</h3>' +
469        '<p class="form__sub">Te contactamos en menos de 48hs hábiles.</p>';
470
471      form.parentNode.insertBefore(card, form);
472      // display inline: le gana a cualquier regla del CSS salvo !important.
473      form.style.display = 'none';
474    }
475
476    /* Los estilos van acá y no en styles.css para que todo el cambio
477       viva en un solo archivo. Se inyectan una sola vez, recién cuando
478       hacen falta. */
479    function inyectarEstilosExito() {
480      if (document.getElementById('exito-styles')) return;
481
482      const st = document.createElement('style');
483      st.id = 'exito-styles';
484      st.textContent = `
485        .form--exito {
486          display: flex;
487          flex-direction: column;
488          align-items: center;
489          justify-content: center;
490          text-align: center;
491          gap: 18px;
492          animation: exitoIn .5s cubic-bezier(.16, 1, .3, 1) both;
493        }
494        .form--exito .form__title { margin: 0; }
495        .form--exito .form__sub { margin: 0; max-width: 30ch; }
496        .exito__check {
497          display: flex;
498          align-items: center;
499          justify-content: center;
500          width: 72px;
501          height: 72px;
502          border-radius: 50%;
503          background: #00A3CF;
504          color: #fff;
505          font-size: 36px;
506          line-height: 1;
507          box-shadow: 0 0 0 10px rgba(0, 163, 207, .14);
508          animation: exitoCheck .5s .12s cubic-bezier(.34, 1.56, .64, 1) both;
509        }
510        @keyframes exitoIn {
511          from { opacity: 0; transform: scale(.88); }
512          to   { opacity: 1; transform: scale(1); }
513        }
514        @keyframes exitoCheck {
515          from { opacity: 0; transform: scale(.3); }
516          to   { opacity: 1; transform: scale(1); }
517        }
518        @media (prefers-reduced-motion: reduce) {
519          .form--exito, .exito__check { animation: none; }
520        }
521      `;
522      document.head.appendChild(st);
523    }
524  }
525
526  /* ───────────── 7 · RELOJ DE COLECTAS (scroll-driven)
527     El scroll dentro de la sección mueve las agujas de las 11 a las 17hs.
528     Cada colecta se activa al pasar por su marca. Solo ≥1025px. */
529  const scroller = document.getElementById('horarios-scroll');
530  const clock = document.getElementById('clock');
531
532  if (scroller && clock) {
533    const handHour = document.getElementById('hand-hour');
534    const handMin = document.getElementById('hand-minute');
535    const arc = document.getElementById('clock-arc');
536    const marks = clock.querySelectorAll('.mark');
537    const labels = clock.querySelectorAll('[data-label]');
538    const steps = document.querySelectorAll('.colecta');
539
540    // Ticks: 60 minutos, cada 5 más marcado.
541    const ticksG = document.getElementById('clock-ticks');
542    let ticks = '';
543    for (let i = 0; i < 60; i++) {
544      const major = i % 5 === 0;
545      const len = major ? 12 : 6;
546      ticks += `<line class="tick${major ? ' tick--major' : ''}" x1="190" y1="${190 - 170}" x2="190" y2="${190 - 170 + len}" stroke-width="${major ? 2 : 1}" transform="rotate(${i * 6} 190 190)"/>`;
547    }
548    ticksG.innerHTML = ticks;
549
550    // Circunferencia del arco de progreso (r = 170)
551    const C = 2 * Math.PI * 170;
552    arc.style.strokeDasharray = C;
553    arc.style.strokeDashoffset = C;
554
555    const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
556
557    let clockRaf = false;
558    let currentStep = -1;
559
560    function drawClock() {
561      const desktop = window.innerWidth > 1024;
562
563      if (!desktop) {
564        // En tablet/mobile el reloj no existe: las 3 cards están todas visibles.
565        steps.forEach(s => s.classList.remove('is-active'));
566        clockRaf = false;
567        return;
568      }
569
570      const r = scroller.getBoundingClientRect();
571      const travel = r.height - window.innerHeight;
572      const p = clamp(-r.top / travel, 0, 1);
573
574      // Reloj simbólico, no cronométrico:
575      // · la aguja de la hora va de marca a marca (11 → 14 → 17 = 180° totales)
576      // · el minutero da UNA vuelta completa por cada tramo entre colectas (2 tramos)
577      // Resultado: en cada colecta el reloj marca exacto, con el minutero en las 12.
578      const hourAngle = 330 + p * 180;
579      const minuteAngle = p * 720;
580
581      handHour.setAttribute('transform', `rotate(${hourAngle} 190 190)`);
582      handMin.setAttribute('transform', `rotate(${minuteAngle} 190 190)`);
583
584      // Arco: mismo barrido de 180°, arrancando en las 11 (rotate 240 va en el SVG)
585      arc.style.strokeDashoffset = C - (C * p * 0.5);
586
587      // Colecta activa: la marca más cercana
588      const step = p < 0.25 ? 0 : (p < 0.75 ? 1 : 2);
589
590      if (step !== currentStep) {
591        currentStep = step;
592        steps.forEach((s, i) => s.classList.toggle('is-active', i === step));
593        marks.forEach((m, i) => m.classList.toggle('is-on', i <= step));
594        labels.forEach((l, i) => l.classList.toggle('is-on', i <= step));
595      }
596
597      clockRaf = false;
598    }
599
600    function requestClock() {
601      if (!clockRaf) { window.requestAnimationFrame(drawClock); clockRaf = true; }
602    }
603
604    window.addEventListener('scroll', requestClock, { passive: true });
605    window.addEventListener('resize', () => { currentStep = -1; requestClock(); });
606    drawClock();
607  }
608
609  /* ───────────── 8 · NÚMEROS QUE CUENTAN
610     99 cuenta entero. 2M y 5 cuentan con un decimal mientras corren y
611     recién al final se asientan en el entero: con números tan chicos,
612     sin decimal el conteo no se llega a percibir. */
613  const counters = document.querySelectorAll('[data-count]');
614  const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);
615
616  function runCount(el) {
617    const target = parseFloat(el.dataset.count);
618    const dec = parseInt(el.dataset.decimals || '0', 10);
619    const DUR = 1800;
620    const t0 = performance.now();
621
622    function tick(now) {
623      const t = clamp01((now - t0) / DUR);
624      const eased = 1 - Math.pow(1 - t, 4);   // easeOutQuart
625      const v = target * eased;
626
627      el.textContent = t < 1
628        ? v.toFixed(dec).replace('.', ',')     // 0,0 → 1,7 → 2,0
629        : String(target);                      // se asienta en 2
630
631      if (t < 1) requestAnimationFrame(tick);
632    }
633    requestAnimationFrame(tick);
634  }
635
636  if (reduced || !('IntersectionObserver' in window)) {
637    counters.forEach(el => { el.textContent = el.dataset.count; });
638  } else {
639    const countIO = new IntersectionObserver((entries) => {
640      entries.forEach(e => {
641        if (!e.isIntersecting) return;
642        const el = e.target;
643        countIO.unobserve(el);
644        // Espero a que la card termine de entrar, si no el conteo pasa invisible.
645        setTimeout(() => runCount(el), 350);
646      });
647    }, { threshold: 1, rootMargin: '0px 0px -12% 0px' });
648
649    counters.forEach(el => countIO.observe(el));
650  }
651
652  /* ───────────── 9 · CARDS "¿CALIFICÁS?"
653     Revelado escalonado + flotación suave de la imagen con el scroll. */
654  const reveals = document.querySelectorAll('[data-reveal]');
655
656  if (reduced || !('IntersectionObserver' in window)) {
657    reveals.forEach(el => el.classList.add('is-in'));
658  } else {
659    const revealIO = new IntersectionObserver((entries) => {
660      entries.forEach(e => {
661        if (!e.isIntersecting) return;
662        const el = e.target;
663        const i = Array.from(el.parentElement.children).indexOf(el);
664        el.style.setProperty('--d', (i * 140) + 'ms');
665        el.classList.add('is-in');
666        revealIO.unobserve(el);
667      });
668    }
668, { threshold: 0.25, rootMargin: '0px 0px -10% 0px' });
669
670    reveals.forEach(el => revealIO.observe(el));
671  }
672
673  // Flotación: las imágenes se mueven un poco menos rápido que la página.
674  const floats = document.querySelectorAll('[data-float]');
675  if (floats.length && !reduced) {
676    let floatRaf = false;
677    function drawFloat() {
678      floats.forEach(img => {
679        const r = img.getBoundingClientRect();
680        const center = r.top + r.height / 2;
681        const off = (center - window.innerHeight / 2) / window.innerHeight;  // -1 … 1
682        img.style.setProperty('--float', (off * -14).toFixed(1) + 'px');
683      });
684      floatRaf = false;
685    }
686    window.addEventListener('scroll', () => {
687      if (!floatRaf) { requestAnimationFrame(drawFloat); floatRaf = true; }
688    }, { passive: true });
689    drawFloat();
690  }
691
692  /* ───────────── 10 · VECTORES QUE SE DIBUJAN
693     Cualquier .diag[data-draw] se traza al entrar en pantalla.
694     Hoy: la diagonal de Soporte y la ruta de Empezá hoy. */
695  const diags = document.querySelectorAll('.diag[data-draw]');
696
697  if (reduced || !('IntersectionObserver' in window)) {
698    diags.forEach(d => d.classList.add('is-drawn'));
699  } else {
700    const diagIO = new IntersectionObserver((entries) => {
701      entries.forEach(e => {
702        if (!e.isIntersecting) return;
703        e.target.classList.add('is-drawn');
704        diagIO.unobserve(e.target);
705      });
706    }, { threshold: 0.08 });
707    diags.forEach(d => diagIO.observe(d));
708  }
709
710  /* ───────────── 11 · INTEGRACIONES · convergencia de logos
711     Los logos arrancan dispersos y grandes, y con el scroll se achican y
712     vuelven a su recuadro. Se anima solo transform (barato) y el alpha
713     del fondo de la card vía --box. */
714  const intScroll = document.getElementById('integraciones-scroll');
715  const intGrid = document.getElementById('integraciones-grid');
716  const intLead = document.getElementById('integraciones-lead');
717
718  if (intScroll && intGrid) {
719    const cards = Array.from(intGrid.children);
720    const logos = cards.map(c => c.querySelector('img'));
721
722    // Puntos de dispersión autorales (fracción del escenario), no random:
723    // rodean al título sin taparlo y cubren la pantalla completa.
724    const SCATTER = [
725      [0.09, 0.16], [0.31, 0.05], [0.69, 0.06], [0.91, 0.19],
726      [0.05, 0.62], [0.34, 0.90], [0.67, 0.87], [0.95, 0.60]
727    ];
728    const ROT = [-8, 6, -5, 9, 7, -6, 4, -9];
729    const SCALE_START = 2.3;
730
731    let offsets = [];
732
733    function measure() {
734      const stage = intScroll.querySelector('.integraciones__sticky').getBoundingClientRect();
735      offsets = cards.map((card, i) => {
736        const r = card.getBoundingClientRect();
737        const cx = r.left + r.width / 2;
738        const cy = r.top + r.height / 2;
739        const [fx, fy] = SCATTER[i % SCATTER.length];
740        return {
741          dx: (stage.left + stage.width * fx) - cx,
742          dy: (stage.top + stage.height * fy) - cy,
743          rot: ROT[i % ROT.length]
744        };
745      });
746    }
747
748    let intRaf = false;
749
750    function drawInt() {
751      const desktop = window.innerWidth > 1024;
752
753      if (!desktop) {
754        logos.forEach(l => { l.style.transform = ''; });
755        cards.forEach(c => c.style.removeProperty('--box'));
756        intRaf = false;
757        return;
758      }
759
760      if (!offsets.length) measure();
761
762      const r = intScroll.getBoundingClientRect();
763      const travel = r.height - window.innerHeight;
764      const p = Math.min(Math.max(-r.top / travel, 0), 1);
765
766      // easeInOutCubic: arranca lento, converge con decisión, aterriza suave
767      const e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
768      const away = 1 - e;   // 1 = disperso · 0 = en su lugar
769
770      logos.forEach((logo, i) => {
771        const o = offsets[i];
772        const scale = 1 + (SCALE_START - 1) * away;
773        logo.style.transform =
774          `translate(${o.dx * away}px, ${o.dy * away}px) scale(${scale}) rotate(${o.rot * away}deg)`;
775      });
776
777      // Los recuadros aparecen sobre el final, escalonados de izq. a der.
778      cards.forEach((card, i) => {
779        const stagger = i * 0.03;
780        const b = Math.min(Math.max((e - 0.55 - stagger) / 0.35, 0), 1);
781        card.style.setProperty('--box', b.toFixed(3));
782      });
783
784      if (intLead) intLead.style.setProperty('--lead', Math.min(Math.max((e - 0.7) / 0.3, 0), 1).toFixed(3));
785
786      intRaf = false;
787    }
788
789    function requestInt() {
790      if (!intRaf) { window.requestAnimationFrame(drawInt); intRaf = true; }
791    }
792
793    if (reduced) {
794      cards.forEach(c => c.style.setProperty('--box', '1'));
795    } else {
796      window.addEventListener('scroll', requestInt, { passive: true });
797      window.addEventListener('resize', () => { offsets = []; requestInt(); });
798      window.addEventListener('load', () => { offsets = []; drawInt(); });
799      drawInt();
800    }
801  }
802
803  /* ───────────── 12 · RUTAS FLEX vs TURBO
804     Las dos comparten un ciclo de 4s y arrancan EN EL MISMO INSTANTE.
805     Flex tarda 2,6s en llegar; Turbo, 0,8s. Turbo llega y espera 3,2s
806     mirando cómo Flex todavía está en camino. Ese silencio es el pitch. */
807  const routes = Array.from(document.querySelectorAll('[data-route]'));
808
809  if (routes.length) {
810    const CYCLE = 4000;
811
812    const rs = routes.map(svg => {
813      const trail = svg.querySelector('.route__trail');
814      const dot = svg.querySelector('.route__dot');
815      const halo = svg.querySelector('.route__halo');
816      const stops = Array.from(svg.querySelectorAll('.route__stop'));
817      const len = trail.getTotalLength();
818
819      // ¿en qué punto del recorrido queda cada parada?
820      const stopAt = stops.map(st => {
821        const cx = parseFloat(st.getAttribute('cx'));
822        const cy = parseFloat(st.getAttribute('cy'));
823        let best = 0, bestD = Infinity;
824        for (let i = 0; i <= 200; i++) {
825          const pt = trail.getPointAtLength((i / 200) * len);
826          const d = (pt.x - cx) ** 2 + (pt.y - cy) ** 2;
827          if (d < bestD) { bestD = d; best = i / 200; }
828        }
829        return best;
830      });
831
832      return { svg, trail, dot, halo, stops, stopAt, len, travel: parseInt(svg.dataset.travel, 10) };
833    });
834
835    function place(r, prog) {
836      const pt = r.trail.getPointAtLength(prog * r.len);
837      r.dot.setAttribute('cx', pt.x);
838      r.dot.setAttribute('cy', pt.y);
839      r.halo.setAttribute('cx', pt.x);
840      r.halo.setAttribute('cy', pt.y);
841      r.trail.style.strokeDashoffset = 1 - prog;
842      r.stops.forEach((st, i) => st.classList.toggle('is-passed', prog >= r.stopAt[i]));
843      r.svg.classList.toggle('is-arrived', prog >= 1);
844    }
845
846    if (reduced) {
847      rs.forEach(r => place(r, 1));
848    } else {
849      let running = false;
850      let t0 = 0;
851
852      function frame(now) {
853        if (!running) return;
854        const t = (now - t0) % CYCLE;
855
856        rs.forEach(r => {
857          const raw = Math.min(t / r.travel, 1);
858          const eased = 1 - Math.pow(1 - raw, 3);   // arranca fuerte, frena al llegar
859          place(r, eased);
860        });
861
862        requestAnimationFrame(frame);
863      }
864
865      const routeIO = new IntersectionObserver((entries) => {
866        const visible = entries.some(e => e.isIntersecting);
867        if (visible && !running) {
868          running = true;
869          t0 = performance.now();
870          requestAnimationFrame(frame);
871        } else if (!visible) {
872          running = false;   // fuera de pantalla no gasta CPU
873        }
874      }, { threshold: 0.2 });
875
876      rs.forEach(r => routeIO.observe(r.svg));
877      rs.forEach(r => place(r, 0));
878    }
879  }
880
881  /* ───────────── 13 · HERO · el tablero sigue al mouse
882     Parallax leve. La flotación en loop la hace el CSS; acá solo se
883     alimentan --px / --py, así los dos efectos no se pisan. */
884  const heroMedia = document.querySelector('.hero__media');
885
886  if (heroMedia && !reduced && window.matchMedia('(hover: hover)').matches) {
887    const hero = document.querySelector('.s-hero');
888    let heroRaf = false;
889    let mx = 0, my = 0;
890
891    hero.addEventListener('mousemove', (e) => {
892      const r = hero.getBoundingClientRect();
893      mx = ((e.clientX - r.left) / r.width) * 2 - 1;   // -1 … 1
894      my = ((e.clientY - r.top) / r.height) * 2 - 1;
895
896      if (!heroRaf) {
897        requestAnimationFrame(() => {
898          heroMedia.querySelector('img').style.setProperty('--px', mx.toFixed(3));
899          heroMedia.querySelector('img').style.setProperty('--py', my.toFixed(3));
900          heroRaf = false;
901        });
902        heroRaf = true;
903      }
904    });
905
906    hero.addEventListener('mouseleave', () => {
907      const img = heroMedia.querySelector('img');
908      img.style.setProperty('--px', 0);
909      img.style.setProperty('--py', 0);
910    });
911  }
912
913})();

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.