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.