// 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 (
);
}
// ── 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 */}
{/* 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 */}
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 */}
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 */}
{/* 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}
↑
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 */}
{/* Lookup beam */}
{/* Vehicle card */}
{/* Customer card */}
{/* Service card */}
{/* Invoice total chip */}
);
}
function DataCard({ top, progress, eyebrow, title, meta }) {
return (
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 */}
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 (
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (
);
}
function ConfigRow({ label, value }) {
return (
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 (
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 (
);
}
function GarageIcon() {
return (
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 */}
{/* 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 });