/* ===========================================================
1 A 1 — Componentes (rediseño minimal / líneas / lightbox)
=========================================================== */
const I = {
shop: "M3 9l1.5-4.5A2 2 0 0 1 6.4 3h11.2a2 2 0 0 1 1.9 1.5L21 9M3 9v9a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9M3 9h18M8 9v2a2 2 0 0 1-2 2 2 2 0 0 1-2-2M16 9v2a2 2 0 0 0 2 2 2 2 0 0 0 2-2M12 9v2a2 2 0 0 1-2 2 2 2 0 0 1-2-2m8 0v2a2 2 0 0 1-2 2 2 2 0 0 1-2-2",
wrench: "M14.7 6.3a4 4 0 0 0-5.4 5l-6 6a1.5 1.5 0 0 0 2 2l6-6a4 4 0 0 0 5-5.4l-2.3 2.3-2-.5-.5-2 2.6-2.4z",
migrate: "M4 7h11M4 7l3-3M4 7l3 3M20 17H9m11 0l-3-3m3 3l-3 3",
ai: "M12 3v3m0 12v3m9-9h-3M6 12H3m13.5-6.5L14.5 7.5m-5 9l-2 2m11 0l-2-2m-9-9l-2-2M9 9h6v6H9z",
ads: "M3 11v2a1 1 0 0 0 1 1h2l4 4V6L6 10H4a1 1 0 0 0-1 1zM15 8a4 4 0 0 1 0 8M18 5a8 8 0 0 1 0 14",
chart: "M4 20V10m5 10V4m5 16v-7m5 7V8M3 20h18",
check: "M5 12l5 5L20 6",
whatsapp: "M12 3a9 9 0 0 0-7.7 13.6L3 21l4.5-1.2A9 9 0 1 0 12 3zm0 2a7 7 0 0 1 5.9 10.8l-.3.5.6 2-2.1-.6-.5.3A7 7 0 1 1 12 5zm-2.2 3.3c-.2 0-.5.1-.7.4-.2.3-.8.8-.8 1.9s.9 2.2 1 2.4c.1.2 1.6 2.6 4 3.5 2 .8 2.4.6 2.8.6.4 0 1.3-.5 1.5-1.1.2-.6.2-1 .1-1.1l-.6-.3-1.3-.6c-.2 0-.3-.1-.5.1l-.6.8c-.1.1-.2.2-.4.1-.2-.1-.9-.4-1.7-1-.6-.6-1-1.2-1.2-1.4-.1-.2 0-.3.1-.4l.3-.4.2-.4v-.3l-.6-1.5c-.2-.4-.3-.4-.5-.4h-.6z",
mail: "M3 6h18v12H3zM3 7l9 6 9-6",
pin: "M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10zm0-7.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z",
lock: "M7 11V8a5 5 0 0 1 10 0v3M5.5 11h13v9h-13z",
arrow: "M5 12h14M13 6l6 6-6 6",
"arrow-left": "M19 12H5M11 18l-6-6 6-6",
"arrow-ne": "M7 17L17 7M8 7h9v9",
"arrow-up": "M12 19V5M6 11l6-6 6 6",
x: "M6 6l12 12M18 6L6 18",
menu: "M3 12h18M3 6h18M3 18h18"
};
function Icon({ name, w = 24, cls }) {
return (
);
}
/* Título de sección con mask-reveal: el texto sube desde detrás de la línea.
El JS de reveal (app.jsx) le agrega .in al entrar en pantalla. */
function SectionTitle({ children, className }) {
return (
{children}
);
}
const WA_LINK = "https://wa.me/526640000000";
const MAIL_LINK = "mailto:diego@1a1.mx";
const MAPS_LINK = "https://www.google.com/maps/place/SERCONTAD/@32.520156,-116.9926145,19z/";
const BRAND = (
1·A·1
);
/* ---------------- NAV ---------------- */
function Nav({ t, lang, setLang, route }) {
const [scrolled, setScrolled] = React.useState(false);
const [menuOpen, setMenuOpen] = React.useState(false);
const progRef = React.useRef(null);
React.useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > 12);
const el = progRef.current;
if (el) {
const max = document.documentElement.scrollHeight - window.innerHeight;
el.style.transform = `scaleX(${max > 0 ? Math.min(1, window.scrollY / max).toFixed(4) : 0})`;
}
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
const toggleMenu = () => setMenuOpen(!menuOpen);
const closeMenu = () => setMenuOpen(false);
React.useEffect(() => {
document.body.style.overflow = menuOpen ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [menuOpen]);
const routes = ["/trabajo", "/industrias", "/herramientas-gratuitas"];
return (
);
}
/* ---------------- HERO ---------------- */
function Hero({ t }) {
const h = t.hero;
const cardRef = React.useRef(null);
const onMouseMove = (e) => {
const card = cardRef.current;
if (!card) return;
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const xc = rect.width / 2;
const yc = rect.height / 2;
// Tilt the card away from the mouse
const rotateX = ((yc - y) / yc) * 8; // max 8deg
const rotateY = ((x - xc) / xc) * -8; // max -8deg
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateY(-4px) scale(1.02)`;
card.style.boxShadow = `0 54px 100px -40px rgba(0,0,0,.75)`;
// Pan the image slot inside in the opposite direction
const imgX = ((x - xc) / xc) * -12; // max -12px
const imgY = ((y - yc) / yc) * -12; // max -12px
const img = card.querySelector("image-slot");
if (img) {
img.style.transform = `scale(1.12) translate(${imgX}px, ${imgY}px)`;
}
};
const onMouseLeave = () => {
const card = cardRef.current;
if (!card) return;
card.style.transform = "";
card.style.boxShadow = "";
const img = card.querySelector("image-slot");
if (img) {
img.style.transform = "";
}
};
return (
{(() => {
const titleText = h.title || "";
const dotIndex = titleText.indexOf(". ");
if (dotIndex !== -1) {
return (
{titleText.slice(0, dotIndex + 1)}
{titleText.slice(dotIndex + 2)}
);
}
return {titleText};
})()}
{[...h.partners, ...h.partners].map((p, i) => (
= h.partners.length ? "true" : undefined}>
))}
);
}
/* ---------------- SHOWCASE (tarjetas en movimiento) ---------------- */
function Showcase({ t }) {
const s = t.showcase;
const stageRef = React.useRef(null);
const cardsRef = React.useRef([]);
const state = React.useRef({ rx: 0, ry: 0, drift: [0, 0, 0] });
// Intensidad de tilt por tarjeta: la central reacciona más
const TILT = [0.55, 1, 0.75];
const apply = () => {
const { rx, ry, drift } = state.current;
cardsRef.current.forEach((el, i) => {
if (!el) return;
el.style.transform =
`perspective(1100px) rotateX(${(rx * TILT[i]).toFixed(2)}deg) rotateY(${(ry * TILT[i]).toFixed(2)}deg) translateY(${drift[i].toFixed(1)}px)`;
});
};
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const stage = stageRef.current;
if (!stage) return;
const onMove = (e) => {
const r = stage.getBoundingClientRect();
state.current.rx = -((e.clientY - r.top) / r.height - 0.5) * 7;
state.current.ry = ((e.clientX - r.left) / r.width - 0.5) * 7;
apply();
};
const onLeave = () => { state.current.rx = 0; state.current.ry = 0; apply(); };
// Parallax: cada tarjeta deriva a distinta velocidad respecto al centro del viewport
const SPEED = [0.05, 0.11, 0.08];
let raf = null;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = null;
const r = stage.getBoundingClientRect();
const offset = r.top + r.height / 2 - window.innerHeight / 2;
state.current.drift = SPEED.map((v) => -offset * v);
apply();
});
};
stage.addEventListener("mousemove", onMove);
stage.addEventListener("mouseleave", onLeave);
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => {
stage.removeEventListener("mousemove", onMove);
stage.removeEventListener("mouseleave", onLeave);
window.removeEventListener("scroll", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, []);
const card = (i, cls) => (
(cardsRef.current[i] = el)}>
{s.cards[i].url}
{s.cards[i].num} {s.cards[i].name} · {s.cards[i].loc}
{s.cards[i].desc}
);
const titleText = s.title || "";
const dotIndex = titleText.indexOf(". ");
return (
{dotIndex !== -1 ? (
{titleText.slice(0, dotIndex + 1)}
{titleText.slice(dotIndex + 2)}
) : titleText}
01{s.eyebrow}
{s.para}
{s.note}
{card(0, "pos-l")}
{card(1, "pos-c")}
{card(2, "pos-r")}
);
}
/* ---------------- POR QUÉ ---------------- */
function Why({ t }) {
return (
01{t.why.eyebrow}
{t.why.title}
{t.why.lead}
{t.why.items.map((it, i) => (
))}
);
}
/* ---------------- SERVICIOS — split editorial (aside fija + lista) ---------------- */
function Services({ t }) {
const s = t.services;
return (
);
}
/* ---------------- ÍNDICE WEB — tarjetas estilo Showcase con preview flotante ---------------- */
function WebIndex({ t }) {
const w = t.webIndex;
const stageRef = React.useRef(null);
const cardsRef = React.useRef([]);
const state = React.useRef({ rx: 0, ry: 0, drift: [0, 0, 0] });
const TILT = [0.55, 1, 0.75];
const apply = () => {
const { rx, ry, drift } = state.current;
cardsRef.current.forEach((el, i) => {
if (!el) return;
el.style.transform =
`perspective(1100px) rotateX(${(rx * TILT[i]).toFixed(2)}deg) rotateY(${(ry * TILT[i]).toFixed(2)}deg) translateY(${drift[i].toFixed(1)}px)`;
});
};
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const stage = stageRef.current;
if (!stage) return;
const onMove = (e) => {
const r = stage.getBoundingClientRect();
state.current.rx = -((e.clientY - r.top) / r.height - 0.5) * 7;
state.current.ry = ((e.clientX - r.left) / r.width - 0.5) * 7;
apply();
};
const onLeave = () => { state.current.rx = 0; state.current.ry = 0; apply(); };
const SPEED = [0.05, 0.11, 0.08];
let raf = null;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = null;
const r = stage.getBoundingClientRect();
const offset = r.top + r.height / 2 - window.innerHeight / 2;
state.current.drift = SPEED.map((v) => -offset * v);
apply();
});
};
stage.addEventListener("mousemove", onMove);
stage.addEventListener("mouseleave", onLeave);
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => {
stage.removeEventListener("mousemove", onMove);
stage.removeEventListener("mouseleave", onLeave);
window.removeEventListener("scroll", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, []);
const card = (i, cls) => (
);
return (
03{w.eyebrow}
{w.title}
{w.lead}
{w.note}
{card(0, "pos-l")}
{card(1, "pos-c")}
{card(2, "pos-r")}
);
}
/* ---------------- ÍNDICE WEB — listado estilo sección 01 con preview flotante ---------------- */
function WebListIndex({ t }) {
const w = t.webListIndex;
const allProjects = [t.gallery.featured, ...t.gallery.items];
const previewRef = React.useRef(null);
const [activeIndex, setActiveIndex] = React.useState(null);
const onMouseMove = (e) => {
const el = previewRef.current;
const cx = e.clientX;
const cy = e.clientY;
// Position preview centered on cursor
if (el) {
const width = 520;
const height = 340;
let x = cx - width / 2;
let y = cy - height / 2;
// Clamp to viewport edges with 12px margin
x = Math.max(12, Math.min(x, window.innerWidth - width - 12));
y = Math.max(12, Math.min(y, window.innerHeight - height - 12));
el.style.left = `${x}px`;
el.style.top = `${y}px`;
}
};
const handleProjectClick = (i) => {
window.pendingProjectOpen = i;
window.dispatchEvent(new CustomEvent('openproject', { detail: { index: i } }));
};
return (
04{w.eyebrow}
{w.title}
{w.lead}
{w.note}
{/* Backdrop blur overlay – brings preview to the front */}
{allProjects.map((p, idx) => (
))}
);
}
/* ---------------- TRABAJO — exposición editorial (catálogo curatorial impreso) ---------------- */
function WorkExhibition({ t }) {
const g = t.gallery;
const all = [g.featured, ...g.items];
const [cat, setCat] = React.useState("all");
const inCat = (p) => cat === "all" || p.cat === cat;
// Abre la pieza en el lightbox de la galería (montada en la misma página).
const openPiece = (i) => {
window.pendingProjectOpen = null;
window.dispatchEvent(new CustomEvent("openproject", { detail: { index: i } }));
};
// Tamaño curatorial por posición en la retícula de 12 columnas (composición asimétrica).
const layout = ["exh-c-7", "exh-c-5 exh-drop", "exh-c-6", "exh-c-6 exh-drop", "exh-c-12 exh-protagonist"];
return (
{/* HERO editorial */}
05
{g.eyebrow}
{g.room}
{g.title}
{g.lead}
{/* Filtros editoriales */}
{g.filters.map((f, i) => (
{i > 0 && ·}
))}
{/* Catálogo asimétrico — cada proyecto es una "pieza expuesta" */}
{all.map((p, i) => (
{p.type}
{p.title}
{p.desc}
{p.services ? (
{g.servicesLabel}
{p.services.map((s, k) => - {s}
)}
) : null}
{p.tags ? (
{g.stackLabel}
{p.tags.map((s, k) => - {s}
)}
) : null}
))}
);
}
/* ---------------- DESARROLLO WEB — sala de galería (pin + scroll horizontal) ---------------- */
function Gallery({ t }) {
const g = t.gallery;
const all = [g.featured, ...g.items];
const [cat, setCat] = React.useState("all");
const [open, setOpen] = React.useState(false);
const [idx, setIdx] = React.useState(0);
const [slide, setSlide] = React.useState(0);
const [glActive, setGlActive] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const closeTimer = React.useRef(null);
const sectionRef = React.useRef(null);
const stageRef = React.useRef(null);
const trackRef = React.useRef(null);
const barRef = React.useRef(null);
const countRef = React.useRef(null);
const glCanvasRef = React.useRef(null);
const glRef = React.useRef(null);
const inCat = (p) => cat === "all" || p.cat === cat;
const visible = all.filter(inCat);
React.useEffect(() => {
const checkPending = () => {
if (typeof window.pendingProjectOpen === 'number') {
const i = window.pendingProjectOpen;
window.pendingProjectOpen = null;
setTimeout(() => {
setIdx(i);
setOpen(true);
setSlide((s) => s + 1);
}, 150);
}
};
checkPending();
const handleOpen = (e) => {
if (e.detail && typeof e.detail.index === 'number') {
const i = e.detail.index;
setIdx(i);
setOpen(true);
setSlide((s) => s + 1);
}
};
window.addEventListener('openproject', handleOpen);
return () => {
window.removeEventListener('openproject', handleOpen);
};
}, []);
// En táctil / pantallas chicas / reduced-motion la sala no se pinea:
// el muro se recorre con scroll horizontal nativo (CSS).
const STATIC_MQ = "(max-width: 880px), (pointer: coarse), (prefers-reduced-motion: reduce)";
// Pin + traducción: la sección mide 100vh + el recorrido del muro;
// al bajar, el muro avanza hacia la derecha.
React.useEffect(() => {
const section = sectionRef.current, stage = stageRef.current, track = trackRef.current;
if (!section || !stage || !track) return;
const mq = window.matchMedia(STATIC_MQ);
const n = visible.length;
let dist = 0, raf = null;
const apply = () => {
raf = null;
if (mq.matches || !dist) return;
const top = section.getBoundingClientRect().top;
const p = Math.min(1, Math.max(0, -top / dist));
track.style.transform = `translate3d(${(-p * dist).toFixed(1)}px, 0, 0)`;
if (barRef.current) barRef.current.style.transform = `scaleX(${p.toFixed(4)})`;
if (countRef.current) {
const cur = Math.min(n, Math.floor(p * n) + 1);
countRef.current.textContent = String(cur).padStart(2, "0") + " — " + String(n).padStart(2, "0");
}
};
const measure = () => {
if (mq.matches) {
section.style.height = "";
track.style.transform = "";
return;
}
dist = Math.max(0, track.scrollWidth - stage.clientWidth);
section.style.height = `calc(100vh + ${Math.round(dist)}px)`;
apply();
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(apply); };
measure();
const timers = [120, 450].map((ms) => setTimeout(measure, ms)); // fuentes / layout tardío
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);
mq.addEventListener && mq.addEventListener("change", measure);
return () => {
timers.forEach(clearTimeout);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", measure);
mq.removeEventListener && mq.removeEventListener("change", measure);
if (raf) cancelAnimationFrame(raf);
section.style.height = "";
};
}, [cat, visible.length]);
// Revelado cinematográfico por pieza (blur→foco + slice + número kinético)
React.useEffect(() => {
const section = sectionRef.current;
if (!section) return;
const pieces = Array.from(section.querySelectorAll(".gw-piece"));
if (!pieces.length) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|| document.documentElement.classList.contains("motion-off");
section.classList.add("cine");
if (reduce || !window.anime) {
pieces.forEach((p) => {
p.classList.add("in-view");
p.querySelectorAll(".gw-plaque > *, .gw-index").forEach((el) => { el.style.opacity = 1; });
});
return;
}
const reveal = (piece) => {
const lines = piece.querySelectorAll(".gw-plaque > *");
const index = piece.querySelector(".gw-index");
anime({ targets: lines, opacity: [0, 1], translateY: [16, 0], duration: 620, delay: anime.stagger(80, { start: 240 }), easing: "easeOutExpo" });
if (index) anime({ targets: index, opacity: [0, 1], translateY: [28, 0], duration: 1000, easing: "easeOutExpo" });
};
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting && !e.target.classList.contains("in-view")) {
e.target.classList.add("in-view");
reveal(e.target);
io.unobserve(e.target);
}
});
}, { threshold: 0.25 });
pieces.forEach((p) => io.observe(p));
return () => io.disconnect();
}, [cat]);
const changeCat = (id) => setCat(id);
const openAt = (p) => {
const i = all.indexOf(p);
setIdx(i); setOpen(true); setSlide((s) => s + 1);
if (glRef.current) glRef.current.focus(i); // "la pieza se vuelve un mundo"
};
const close = () => {
setOpen(false);
if (glRef.current) {
glRef.current.unfocus();
// mantiene el lienzo a pantalla completa mientras la pieza se reduce, luego lo libera
setClosing(true);
clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => setClosing(false), 520);
}
};
const go = (dir) => {
if (glRef.current) { // en GL recorremos todas las piezas
const ni = (idx + dir + all.length) % all.length;
setIdx(ni); setSlide((s) => s + 1); glRef.current.focus(ni);
return;
}
const list = all.filter(inCat);
const cur = Math.max(0, list.findIndex((p) => all.indexOf(p) === idx));
const ni = (cur + dir + list.length) % list.length;
setIdx(all.indexOf(list[ni]));
setSlide((s) => s + 1);
};
React.useEffect(() => {
const onKey = (e) => {
if (!open) return;
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") go(1);
else if (e.key === "ArrowLeft") go(-1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
React.useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [open]);
// Capa WebGL (Three.js) — sólo en el modo pineado de escritorio y con motion.
// El DOM sigue siendo accesible (opacity:0, focusable); este canvas es lo visual.
React.useEffect(() => {
const canvas = glCanvasRef.current, stage = stageRef.current, section = sectionRef.current;
if (!canvas || !stage || !section) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|| document.documentElement.classList.contains("motion-off");
if (reduce || !window.galleryGLSupported || !window.galleryGLSupported() || !window.createGalleryGL) return;
const staticMq = window.matchMedia(STATIC_MQ);
// Usa la imagen que el image-slot resuelve: la subida por el autor (_userUrl) o el src del asset.
const resolveSrc = (p) => {
const el = document.getElementById("lb-" + p.idx);
return (el && (el._userUrl || el.getAttribute("src"))) || p.src;
};
const items = all.map((p) => ({ src: resolveSrc(p), idx: p.idx, title: p.title }));
const getProgress = () => {
const top = section.getBoundingClientRect().top;
const dist = section.offsetHeight - window.innerHeight;
return dist > 0 ? Math.min(1, Math.max(0, -top / dist)) : 0;
};
const build = () => {
if (glRef.current || staticMq.matches) return;
const gl = window.createGalleryGL({ canvas, items, getProgress, onPick: (i) => openAt(all[i]) });
if (gl) { glRef.current = gl; stage.classList.add("gl-on"); setGlActive(true); }
};
const teardown = () => {
if (glRef.current) { glRef.current.destroy(); glRef.current = null; }
stage.classList.remove("gl-on"); setGlActive(false);
};
const onMq = () => { staticMq.matches ? teardown() : build(); };
build();
staticMq.addEventListener && staticMq.addEventListener("change", onMq);
// Imagen soltada en un slot → recarga esa textura en el WebGL al vuelo
const onSlotChange = (e) => {
if (!glRef.current || !e.detail || !e.detail.id) return;
const pi = all.findIndex((p) => "lb-" + p.idx === e.detail.id);
if (pi >= 0) glRef.current.updateTexture(pi, e.detail.url);
};
document.addEventListener("imageslotchange", onSlotChange);
// Pausa el render WebGL cuando la sala sale de pantalla (ahorro de batería/CPU)
const vis = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (!glRef.current) return;
e.isIntersecting ? glRef.current.resume() : glRef.current.pause();
});
}, { rootMargin: "120px" });
vis.observe(section);
return () => {
vis.disconnect();
document.removeEventListener("imageslotchange", onSlotChange);
teardown();
staticMq.removeEventListener && staticMq.removeEventListener("change", onMq);
};
}, []);
// Tipografía kinética del case-study (GL)
React.useEffect(() => {
if (!glActive || !open || !window.anime) return;
const els = document.querySelectorAll(".gw-case-body > *");
if (els.length) anime({ targets: els, opacity: [0, 1], translateY: [26, 0], duration: 720, delay: anime.stagger(70), easing: "easeOutExpo" });
}, [open, slide, glActive]);
// Filtro en GL: las piezas fuera de categoría salen volando, las visibles se recolocan
React.useEffect(() => {
if (!glActive || !glRef.current) return;
const indices = all.map((p, i) => i).filter((i) => cat === "all" || all[i].cat === cat);
glRef.current.setVisible(indices);
}, [cat, glActive]);
const proj = all[idx];
const total = String(all.length).padStart(2, "0");
const pieceKey = (p) => (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAt(p); }
};
return (
{g.quick}
{g.room}
{g.hint}
{"01 — " + String(visible.length).padStart(2, "0")}
{all.map((p) => (
openAt(p)} onKeyDown={pieceKey(p)} aria-label={p.title}>
{p.idx}
{p.type}
{p.title}
{p.tags ? p.tags.join(" · ") : p.url}
))}
{glActive && (
e.stopPropagation()}>
{proj.idx} — {total}
{proj.type}
{proj.title}
{proj.desc}
{proj.tags ?
{proj.tags.map((tg, i) => {tg})}
: null}
{g.endCta}
{proj.url}
)}
e.stopPropagation()}>
{proj.idx} — {total}
{proj.type}
{proj.title}
{proj.desc}
{proj.tags ?
{proj.tags.map((tg, i) => {tg})}
: null}
{proj.url}
);
}
/* ---------------- AUDITORÍA — diagnóstico que se ensambla al scroll ---------------- */
function Audit({ t }) {
const a = t.audit;
const secRef = React.useRef(null);
const bigRef = React.useRef(null);
const zeroRef = React.useRef(null);
const scanRef = React.useRef(null);
const diagRef = React.useRef(null);
React.useEffect(() => {
const sec = secRef.current;
if (!sec) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const motionOff = document.documentElement.classList.contains("motion-off");
if (reduce || motionOff || !window.anime) return; // sin motion: estado final por CSS
const items = diagRef.current ? Array.from(diagRef.current.querySelectorAll("li")) : [];
sec.classList.add("arm"); // oculta estados iniciales sólo si vamos a animar
let played = false;
const play = () => {
if (played) return;
played = true;
const tl = anime.timeline({ easing: "easeOutExpo" });
tl.add({ targets: scanRef.current, translateY: ["-40%", "130%"], opacity: [0, 1, 1, 0], duration: 1300, easing: "easeInOutQuad" }, 0)
.add({ targets: items, opacity: [0, 1], translateX: [-22, 0], duration: 560, delay: anime.stagger(85) }, 260)
.add({ targets: bigRef.current, opacity: [0, 1], scale: [0.7, 1], duration: 1000, easing: "easeOutElastic(1, .55)" }, 520);
if (zeroRef.current) {
const obj = { v: 9 };
anime({
targets: obj, v: 0, round: 1, duration: 900, delay: 660, easing: "easeOutExpo",
update: () => { if (zeroRef.current) zeroRef.current.textContent = String(obj.v); }
});
}
sec.classList.add("played");
};
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { play(); io.disconnect(); } });
}, { threshold: 0.35 });
io.observe(sec);
return () => io.disconnect();
}, []);
// Imán sobre el $0 (sólo puntero fino)
const onBigMove = (e) => {
const el = bigRef.current;
if (!el || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
const r = el.getBoundingClientRect();
const dx = (e.clientX - (r.left + r.width / 2)) * 0.16;
const dy = (e.clientY - (r.top + r.height / 2)) * 0.16;
el.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px)`;
};
const onBigLeave = () => { if (bigRef.current) bigRef.current.style.transform = ""; };
return (
06{a.eyebrow}
{a.title}
{a.p}
$0
{a.freeSm}
{a.cta}
{a.eyebrow}
{a.list.map((li, i) => (
- {li}
))}
);
}
/* ---------------- CONTACTO ---------------- */
function Contact({ t }) {
const [sent, setSent] = React.useState(false);
const ch = { wa: "whatsapp", mail: "mail", pin: "pin" };
const href = { wa: WA_LINK, mail: MAIL_LINK, pin: MAPS_LINK };
return (
);
}
/* ---------------- FOOTER ---------------- */
/* ---------------- Página "Próximamente" (Industrias / Herramientas) ---------------- */
function ComingSoon({ soon, data }) {
return (
{soon.eyebrow}
{data.title}
{data.text}
);
}
function Footer({ t }) {
return (
);
}
/* ---------------- FAB de chat (WhatsApp) ---------------- */
function ChatFab() {
return (
);
}
Object.assign(window, { Icon, Nav, Hero, Showcase, Why, Services, WebIndex, WebListIndex, Gallery, Audit, Contact, Footer, ChatFab });