// Piezas con movimiento del home: el croquis del hero, el odómetro de precios // y la ficha fija de servicios. La geometría y los cálculos viven en // logica.js; aquí solo se pinta y se anima. // // Todo arranca en su estado final: si GSAP no carga o el visitante pidió // menos movimiento, se ve el croquis terminado y los precios quietos. Solo // cuando JoxeMotor.activo es true se esconden las piezas para animarlas. const mvActivo = () => !!(window.JoxeMotor && window.JoxeMotor.activo); // Línea de un titular dentro de una máscara: el motor la hace subir desde // abajo. Sin motor es un bloque normal. const L = ({ children }) => ( {children} ); // Rótulo de sección: un trazo corto de bronce seguido del texto en mono. const Rotulo = ({ children, style }) => ( ); // ------------------------------------------------------------------ // ODÓMETRO // ------------------------------------------------------------------ const TIRA = "0123456789".repeat(2).split("").join("\n"); const Odometro = ({ texto, style }) => { const M = window.JoxeMovimiento; const partes = M.partesOdometro(texto); const ref = React.useRef(null); const previo = React.useRef(texto); // Cuando cambia el valor (la ficha pasa a otro servicio), cada dígito rueda // hacia arriba desde el anterior. La primera aparición la anima el motor. React.useLayoutEffect(() => { const antes = previo.current; previo.current = texto; if (antes === texto || !mvActivo() || !ref.current) return; const viejos = M.partesOdometro(antes).filter(p => p.tipo === "d").reverse(); const tiras = Array.from(ref.current.querySelectorAll(".odo-t")).reverse(); tiras.forEach((t, i) => { const desde = viejos[i] ? -(viejos[i].v / 20) * 100 : 0; window.gsap.fromTo(t, { y: 0, yPercent: desde }, { y: 0, yPercent: Number(t.dataset.y), duration: 0.9, ease: "expo.out", delay: i * 0.035, overwrite: true }); }); }, [texto]); if (!partes.some(p => p.tipo === "d")) return {texto}; return ( {texto} ); }; // Texto que asoma desde abajo cada vez que se monta (se usa con key para que // cada servicio nuevo de la ficha entre con su propio gesto). const Asoma = ({ children, style }) => { const ref = React.useRef(null); React.useLayoutEffect(() => { if (!mvActivo() || !ref.current) return; window.gsap.fromTo(ref.current, { yPercent: 105 }, { yPercent: 0, duration: 0.7, ease: "expo.out" }); }, []); return ( {children} ); }; // ------------------------------------------------------------------ // FICHA DE SERVICIOS (solo escritorio) // Muestra el servicio que cruza la línea de lectura o el que se apunta: lo // mismo que dice la fila, pero puesto como una cuenta que no cambia al // llegar al salón. La lista sigue siendo el contenido; la ficha es la prueba // de "sin sorpresas". // ------------------------------------------------------------------ // Duración con las mismas unidades que la regla de la ficha ("1 h", "2 h 30 min"). const duracionFicha = mins => { if (!mins) return ""; const h = Math.floor(mins / 60), m = mins % 60; if (!h) return `${m} min`; return m ? `${h} h ${m} min` : `${h} h`; }; const FichaServicio = ({ servicios, activa, precio, dias, hoy }) => { const s = servicios[activa] || servicios[0]; if (!s) return null; const maxDur = Math.max(240, ...servicios.map(x => x.dur || 0)); const horas = Math.ceil(maxDur / 60); const etiquetasDia = dias(s); const hoyPrecio = hoy && s.dayPrices && Number(s.dayPrices[hoy.clave]) > 0 ? `${hoy.nombre} ${precio({ price: s.dayPrices[hoy.clave] })}` : null; return (
Reservar cita →
); }; // ------------------------------------------------------------------ // CROQUIS DEL HERO // "La imagen no se improvisa. Se diseña.": el corte se dibuja antes de // hacerse. Perfil, tercios del rostro, línea de degradado y una guía de // elevación que el visitante mueve con el cursor; la silueta del pelo // responde (a poca elevación el peso baja, a mucha las capas se emparejan). // Es una ilustración, no un trabajo del salón, y así lo dice su rótulo. // ------------------------------------------------------------------ // memo: el hero se vuelve a pintar cuando cambia el nav al hacer scroll, y // el croquis no tiene por qué repetir su render (lo que se mueve lo cambia // aplicar() directo en el SVG). const Croquis = React.memo(() => { const M = window.JoxeMovimiento; const C = M.CROQUIS; const raiz = React.useRef(null); const el = React.useRef({}); const setRef = nombre => n => { el.current[nombre] = n; }; const hebras = React.useMemo(() => M.hebras(), []); const INICIAL = 45; const s0 = M.siluetaPelo(INICIAL), g0 = M.guia(INICIAL), w0 = M.puntoPeso(INICIAL); // Columna de rótulos a la derecha: el texto va alineado al borde del // lienzo y las líneas guía terminan antes, así el rótulo no se sale del // SVG aunque en móvil la letra sea más grande. const ROT_X = C.ancho - 16; const LIDER_X = 530; // Redibuja lo que depende de la elevación sin pasar por React: se llama en // cada fotograma mientras la guía se mueve. const aplicar = React.useCallback(grados => { const e = el.current; if (!e.forma) return; const s = M.siluetaPelo(grados), g = M.guia(grados), w = M.puntoPeso(grados); e.forma.setAttribute("d", s.forma); e.borde.setAttribute("d", s.borde); e.mechones.setAttribute("d", M.mechones(grados)); e.guia.setAttribute("x2", g.x2); e.guia.setAttribute("y2", g.y2); e.arco.setAttribute("d", g.arco); e.valor.textContent = `${Math.round(grados)}°`; e.peso.setAttribute("cx", w.x); e.peso.setAttribute("cy", w.y); e.pesoLinea.setAttribute("d", `M${w.x + 5} ${w.y}H${LIDER_X}`); e.pesoRot.setAttribute("y", w.y + 3.5); }, []); React.useLayoutEffect(() => { const nodo = raiz.current; const svg = nodo.querySelector("svg"); const gsap = window.gsap; const activo = mvActivo(); const estado = { g: INICIAL }; let vaiven = null, intro = null, visible = false, tocado = false, reanudar = null; const aSvg = ev => { const m = svg.getScreenCTM(); if (!m) return null; const p = svg.createSVGPoint(); p.x = ev.clientX; p.y = ev.clientY; return p.matrixTransform(m.inverse()); }; // El cursor manda la guía. Mientras se apunta, nada se mueve solo. const mover = ev => { if (ev.pointerType === "touch") return; const p = aSvg(ev); if (!p) return; const destino = M.anguloDesde(p.x, p.y); if (!tocado) { tocado = true; nodo.classList.add("cq-tocado"); } if (vaiven) vaiven.pause(); clearTimeout(reanudar); if (activo) { gsap.to(estado, { g: destino, duration: 0.45, ease: "power3.out", overwrite: true, onUpdate: () => aplicar(estado.g) }); } else { estado.g = destino; aplicar(destino); } }; const salir = () => { if (!vaiven) return; clearTimeout(reanudar); reanudar = setTimeout(() => { gsap.to(estado, { g: INICIAL, duration: 1.2, ease: "power2.inOut", overwrite: true, onUpdate: () => aplicar(estado.g), onComplete: () => { if (visible) vaiven.restart(); }, }); }, 2500); }; nodo.addEventListener("pointermove", mover); nodo.addEventListener("pointerleave", salir); const limpiar = [() => { nodo.removeEventListener("pointermove", mover); nodo.removeEventListener("pointerleave", salir); clearTimeout(reanudar); }]; if (activo) { const q = sel => nodo.querySelectorAll(sel); // Los trazos llevan pathLength="1": con guion y desfase de 1 quedan // sin dibujar y se dibujan llevando el desfase a 0. gsap.set(q(".cq-dib"), { strokeDasharray: 1, strokeDashoffset: 1 }); gsap.set(q(".cq-aparece, .cq-hebras path, .cq-rot"), { opacity: 0 }); estado.g = 0; aplicar(0); intro = gsap.timeline({ paused: true, defaults: { ease: "power2.inOut" } }) .to(q(".cq-esquina"), { opacity: 1, duration: 0.5, stagger: 0.06 }, 0) .to(q(".cq-tercio"), { strokeDashoffset: 0, duration: 0.9, stagger: 0.07 }, 0.1) .to(q(".cq-perfil"), { strokeDashoffset: 0, duration: 1.7, stagger: 0.12 }, 0.25) .to(q(".cq-oreja"), { strokeDashoffset: 0, duration: 0.9 }, 1.0) .to(q(".cq-deg-mascara"), { strokeDashoffset: 0, duration: 1.1 }, 1.3) .to(q(".cq-hebras path"), { opacity: 1, duration: 0.45, stagger: 0.11, ease: "power1.out" }, 1.45) .to(q(".cq-borde"), { strokeDashoffset: 0, duration: 1.2 }, 1.7) .to(q(".cq-forma, .cq-mechones"), { opacity: 1, duration: 0.8, ease: "power1.out" }, 2.1) .to(q(".cq-guia-g"), { opacity: 1, duration: 0.3 }, 2.2) .to(estado, { g: INICIAL, duration: 1.3, ease: "expo.out", onUpdate: () => aplicar(estado.g) }, 2.2) .to(q(".cq-rot"), { opacity: 1, duration: 0.5, stagger: 0.09, ease: "power1.out" }, 2.6); // Después de dibujarse, la guía se pasea sola para mostrar que se // puede mover: baja, sube, vuelve a 45° y descansa. vaiven = gsap.timeline({ paused: true, repeat: -1, delay: 0.6, defaults: { ease: "sine.inOut", onUpdate: () => aplicar(estado.g) } }) .to(estado, { g: 16, duration: 2.6 }) .to(estado, { g: 80, duration: 3.8 }) .to(estado, { g: INICIAL, duration: 2.4 }) .to({}, { duration: 2.2 }); intro.eventCallback("onComplete", () => { if (!tocado && visible) vaiven.play(); }); // Fuera de pantalla no se gasta un fotograma. const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) { if (!intro.progress()) intro.play(); else if (intro.progress() === 1 && !tocado) vaiven.play(); } else if (vaiven) vaiven.pause(); }, { threshold: 0.25 }); io.observe(nodo); limpiar.push(() => { io.disconnect(); intro.kill(); vaiven.kill(); gsap.killTweensOf(estado); }); } return () => limpiar.forEach(f => f()); }, []); const tercio = (y1, y2, n) => ( {n} ); const T = C.tercios; return (
{/* Marco de dibujo técnico */} {[[18, 18, 1, 1], [C.ancho - 18, 18, -1, 1], [18, C.alto - 18, 1, -1], [C.ancho - 18, C.alto - 18, -1, -1]].map(([x, y, sx, sy], i) => ( ))} {/* Tercios del rostro */} {T.map((y, i) => ( ))} {tercio(T[0], T[1], "I")} {tercio(T[1], T[2], "II")} {tercio(T[2], T[3], "III")} {/* Pelo: forma, hebras del costado y mechones */} {hebras.map((d, i) => )} {/* Cabeza */} {/* Línea de degradado (punteada, se revela con una máscara) */} {/* Guía de elevación */} {/* Cajetín con la lectura de la elevación, fijo para que ningún ángulo lo haga chocar con la guía o con los rótulos. */} ELEVACIÓN {INICIAL}° {/* Rótulos */} LÍNEA DE PESO DEGRADADO TEXTURA TERCIOS DEL ROSTRO EJEMPLO ILUSTRATIVO
); }); Object.assign(window, { L, Rotulo, Odometro, Asoma, FichaServicio, Croquis });