// METBooker animated promo — looping scenes for embed // ── Brand ─────────────────────────────────────────────────────────────────── const BRAND = { lime: '#bcdf05', limeDark: '#a3c204', limeBright: '#d4f72f', black: '#111111', blackSoft: '#1a1a1a', blackHard: '#0a0a0a', white: '#ffffff', offwhite: '#f5f5f5', gray: '#9a9a9a', grayDim: '#666', grayLight: '#e8e8e8', border: 'rgba(255,255,255,0.10)', muted: 'rgba(255,255,255,0.55)', }; const FONTS = { // Gotham Ultra fallback: Montserrat 900 (closest free geometric heavy) head: '"Montserrat", "Arial Black", Impact, system-ui, sans-serif', body: '"Montserrat", system-ui, sans-serif', mono: '"JetBrains Mono", "SF Mono", ui-monospace, monospace', }; // Scene timing — overlap windows by 0.3s for crossfade const TOTAL = 27.5; const SCENE = [ { i: 1, label: 'Eliminate No-Shows', start: 0, in: 0.2, out: 5.2 }, { i: 2, label: 'Free Up Time', start: 4.9, in: 5.1, out: 10.1 }, { i: 3, label: 'Capture Data', start: 9.8, in: 10.0, out: 15.0 }, { i: 4, label: 'Customer Experience', start: 14.7, in: 14.9, out: 19.9 }, { i: 5, label: 'Business Flexibility', start: 19.6, in: 19.8, out: 24.8 }, ]; const OUTRO = { start: 24.5, in: 24.7, out: 27.5 }; // ── Looping stage (no controls — for embed) ───────────────────────────────── function LoopStage({ width, height, duration, background, children }) { const stageRef = React.useRef(null); const rafRef = React.useRef(null); const lastRef = React.useRef(null); const [time, setTime] = React.useState(0); const [scale, setScale] = React.useState(1); React.useEffect(() => { const step = (ts) => { if (lastRef.current == null) lastRef.current = ts; const dt = (ts - lastRef.current) / 1000; lastRef.current = ts; setTime((t) => (t + dt) % duration); rafRef.current = requestAnimationFrame(step); }; rafRef.current = requestAnimationFrame(step); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); lastRef.current = null; }; }, [duration]); React.useEffect(() => { const el = stageRef.current; if (!el) return; const measure = () => { const s = Math.min(el.clientWidth / width, el.clientHeight / height); setScale(s); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); window.addEventListener('resize', measure); return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; }, [width, height]); return (
{children}
); } // ── Helpers ───────────────────────────────────────────────────────────────── const lerp = (a, b, t) => a + (b - a) * t; const clamp01 = (v) => Math.max(0, Math.min(1, v)); // Standard sprite entry: opacity + slide. Returns { opacity, translateY }. function entry(localTime, dur = 0.6, distance = 24, ease = Easing.easeOutCubic) { const t = ease(clamp01(localTime / dur)); return { opacity: t, translateY: (1 - t) * distance }; } function fadeInOut(time, inStart, inDur, outStart, outDur) { if (time < inStart) return 0; if (time < inStart + inDur) return Easing.easeOutCubic((time - inStart) / inDur); if (time < outStart) return 1; if (time < outStart + outDur) return 1 - Easing.easeInCubic((time - outStart) / outDur); return 0; } // ── Persistent chrome — logo + progress + scene label ─────────────────────── function Chrome({ portrait, sceneIndex, total }) { const PAD = portrait ? 50 : 60; const small = portrait ? 18 : 16; return ( {/* Top-left: METBooker wordmark */}
METBooker
{/* Top-right: scene label */}
0{sceneIndex} 0{total}
{/* Bottom progress dots */} ); } function ProgressDots({ portrait }) { const time = useTime(); const PAD = portrait ? 50 : 60; return (
{SCENE.map((s, idx) => { const active = time >= s.start && time < (SCENE[idx + 1]?.start ?? OUTRO.start); return (
); })}
); } // ── Scene wrapper — gives consistent headline + visual layout ─────────────── function SceneFrame({ portrait, eyebrow, headLines, accentIndex, visual, sceneIdx }) { const { localTime, duration } = useSprite(); const inDur = 0.7; const outStart = duration - 0.55; // Headline word-by-word reveal const HeadLine = ({ text, color, delay }) => { const t = clamp01((localTime - delay) / 0.55); const e = Easing.easeOutCubic(t); return (
{text}
); }; // Exit fade for the whole scene block let exitOpacity = 1; if (localTime > outStart) { exitOpacity = 1 - Easing.easeInCubic(clamp01((localTime - outStart) / 0.55)); } const headSize = portrait ? 110 : 104; const headPad = portrait ? 50 : 56; if (portrait) { return (
{/* Top half: headline */}
{eyebrow}

{headLines.map((line, i) => ( ))}

{/* Bottom half: visual */}
{visual}
); } // Landscape return (
{/* Left: headline */}
{eyebrow}

{headLines.map((line, i) => ( ))}

{/* Right: visual */}
{visual}
); } // ═══════════════════════════════════════════════════════════════════════════ // SCENE 1 — Eliminate No-Shows: booking + deposit secured // ═══════════════════════════════════════════════════════════════════════════ function Scene1Visual() { const { localTime } = useSprite(); const cardIn = clamp01((localTime - 0.4) / 0.7); const payIn = clamp01((localTime - 1.1) / 0.6); const stampIn = clamp01((localTime - 1.9) / 0.5); const amountT = clamp01((localTime - 2.1) / 0.9); const ghostOut = clamp01((localTime - 1.7) / 0.4); const stampScale = Easing.easeOutBack(stampIn); const stampRotate = lerp(-20, -8, stampIn); // Money amount counter £0 → £50 const amount = Math.round(lerp(0, 50, Easing.easeOutCubic(amountT))); return (
{/* Booking card */}
Booking #4127
Sarah Mitchell
MOT + Full Service · Ford Focus
{/* Old "no-show" ghost calendar entry — gets crossed out */}
0 ? 1 : 0), transform: `translateY(${ghostOut * -12}px)`, fontFamily: FONTS.body, color: BRAND.muted, position: 'absolute', }}>
NO-SHOW
Empty bay · Lost revenue
£0
{/* Payment card sliding in */}
•••• •••• •••• 4127
S MITCHELL 11/27
{/* "DEPOSIT SECURED" stamp */}
£{amount} SECURED
); } // ═══════════════════════════════════════════════════════════════════════════ // SCENE 2 — Free Up Time: customers self-book from your availability // ═══════════════════════════════════════════════════════════════════════════ function Scene2Visual() { const { localTime } = useSprite(); // Calendar grid — 5 columns (days) x 6 rows (time slots) const DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI']; const TIMES = ['08', '09', '10', '11', '12', '14']; // Pre-booked slots (already in the system) const preBooked = new Set(['0-2', '1-0', '2-3', '3-1', '4-4']); // Auto-booked during animation (with delays) const autoBookings = [ { key: '0-0', name: 'J. Patel', t: 0.4, color: BRAND.lime }, { key: '2-1', name: 'M. Chen', t: 0.7, color: BRAND.lime }, { key: '1-4', name: 'L. Brooks', t: 1.0, color: BRAND.lime }, { key: '3-3', name: 'A. Singh', t: 1.3, color: BRAND.lime }, { key: '4-1', name: 'D. Kelly', t: 1.6, color: BRAND.lime }, { key: '0-5', name: 'R. Owens', t: 1.9, color: BRAND.lime }, { key: '2-4', name: 'K. Hayes', t: 2.2, color: BRAND.lime }, ]; const bookedMap = {}; autoBookings.forEach(b => { bookedMap[b.key] = b; }); // Counter const countT = clamp01((localTime - 0.4) / 2.4); const count = Math.floor(lerp(5, 12, Easing.easeOutCubic(countT))); const cellW = 110; const cellH = 60; const gap = 8; const gridW = DAYS.length * (cellW + gap) - gap; const gridH = TIMES.length * (cellH + gap) - gap; return (
{/* Self-service callout card */}
LIVE · CUSTOMER BOOKING
{/* Calendar header */}
{DAYS.map((d, i) => (
{d}
))}
{/* Calendar grid */}
{TIMES.map((t, row) => (
{t}:00
{DAYS.map((d, col) => { const k = `${col}-${row}`; const pre = preBooked.has(k); const auto = bookedMap[k]; const cellEntryT = clamp01((localTime - 0.3 - col * 0.04 - row * 0.02) / 0.3); const autoT = auto ? clamp01((localTime - auto.t) / 0.45) : 0; const autoEase = auto ? Easing.easeOutBack(autoT) : 0; return (
{pre && (
)} {auto && autoT > 0 && (
{auto.name.split(' ').map(p => p[0]).join('')}
)}
); })}
))}
{/* Bottom counter */}
BOOKINGS THIS WEEK
{count}
PHONE CALLS
0
); } // ═══════════════════════════════════════════════════════════════════════════ // SCENE 3 — Capture Important Data // ═══════════════════════════════════════════════════════════════════════════ function Scene3Visual() { const { localTime } = useSprite(); const plateIn = clamp01((localTime - 0.2) / 0.5); const lookupT = clamp01((localTime - 0.9) / 0.5); const card1In = clamp01((localTime - 1.4) / 0.45); const card2In = clamp01((localTime - 1.7) / 0.45); const card3In = clamp01((localTime - 2.0) / 0.45); const totalIn = clamp01((localTime - 2.4) / 0.5); return (
{/* UK Number plate */}
GB
MET 1B
{/* Lookup beam */}
{/* Vehicle card */} {/* Customer card */} {/* Service card */} {/* Invoice total chip */}
INVOICE £289.00
); } function DataCard({ top, progress, eyebrow, title, meta }) { return (
{eyebrow}
{title}
{meta}
); } // ═══════════════════════════════════════════════════════════════════════════ // SCENE 4 — Customer Experience // ═══════════════════════════════════════════════════════════════════════════ function Scene4Visual() { const { localTime } = useSprite(); const phoneIn = clamp01((localTime - 0.2) / 0.6); const confIn = clamp01((localTime - 0.8) / 0.4); const emailT = clamp01((localTime - 1.3) / 0.9); const calT = clamp01((localTime - 1.8) / 0.9); return (
{/* Phone */}
{/* Screen */}
{/* notch */}
METBOOKER
You're booked in.
{/* Confirmation panel */}
Confirmed · #4127
Thu 14 Nov · 10:30 AM
MOT + Full Service
{/* Mini buttons */}
0.05} /> 0.05} />
{/* Email envelope flying out (right) */} } title="Confirmation email" sub="sarah@email.com" accent={false} /> {/* Calendar invite flying out (left) */} } title="Calendar invite" sub="Thu 14 Nov · 10:30" accent={true} />
); } function MiniBtn({ label, active }) { return (
{label}
); } function Notification({ progress, startX, startY, endX, endY, rotateEnd, icon, title, sub, accent }) { if (progress <= 0) return null; const eased = Easing.easeOutCubic(progress); const x = lerp(startX, endX, eased); const y = lerp(startY, endY, eased); const rot = lerp(0, rotateEnd, eased); return (
{icon}
{title}
{sub}
); } // ═══════════════════════════════════════════════════════════════════════════ // SCENE 5 — Business Flexibility (van + garage + config) // ═══════════════════════════════════════════════════════════════════════════ function Scene5Visual() { const { localTime } = useSprite(); const vanIn = clamp01((localTime - 0.2) / 0.6); const garIn = clamp01((localTime - 0.5) / 0.6); const toggleT = clamp01((localTime - 1.2) / 0.6); const configIn = clamp01((localTime - 1.6) / 0.6); // Toggle oscillates between left and right const tphase = ((localTime - 1.2) % 2) / 2; const toggleSide = localTime > 1.2 ? (tphase < 0.5 ? Easing.easeInOutCubic(tphase * 2) : 1 - Easing.easeInOutCubic((tphase - 0.5) * 2)) : 0; return (
{/* Van (mobile) */} } /> {/* Garage (static) */} = 0.5} label="STATIC" sub="Workshop" icon={} /> {/* Toggle bar */}
MOBILE = 0.5 ? BRAND.black : BRAND.muted, transition: 'color 200ms' }}>STATIC
{/* Config rows */}
); } function ModeCard({ x, y, progress, active, label, sub, icon }) { return (
{icon}
{label}
{sub}
); } function ConfigRow({ label, value }) { return (
{label}
{value}
); } // ═══════════════════════════════════════════════════════════════════════════ // OUTRO // ═══════════════════════════════════════════════════════════════════════════ function Outro({ portrait }) { const { localTime, duration } = useSprite(); const t1 = clamp01((localTime - 0.2) / 0.7); const t2 = clamp01((localTime - 0.7) / 0.7); const t3 = clamp01((localTime - 1.2) / 0.7); const exitT = clamp01((localTime - (duration - 0.4)) / 0.4); const fade = 1 - exitT; return (
METBooker
Online booking + payments for the automotive trade
BOOK A DEMO
metbooker.com
); } // ── Icon primitives ──────────────────────────────────────────────────────── function Check({ size = 18, color = '#111' }) { return ( ); } function Chip({ label }) { return (
{label}
); } function EnvelopeIcon() { return ( ); } function CalendarIcon() { return ( ); } function VanIcon() { return ( {/* Van body */} {/* Window */} {/* Divider */} {/* Wheels */} ); } function GarageIcon() { return ( {/* Roof */} {/* Building */} {/* Door */} ); } // ═══════════════════════════════════════════════════════════════════════════ // MAIN APP // ═══════════════════════════════════════════════════════════════════════════ function MetBookerAnimation() { const [portrait, setPortrait] = React.useState(() => typeof window !== 'undefined' && window.innerHeight > window.innerWidth ); React.useEffect(() => { const update = () => setPortrait(window.innerHeight > window.innerWidth); update(); window.addEventListener('resize', update); return () => window.removeEventListener('resize', update); }, []); const w = portrait ? 900 : 1600; const h = portrait ? 1600 : 720; return ( {/* Subtle background grid */} {/* Scenes */} } /> } /> } /> } /> } /> {/* Persistent chrome (visible during scenes only, not outro) */} ); } function SceneShell({ sceneIdx, portrait, eyebrow, headLines, accentIndex, visual }) { return ( ); } function PersistentChrome({ portrait }) { const time = useTime(); const visible = time < OUTRO.start; const opacity = visible ? 1 : Math.max(0, 1 - (time - OUTRO.start) / 0.3); if (opacity <= 0) return null; // active scene let activeIdx = 0; for (let i = 0; i < SCENE.length; i++) { if (time >= SCENE[i].start) activeIdx = i; } const PAD = portrait ? 50 : 40; return (
{/* Top-left wordmark */}
METBooker
{/* Top-right scene counter */}
0{activeIdx + 1} 05
{/* Bottom progress dots */}
{SCENE.map((s, idx) => (
))}
); } function BgGrid({ portrait }) { return (
); } // Expose to window so the HTML can mount Object.assign(window, { MetBookerAnimation });