/* global React, Sprite, useSprite, useTime, Easing, interpolate, animate, clamp,
ScrollStage, CursorTrail, CanvasChrome, SidePanel, useIsMobile */
// Bataj Products Scroll-Reel — the products section as one big scroll-driven
// video. Replaces ProductsIntro + AllProducts + Stats + Process.
//
// Story arc (60 units of time mapped to ~750vh of scroll):
// 00–03 Intro: "PRODUCTOS · Cuatro universos."
// 03–12 Turnos: warm calendar booking, slot ignites, confirm pulses
// 12–21 Sak: editorial luxury catalog, cards reveal, price glows
// 21–30 Delivery: dark console, orders flow through pipeline
// 30–39 Dunamis: dashboard, KPIs tick up, sparklines draw, alerts pop
// 39–46 Stats: 4 monumental numbers count up
// 46–53 Process: 01→04 BRIEF/SISTEMA/BUILD/LAUNCH march in
// 53–60 Outro: "Mismo estudio. Cuatro lenguajes." + CTA wink
const _cl = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
/* ───────────── Small reusable helpers ───────────── */
// progress 0 → 1 across [from, to] within sprite-local time
function ramp(localTime, from, to, ease = Easing.easeOutCubic) {
return ease(_cl((localTime - from) / Math.max(0.0001, to - from), 0, 1));
}
// integer count tween
function counted(localTime, from, to, target, fmt = (v) => v) {
const t = ramp(localTime, from, to, Easing.easeOutCubic);
return fmt(target * t);
}
function TrafficDots({ colors = ['#d0c8b8', '#d0c8b8', '#d0c8b8'] }) {
return (
{colors.map((c, i) => (
))}
);
}
/* ───────────── Window chrome shell ───────────── */
function WindowFrame({ x, y, w, h, bar, barBg, lineColor, bg, children, scaleAtEntry = 0 }) {
// scaleAtEntry: 0 = no entry animation, otherwise it's an entrance progress 0->1
const scale = 0.94 + 0.06 * scaleAtEntry;
const opacity = _cl(scaleAtEntry / 0.6, 0, 1);
return (
);
}
/* ═══════════════════════════════════════════════════════════════════════
SCENE 0 — INTRO (0 → 3)
═══════════════════════════════════════════════════════════════════════ */
function SceneIntro() {
const isMobile = useIsMobile();
const { localTime } = useSprite();
const a1 = ramp(localTime, 0.1, 0.9, Easing.easeOutBack);
const a2 = ramp(localTime, 0.6, 1.4);
const a3 = ramp(localTime, 1.4, 2.2);
const out = 1 - ramp(localTime, 2.4, 3.0);
return (
▸ Sec.03 · Catálogo · 04 productos
PRO_DUCTOS
Cuatro universos.
01 · TURNOS
02 · SAK
03 · DELIVERY
04 · DUNAMIS
);
}
/* ═══════════════════════════════════════════════════════════════════════
SCENE 1 — APP TURNOS (3 → 12)
═══════════════════════════════════════════════════════════════════════ */
function SceneTurnosLive() {
const isMobile = useIsMobile();
const { localTime, duration } = useSprite();
const entry = ramp(localTime, 0, 1.0);
const exit = ramp(localTime, duration - 0.8, duration, Easing.easeInCubic);
const opacity = entry * (1 - exit);
// animation phases
const headerIn = ramp(localTime, 0.3, 1.1);
const weekIn = ramp(localTime, 0.9, 1.6);
const slotsIn = ramp(localTime, 1.5, 2.4);
const selectIn = ramp(localTime, 2.6, 3.4, Easing.easeOutBack);
const ctaPulse = Math.sin(_cl((localTime - 3.7) * 4, 0, Math.PI * 2)) * 0.5 + 0.5;
const toastIn = ramp(localTime, 4.4, 5.0, Easing.easeOutBack);
const toastOut = ramp(localTime, 7.0, 7.6);
// cursor keyframes (absolute canvas coordinates)
const cursorKf = [
{ t: 0.6, x: 1750, y: 850 }, // off-canvas right
{ t: 1.4, x: 760, y: 540 }, // hover Lun 27
{ t: 2.2, x: 760, y: 540 }, // hold
{ t: 3.1, x: 660, y: 800, click: true }, // click 15:00
{ t: 3.8, x: 660, y: 800 },
{ t: 4.4, x: 1070, y: 920, click: true }, // click Confirmar
{ t: 5.5, x: 1070, y: 920 },
{ t: 7.0, x: 1300, y: 980 },
];
const slots = ['09:00','09:30','10:00','10:30','11:00','11:30','12:00','14:00','14:30','15:00','15:30','16:00'];
const taken = new Set(['10:00','11:30','14:30']);
return (
app.turnos.studio / reservar
>}
>
{/* Sidebar */}
A
Aurelia Reyes
Psicología clínica
{['Agenda','Pacientes','Notas','Cobros','Historial'].map((x,i) => (
{x}
))}
{/* upcoming card */}
Próximo · Confirmado
Mariana López
15:00 · Sesión seguimiento
{/* Calendar area */}
Lunes, 27 de mayo
Reservá tu sesión
{/* Week strip */}
{['Lun 27','Mar 28','Mié 29','Jue 30','Vie 31','Sáb 1','Dom 2'].map((d, i) => {
const a = ramp(localTime, 0.9 + i * 0.04, 1.4 + i * 0.04, Easing.easeOutBack);
const isSelected = i === 0;
return (
{d.split(' ')[0]}
{d.split(' ')[1]}
);
})}
{/* Slots */}
Horarios disponibles
{slots.map((s, i) => {
const a = ramp(localTime, 1.5 + (i * 0.05), 2.1 + (i * 0.05), Easing.easeOutCubic);
const isTaken = taken.has(s);
const isSel = s === '15:00';
const selRamp = isSel ? selectIn : 0;
return (
{s}
);
})}
{/* Footer row + CTA */}
Sesión 50 min · ARS 18.000
Confirmar reserva →
{/* Toast confirmation */}
{toastIn > 0 && toastOut < 1 && (
Reserva confirmada
Lun 27 · 15:00 · Te llegará un email
)}
);
}
function SceneSakLive() {
const isMobile = useIsMobile();
const { localTime, duration } = useSprite();
const entry = ramp(localTime, 0, 1.0);
const exit = ramp(localTime, duration - 0.8, duration, Easing.easeInCubic);
const opacity = entry * (1 - exit);
const items = [
{ n: 'Lino Crudo', p: 'AR$ 89.000', tag: 'COL/26', img: 'linear-gradient(160deg,#E4D8BD,#C8B58A)' },
{ n: 'Vestido Aria', p: 'AR$ 142.000', tag: 'NEW', img: 'linear-gradient(160deg,#D7C2A1,#9A7A4B)' },
{ n: 'Camisa Oporto', p: 'AR$ 96.500', tag: 'COL/26', img: 'linear-gradient(160deg,#EFE5D0,#B59C70)' },
{ n: 'Pantalón Soho', p: 'AR$ 118.000', tag: 'STAFF', img: 'linear-gradient(160deg,#C9B797,#6F5D3B)' },
];
const headerIn = ramp(localTime, 0.4, 1.2);
const filterIn = ramp(localTime, 1.1, 1.8);
// bag indicator ticks at click time
const bagAdded = localTime > 5.4 ? 1 : 0;
const cursorKf = [
{ t: 0.8, x: 1700, y: 200 },
{ t: 2.4, x: 820, y: 700 }, // hover product 1
{ t: 3.6, x: 820, y: 700, click: true }, // click
{ t: 4.4, x: 980, y: 740 }, // move toward "add" pill
{ t: 5.4, x: 980, y: 740, click: true }, // click add
{ t: 6.6, x: 1240, y: 130 }, // up to bag
{ t: 8.6, x: 1240, y: 130 },
];
return (
sak.studio / colección / otoño 26
>}
>
{/* editorial header */}
Otoño · MMXXVI
Sak Atelier
{/* filter row */}
Filtrar:
Todo
LinoAlgodónSeda
Orden: EditorialVista: Grid
{/* products 2x2 */}
{items.map((it, i) => {
const a = ramp(localTime, 1.6 + (i * 0.25), 2.4 + (i * 0.25), Easing.easeOutCubic);
const hovered = i === 0 && localTime > 2.0 && localTime < 7.5;
const liftT = hovered ? ramp(localTime, 2.0, 2.6) * (1 - ramp(localTime, 7.5, 8.0)) : 0;
return (
0 ? `color-mix(in srgb, var(--bataj-bg) ${liftT * 35}%, transparent)` : 'transparent',
transition: 'background .2s ease',
}}>
{it.n}
Lino 100% · 5 talles
{it.p}
{hovered && (
Añadir a bolsa ↗
)}
);
})}
);
}
function SceneDeliveryLive() {
const isMobile = useIsMobile();
const { localTime, duration } = useSprite();
const entry = ramp(localTime, 0, 1.0);
const exit = ramp(localTime, duration - 0.8, duration, Easing.easeInCubic);
const opacity = entry * (1 - exit);
// Orders animate in over time; one transitions COCINA → LISTO → EN RUTA
const allOrders = [
{ id: '#3492', cli: 'Mesa 12', items: '2× Pizza · 1× Coca', base: '00:14', tIn: 1.0, transitions: [{ at: 3.2, status: 'COCINA', col: 'var(--bataj-accent)' }, { at: 5.5, status: 'LISTO', col: '#FFD93D' }, { at: 7.4, status: 'EN RUTA', col: 'var(--bataj-accent)' }] },
{ id: '#3491', cli: 'Lourdes G.', items: '1× Bowl · Postre', base: '00:08', tIn: 1.3, transitions: [{ at: 0, status: 'EN RUTA', col: 'var(--bataj-accent)' }] },
{ id: '#3490', cli: 'Diego F.', items: '3× Empanadas', base: '00:22', tIn: 1.6, transitions: [{ at: 0, status: 'COCINA', col: 'var(--bataj-accent)' }] },
{ id: '#3489', cli: 'Mesa 04', items: '1× Pasta', base: '00:31', tIn: 1.9, transitions: [{ at: 0, status: 'LISTO', col: '#FFD93D' }] },
{ id: '#3488', cli: 'Sofía R.', items: '2× Sushi · Postre', base: '00:42', tIn: 2.2, transitions: [{ at: 0, status: 'CERRADO', col: '#5A6071' }] },
{ id: '#3487', cli: 'Mesa 09', items: '4× Pizza', base: '01:02', tIn: 2.5, transitions: [{ at: 0, status: 'CERRADO', col: '#5A6071' }] },
];
function currentStatus(order) {
let cur = order.transitions[0];
for (const tr of order.transitions) if (localTime >= tr.at + order.tIn) cur = tr;
return cur;
}
// Timer ticks for first KDS card
const ticker = (localTime > 0.5) ? Math.floor(localTime * 1.5) : 0;
const cursorKf = [
{ t: 0.6, x: 1700, y: 200 },
{ t: 2.2, x: 700, y: 560 }, // hover #3492
{ t: 3.4, x: 700, y: 560, click: true }, // click row
{ t: 5.4, x: 1280, y: 350, click: true }, // mark ready in KDS
{ t: 7.4, x: 1280, y: 460, click: true }, // confirm to ruta
{ t: 8.6, x: 1700, y: 800 },
];
return (
deliv.app / console · tenant: cucina-roma
ONLINE · LIVE
>}
>
{/* Nav */}
Tenant
Cucina Roma
Vistas
{[['Órdenes','78','active'],['Cocina','12',''],['Caja','5',''],['Delivery','9',''],['Reportes','—',''],['Settings','—','']].map(([n,c,act], i) => (
{n}{c}
))}
{/* Orders table */}
Órdenes activas
78 abiertas · 12 en cocina
{['TODO','COCINA','RUTA','LISTO'].map((t, i) => (
{t}
))}
| ID |
Cliente |
Items |
⏱ |
Estado |
{allOrders.map((o) => {
const a = ramp(localTime, o.tIn, o.tIn + 0.4);
const st = currentStatus(o);
const highlighted = o.id === '#3492' && localTime > 3.2;
return (
| {o.id} |
{o.cli} |
{o.items} |
{o.base} |
{st.status}
|
);
})}
{/* KDS panel */}
KDS · Cocina
{[
{ id:'#3492', items:['Pizza Margarita','Pizza 4 Quesos','Coca 500ml'], baseT: 14 },
{ id:'#3490', items:['Empanada Carne ×2','Empanada Pollo'], baseT: 22, urgent: true },
{ id:'#3493', items:['Ravioles caseros','Tiramisú'], baseT: 3 },
].map((k, i) => {
const a = ramp(localTime, 0.8 + i * 0.2, 1.4 + i * 0.2);
const tt = k.baseT + (i === 0 ? Math.floor(localTime) : 0);
const isMarkedReady = i === 0 && localTime > 5.5;
return (
{k.id}
⏱ 00:{String(tt).padStart(2,'0')}
{k.items.map((it, j) => (
-
{isMarkedReady && ✓}
{it}
))}
);
})}
);
}
function SceneDunamisLive() {
const isMobile = useIsMobile();
const { localTime, duration } = useSprite();
const entry = ramp(localTime, 0, 1.0);
const exit = ramp(localTime, duration - 0.8, duration, Easing.easeInCubic);
const opacity = entry * (1 - exit);
// Build chart path progressively
const data = [50,58,55,62,70,68,74,80,77,84,82,90,95,92,100,98,105,110,108,115,122,118,128,134,130,138,142,148,144,152];
const maxV = Math.max(...data);
const drawT = ramp(localTime, 1.6, 4.0, Easing.easeOutCubic);
const drawCount = Math.max(2, Math.floor(drawT * data.length));
const drawn = data.slice(0, drawCount);
const chartW = 720, chartH = 220;
const pts = drawn.map((v, i) => `${(i / (data.length - 1)) * chartW},${chartH - (v / maxV) * (chartH - 20)}`).join(' ');
const lastPt = drawn.length
? { x: ((drawn.length - 1) / (data.length - 1)) * chartW, y: chartH - (drawn[drawn.length - 1] / maxV) * (chartH - 20) }
: null;
// KPI tick-ups
const mrr = (84.2 * ramp(localTime, 0.6, 2.0, Easing.easeOutCubic)).toFixed(1);
const conv = (4.82 * ramp(localTime, 0.8, 2.2, Easing.easeOutCubic)).toFixed(2);
const churn = (2.1 * ramp(localTime, 1.0, 2.4, Easing.easeOutCubic)).toFixed(1);
const aov = Math.floor(142 * ramp(localTime, 1.2, 2.6, Easing.easeOutCubic));
const alert1 = ramp(localTime, 3.5, 4.2, Easing.easeOutBack);
const alert2 = ramp(localTime, 4.5, 5.2, Easing.easeOutBack);
const alert3 = ramp(localTime, 5.5, 6.2, Easing.easeOutBack);
return (
dunamis.app / dashboard / Q2 — 26
Últ. 30 días
LIVE
>}
>
Bienvenida, María
Hoy el negocio está +{mrr === '0.0' ? '0.0' : (mrr * 0.147).toFixed(1)}%
Anomalías: 0 ? '#FFD93D' : 'var(--bataj-accent)' }}>{Math.round(alert1 + alert2)}
Conectores: 6/6 OK
{/* KPI cards */}
{[
{ l: 'MRR', v: `US$ ${mrr}k`, d: '+8.4%', col: 'var(--bataj-accent)' },
{ l: 'Conversión', v: `${conv}%`, d: '+0.6pp', col: 'var(--bataj-accent)' },
{ l: 'Churn', v: `${churn}%`, d: '+0.3pp', col: '#FF8C8C' },
{ l: 'AOV', v: `US$ ${aov}`, d: '+11.0%', col: 'var(--bataj-accent)' },
].map((k) => (
))}
{/* Chart + alerts */}
Ingresos · 30 días
US$ {Math.floor(312480 * ramp(localTime, 1.0, 2.6)).toLocaleString('en-US')}{' '}
+18.2%
Alertas
{Math.round(alert1 + alert2)} nuevas
{[
{ t:'Pico de tráfico orgánico', d:'+312% vs media · hace 14m', col:'var(--bataj-accent)', a: alert1 },
{ t:'Churn detectado plan PRO', d:'2 cuentas · acción sugerida', col:'#FF8C8C', a: alert2 },
{ t:'Stripe webhook delay', d:'>4s · revisar conector', col:'#FFD93D', a: alert3 },
].map((al, i) => (
))}
);
}
/* ═══════════════════════════════════════════════════════════════════════
SCENE 5 — STATS (39 → 46)
═══════════════════════════════════════════════════════════════════════ */
function SceneStats() {
const { localTime, duration } = useSprite();
const opacity = ramp(localTime, 0, 0.8) * (1 - ramp(localTime, duration - 0.6, duration));
const stats = [
{ v: 4, suf:'', l:'productos en producción' },
{ v: 100, suf:'%', l:'diseño + dev integrado' },
{ v: 12, suf:'sem', l:'a primer launch', pre: '<' },
{ v: 0, suf:'', l:'plantillas reutilizadas' },
];
const headerIn = ramp(localTime, 0.2, 1.0);
return (
▸ SEC.04 / PROOF · vital signs
El estudio en números.
{stats.map((s, i) => {
const a = ramp(localTime, 0.8 + i * 0.18, 1.4 + i * 0.18, Easing.easeOutBack);
const count = ramp(localTime, 1.0 + i * 0.15, 2.4 + i * 0.15, Easing.easeOutCubic);
const display = s.v === 100 ? Math.floor(s.v * count)
: Math.floor(s.v * count);
return (
{s.pre || ''}{display}{s.suf}
{s.l}
);
})}
);
}
/* ═══════════════════════════════════════════════════════════════════════
SCENE 6 — PROCESS (46 → 53)
═══════════════════════════════════════════════════════════════════════ */
function SceneProcess() {
const { localTime, duration } = useSprite();
const opacity = ramp(localTime, 0, 0.8) * (1 - ramp(localTime, duration - 0.6, duration));
const steps = [
{ n: '01', t: 'BRIEF', tag: '1 sem', d: 'Entendemos el negocio. No el ticket.' },
{ n: '02', t: 'SISTEMA', tag: '2-3 sem', d: 'Identidad, tokens, voz. Antes de codear.' },
{ n: '03', t: 'BUILD', tag: '6-12 sem', d: 'Sprints quincenales, deploy continuo.' },
{ n: '04', t: 'LAUNCH', tag: 'día 1', d: 'Operativo. Cobranza, soporte, métricas.' },
];
// current step pointer based on time
const stepLen = (duration - 1.2) / steps.length;
const idx = Math.min(steps.length - 1, Math.floor((localTime - 0.3) / stepLen));
return (
▸ SEC.05 / PROCESO · de 0 a producto
De cero a producto. En semanas.
{/* Progress rail */}
{steps.map((s, i) => {
const a = ramp(localTime, 0.4 + i * 0.5, 1.0 + i * 0.5, Easing.easeOutBack);
const active = i === idx;
return (
{s.n}
{s.tag}
{s.t}
{s.d}
);
})}
);
}
/* ═══════════════════════════════════════════════════════════════════════
SCENE 7 — OUTRO (53 → 60)
═══════════════════════════════════════════════════════════════════════ */
function SceneOutro() {
const { localTime, duration } = useSprite();
const a1 = ramp(localTime, 0.2, 1.2, Easing.easeOutBack);
const a2 = ramp(localTime, 1.4, 2.4);
const a3 = ramp(localTime, 2.8, 3.6);
const out = 1 - ramp(localTime, duration - 0.6, duration);
return (
▸ MISMO ESTUDIO · CUATRO LENGUAJES
Cuatro
UNIVERSOS.
↓ contemos el quinto.
▸ hola@bataj.systems
);
}
/* ═══════════════════════════════════════════════════════════════════════
COMPOSITION
═══════════════════════════════════════════════════════════════════════ */
function ProductsScrollReel() {
return (
);
}
Object.assign(window, { ProductsScrollReel });