/* global React, Crest, Tag */
// Hero devices — rotating carousel: iPhone (live), iPad LANDSCAPE (agent home), Laptop (dashboard)
const { useState: useHD, useEffect: useHDEf, useRef: useHDRef } = React;
const useTyped = (target, delay = 0, step = 55) => {
const [val, setVal] = useHD('');
useHDEf(() => {
let raf;
const start = performance.now() + delay;
const tick = (now) => {
if (now < start) { raf = requestAnimationFrame(tick); return; }
const n = Math.min(target.length, Math.floor((now - start) / step));
setVal(target.slice(0, n));
if (n < target.length) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return [val, setVal];
};
const useDelayed = (initial, to, delay) => {
const [v, setV] = useHD(initial);
useHDEf(() => {
const t = setTimeout(() => setV(to), delay);
return () => clearTimeout(t);
}, []);
return v;
};
const useCount = (start, interval = 1000, delay = 0) => {
const [n, setN] = useHD(start);
useHDEf(() => {
let id;
const to = setTimeout(() => { id = setInterval(() => setN(x => x + 1), interval); }, delay);
return () => { clearTimeout(to); clearInterval(id); };
}, []);
return n;
};
const useStaggered = (schedule) => {
const [i, setI] = useHD(0);
useHDEf(() => {
const timers = schedule.map((t, k) => setTimeout(() => setI(k + 1), t));
return () => timers.forEach(clearTimeout);
}, []);
return i;
};
// iPhone — agent recording
// VoiceWave — flowing sine-wave layers radiating from a glowing center
// orb. Replaces every old "vertical bar VU meter" instance across the
// app. Visually: black canvas, multiple gold sine paths at different
// amplitudes/phases drifting horizontally, a soft gold glow in the
// middle. No mic icon — the orb itself is the voice metaphor.
//
// Usage: — sizes itself to its
// container. Pure CSS animation on each path so it doesn't burn CPU.
function VoiceWave({ width = 280, height = 140, orbSize = 64, animated = true }) {
// Each layer: amplitude (vertical swing), period (px per cycle),
// initial phase, stroke opacity, stroke width, scroll speed (s
// per cycle — negative scrolls right-to-left). The first three
// dominate the silhouette; the rest fill in the depth.
const layers = [
{ amp: 22, period: 95, phase: 0.0, opacity: 0.85, sw: 1.6, dur: 7.0 },
{ amp: 14, period: 70, phase: 1.1, opacity: 0.65, sw: 1.4, dur: 5.5 },
{ amp: 28, period: 130, phase: 2.0, opacity: 0.45, sw: 1.2, dur: 9.0 },
{ amp: 10, period: 55, phase: 2.8, opacity: 0.55, sw: 1.0, dur: 4.5 },
{ amp: 18, period: 105, phase: 0.6, opacity: 0.35, sw: 1.0, dur: 8.0 },
{ amp: 8, period: 42, phase: 1.7, opacity: 0.30, sw: 0.8, dur: 3.8 },
];
// The wave SVG is rendered 2× wider than the visible area; we shift
// each path horizontally to animate flow. That way the wave never
// shows a seam where the path ends.
const svgWidth = width * 2;
const cy = height / 2;
function buildPath(layer) {
let d = `M 0 ${cy}`;
for (let x = 0; x <= svgWidth; x += 3) {
const y = cy + Math.sin(x / layer.period + layer.phase) * layer.amp;
d += ` L ${x.toFixed(1)} ${y.toFixed(2)}`;
}
return d;
}
return (
{/* Waves — mask to fade out at the edges so the loop is invisible */}
{/* Center orb — no mic icon, just a gold glow */}
);
}
// iPhone — captures the open house, then ends the session and shows
// the transcript transcribing with 5 identified speakers, scored by
// intent. Matches the real app: during recording we only show "we'll
// identify each speaker at the end" — the reveal is the payoff.
// Phases: RECORDING (wave + floating quote bubbles + offline flash) →
// ENDING → TRANSCRIBING (spinner) → READY (5 speakers cascade in
// best-intent-first with score rings filling around each avatar).
const HDIPhone = () => {
// Master 80ms tick — slightly slower than the 60ms version so the
// beats land instead of blurring past.
const tickFast = useCount(0, 80, 0);
// Phase plan @ 80ms/tick — total ~7.4s:
// RECORDING ticks 0..38 (3.04s) — wave + 4 quote bubbles drift up
// ENDING ticks 39..44 (0.40s) — "STOPPING…" banner
// TRANSCRIBE ticks 45..63 (1.52s) — spinner overlay
// READY ticks 64..93 (~2.4s) — 5 speakers cascade, score rings fill
const T_REC_END = 39;
const T_ENDING_END = 45;
const T_TRANS_END = 64;
const t = tickFast;
const recording = t < T_REC_END;
const ending = t >= T_REC_END && t < T_ENDING_END;
const transcribing = t >= T_ENDING_END && t < T_TRANS_END;
const ready = t >= T_TRANS_END;
// Elapsed timer — sped to feel like a real open house compressed.
const totalSec = 14 * 60 + 22 + (recording ? Math.floor(t * 1.6) : 0);
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
const ss = String(totalSec % 60).padStart(2, '0');
// Quote bubbles that float up over the wave during recording — these
// are the things the agent would have to remember and write down by
// hand. Each appears, drifts up, and fades over ~13 ticks (1.04s at
// 80ms/tick). No live speaker naming — the real app defers that.
const quoteBubbles = [
{ text: "pre-approved $1.4M", appearAt: 5, kind: 'buyer' },
{ text: "60-day close", appearAt: 14, kind: 'buyer' },
{ text: "loves the kitchen", appearAt: 23, kind: 'buyer' },
{ text: "HOA?", appearAt: 32, kind: 'browser' },
];
// Brief "WORKS OFFLINE · 0 BARS" flash on the SAVED pill so agents
// catch that this thing keeps recording in basements.
const showOfflineFlash = recording && t >= 19 && t < 30;
// Transcript lines for the READY reveal. Ordered by buyer-intent
// score descending — the TikTok "best leads first" payoff.
const transcriptLines = [
{ first: 'Sarah', name: 'Sarah Chen', kind: 'buyer', score: 94, line: "Pre-approved $1.4M. 60-day close." },
{ first: 'David', name: 'David Lee', kind: 'buyer', score: 88, line: "We love the kitchen. Can we see the basement?" },
{ first: 'Mike', name: 'Mike Rodriguez', kind: 'seller', score: 76, line: "I've been here 15 years — kids off to college." },
{ first: 'Elena', name: 'Elena Morales', kind: 'browser', score: 41, line: "What's the HOA like?" },
{ first: 'Jennifer', name: 'Jennifer Park', kind: 'browser', score: 38, line: "Just curious — lease runs through 2027." },
];
// 5 ticks (~400ms at 80ms/tick) between each card appearing — gives
// each name + score ring time to land before the next slides in.
const readyTicks = ready ? t - T_TRANS_END : -1;
const linesShown = readyTicks < 0 ? 0 : Math.min(5, Math.floor(readyTicks / 5) + 1);
// Cloud "Saved" pulse — runs during recording.
const savedPulse = recording ? Math.max(1, Math.floor(t / 14)) : Math.floor(T_REC_END / 14);
return (
{/* Status bar */}
2:14●●●●●
{/* Phase banner — flips between Recording / Stopping /
Transcribing / Ready. Colors track the lifecycle. */}
{/* Listing title — matches the real app's Listening screen
("WE'LL IDENTIFY EACH SPEAKER AT THE END") so visitors see
we don't mislabel mid-recording. */}
{ready ? 'TRANSCRIPT · BEST INTENT FIRST'
: transcribing ? 'SEPARATING VOICES…'
: `412 W 78TH ST · ${recording ? 'IDENTIFIED AT END' : 'HOSTED'}`}
{ready ? '5 leads, scored' : 'Listening'}
{/* Main content area — wave during recording, spinner during
transcribing, transcript when ready. */}
{ready ? (
// READY — transcript card with 5 speakers, score ring around
// each avatar, cards cascade in best-intent-first. The score
// ring is the TikTok payoff: numbers tick up from 0.
) : (
// RECORDING / ENDING — wave + floating quote bubbles drift up
// and dissolve over the wave. The hint card explains that
// speakers are tagged at the end (mirrors the real app copy).
<>
{/* Floating quote bubbles — the things an agent would
otherwise have to scribble. Each appears at its
appearAt tick, drifts up + fades over 14 ticks. */}
{recording && quoteBubbles.map((qb) => {
const localT = t - qb.appearAt;
if (localT < 0 || localT > 14) return null;
const progress = localT / 14;
const opacity = progress < 0.15 ? progress / 0.15
: progress > 0.7 ? Math.max(0, (1 - progress) / 0.3) : 1;
return (
"{qb.text}"
);
})}
WE'LL IDENTIFY EACH SPEAKER AT THE END
Capturing every conversation
AUTO-PAUSES ON SILENCE · 14 HRS BATTERY
>
)}
{/* Status pill below the content. Saved during recording —
briefly flips to "WORKS OFFLINE" so visitors catch the
differentiator. "5 LEADS · SCORED" once transcript lands. */}
{!transcribing && (
);
};
// One row in the transcript reveal — name + score ring + intent tag + quote.
// The score ring is the visual hit: a circular stroke around the avatar
// fills from 0 to the lead's score over ~400ms once the row mounts, and
// the number ticks up in lockstep. Sage for warm buyers, gold for
// sellers, terracotta-ish dim for low-intent browsers.
function SpeakerLeadRow({ ln, index }) {
// Local 30ms tick — drives the score ring fill and number tick. Tied
// to the row's mount, not the parent's master clock, so each row's
// ring starts at 0 the moment it appears.
const [n, setN] = useHD(0);
useHDEf(() => {
let id;
let v = 0;
id = setInterval(() => {
v += 4;
if (v >= ln.score) { v = ln.score; clearInterval(id); }
setN(v);
}, 22);
return () => clearInterval(id);
}, []);
const ringColor = ln.score >= 70 ? 'var(--sage)'
: ln.score >= 50 ? 'var(--gold)'
: 'var(--text-muted)';
const C = 2 * Math.PI * 13; // circumference for r=13
const dashOffset = C * (1 - n / 100);
return (
{n}
{ln.name}
{ln.kind}
"{ln.line}"
);
}
// iPad LANDSCAPE — KIOSK in fullscreen guest mode. Side rail is hidden
// (locked kiosk), big listing photo on the left, sign-in form on the
// right. Two guests sign in back-to-back so visitors see throughput.
// Fields type in letter-by-letter (with a blinking caret on the
// active field) so the form-fill reads as a real person at the kiosk.
const HDIPad = () => {
// Two guests cycled through, in order. Counter ticks 6 → 8 across
// the cycle so the listing photo's "N SIGNED IN" badge climbs.
const guests = [
{ name: 'Sarah Chen', email: 'sarah.chen@example.com', phone: '(212) 555-0101', hasAgent: 'no' },
{ name: 'Mike Rodriguez', email: 'mike.r@example.com', phone: '(212) 555-0142', hasAgent: 'yes' },
];
// 60ms tick. Per-guest phase plan (61 ticks ≈ 3.66s):
// NAME ticks 0..10 (0.66s) — letter-by-letter typing
// PAUSE ticks 10..12 (0.12s) — beat between fields
// EMAIL ticks 12..30 (1.08s) — longer string, more chars
// PAUSE ticks 30..32 (0.12s)
// PHONE ticks 32..46 (0.84s)
// PAUSE ticks 46..48 (0.12s)
// CHIP ticks 48..52 (0.24s) — agent chip clicks
// PRESS ticks 52..55 (0.18s) — submit button glows + presses
// SUCCESS ticks 55..61 (0.36s) — overlay holds
const T_NAME = 10, T_PAUSE_1 = 2, T_EMAIL = 18, T_PAUSE_2 = 2;
const T_PHONE = 14, T_PAUSE_3 = 2, T_CHIP = 4, T_PRESS = 3;
const T_SUCCESS = 6;
const PER_GUEST = T_NAME + T_PAUSE_1 + T_EMAIL + T_PAUSE_2 + T_PHONE
+ T_PAUSE_3 + T_CHIP + T_PRESS + T_SUCCESS;
const TOTAL = PER_GUEST * guests.length;
const tickFast = useCount(0, 60, 0);
const t = Math.min(tickFast, TOTAL + 14);
const guestIdx = Math.min(guests.length - 1, Math.floor(t / PER_GUEST));
const guest = guests[guestIdx];
const localT = t - guestIdx * PER_GUEST;
// Per-field start ticks (relative to the current guest cycle).
const NAME_START = 0;
const NAME_END = NAME_START + T_NAME;
const EMAIL_START = NAME_END + T_PAUSE_1;
const EMAIL_END = EMAIL_START + T_EMAIL;
const PHONE_START = EMAIL_END + T_PAUSE_2;
const PHONE_END = PHONE_START + T_PHONE;
const CHIP_START = PHONE_END + T_PAUSE_3;
const CHIP_END = CHIP_START + T_CHIP;
const PRESS_END = CHIP_END + T_PRESS;
const nameActive = localT >= NAME_START && localT < NAME_END;
const emailActive = localT >= EMAIL_START && localT < EMAIL_END;
const phoneActive = localT >= PHONE_START && localT < PHONE_END;
const chipSet = localT >= CHIP_START;
const pressing = localT >= CHIP_END && localT < PRESS_END;
const showSuccess = localT >= PRESS_END;
// Return a typed substring of `target` that fills proportionally
// through the [startTick, startTick + duration) window. Before the
// window: empty. After: full string.
const typedSubstring = (target, startTick, duration) => {
if (localT < startTick) return '';
if (localT >= startTick + duration) return target;
const elapsed = localT - startTick;
return target.slice(0, Math.ceil((elapsed + 1) / duration * target.length));
};
// Listing badge counter — climbs each time a guest hits success.
let guestsIn = 6;
for (let i = 0; i < guests.length; i++) {
if (t >= i * PER_GUEST + PRESS_END) guestsIn += 1;
}
const nameTyped = typedSubstring(guest.name, NAME_START, T_NAME);
const emailTyped = typedSubstring(guest.email, EMAIL_START, T_EMAIL);
const phoneTyped = typedSubstring(guest.phone, PHONE_START, T_PHONE);
const agentSet = chipSet;
const termsOK = chipSet;
return (
{/* front camera — landscape top mid */}
{/* LISTING PHOTO PANE — mirrors the locked kiosk's full-bleed
listing splash. Gold "OPEN HOUSE" badge, address huge in
serif, price + specs at the bottom. */}
{/* OPEN HOUSE badge */}
OPEN HOUSE
{/* Address + price */}
412 W 78th St
Upper West Side
$1.29M
3BD · 2.5BA · 1,840 SF
{/* Live "guests signed in" pill — counts up so visitors see
automation in motion */}
{guestsIn} SIGNED IN
{/* SIGN-IN FORM PANE — gold scan-line wipes each field instead
of letter-by-letter typing. Keyed on guestIdx so each guest
cycle starts from a fresh form (no half-old values
bleeding across). */}
Welcome in
Quick sign-in so we can follow up.
{/* Form fields — each types its target string letter by
letter during the field's active window. A blinking gold
caret sits at the cursor on the currently-typing field. */}
{/* Agent chip — clicks at PHONE_END. Briefly bumps when set. */}
{/* Submit button — visibly compresses on the press tick. */}
{/* Success overlay — covers the form when each guest hits
success. Unmounts when the next guest's form keys in. */}
{showSuccess && (
);
};
// One labeled field in the kiosk form. Renders the value as it types
// in (parent passes a typed substring) with a blinking gold caret on
// the currently-active row.
function HeroKioskField({ label, value, active }) {
return (
{label}
{value || (active ? '' : …)}
{active && (
)}
);
}
// Laptop — BULK FOLLOW-UP COCKPIT. The agent types a prompt into "Ask
// your inbox" and the AI fans out across 14 leads: each tile cascades
// from PENDING → DRAFTING → DRAFTED → SENDING → SENT in rapid
// succession. Showcases scale (14 personalized emails) and speed
// (whole queue clears in ~4s). No slow per-char typing — the
// "personalization" is implied by each tile carrying the lead's
// name + tag + a per-recipient draft preview that fades in when
// drafted.
const HDLaptop = () => {
// 14 warm-buyer leads queued for the bulk send. Mixed tags + scores
// so the grid looks like a real inbox slice.
const queue = [
{ n: 'Sarah Chen', k: 'buyer', score: 94 },
{ n: 'Mike Rodriguez', k: 'seller', score: 76 },
{ n: 'Jennifer Park', k: 'browser', score: 38 },
{ n: 'David Lee', k: 'buyer', score: 88 },
{ n: 'Elena Morales', k: 'buyer', score: 82 },
{ n: 'Tom Walker', k: 'buyer', score: 91 },
{ n: 'Aisha Patel', k: 'seller', score: 71 },
{ n: 'Marcus Reed', k: 'buyer', score: 86 },
{ n: 'Lia Schmidt', k: 'buyer', score: 79 },
{ n: 'Ben Park', k: 'browser', score: 44 },
{ n: 'Olivia Reyes', k: 'buyer', score: 92 },
{ n: 'Noah Patel', k: 'seller', score: 68 },
{ n: 'Priya Joshi', k: 'buyer', score: 84 },
{ n: 'Carlos Diaz', k: 'buyer', score: 89 },
];
// 65ms tick — slower than 50ms so the cascade reads as deliberate
// rather than rushed.
const tickFast = useCount(0, 65, 0);
// Phase plan @ 65ms/tick — total 7.8s:
// PROMPT ticks 0..9 (0.59s) prompt fades in + cursor types
// BUILD ticks 10..18 (0.59s) "Building plan…" spinner
// CASCADE ticks 19..74 (3.6s) 14 tiles cascade through states
// INSPECTOR ticks 38..96 (3.8s) personalization modal slides in
// STAT BAND ticks 96..120 (1.56s) sage stat band slides up
// HOLD remainder
const T_PROMPT = 9;
const T_BUILD = 9;
const T_STAGGER = 3; // ~195ms between tile starts
const T_DRAFT = 7; // ~455ms drafting per tile
const T_SEND = 3; // ~195ms sending per tile
const PROMPT_START = 0;
const BUILD_START = T_PROMPT;
const PLAN_START = BUILD_START + T_BUILD;
const ALL_SENT_AT = PLAN_START + (queue.length - 1) * T_STAGGER + T_DRAFT + T_SEND;
const INSPECTOR_OPEN = 38;
const INSPECTOR_CLOSE = 96;
// Stat band waits for the inspector to slide back out, so the two
// never share the screen and the progress bar can fade cleanly
// before the band slides up.
const STAT_BAND_START = INSPECTOR_CLOSE;
const TOTAL_TICKS = 120;
const t = Math.min(tickFast, TOTAL_TICKS + 6);
const showPrompt = t >= PROMPT_START;
const showBuild = t >= BUILD_START && t < PLAN_START;
const showPlan = t >= PLAN_START;
const showInspector = t >= INSPECTOR_OPEN && t < INSPECTOR_CLOSE;
const showStatBand = t >= STAT_BAND_START;
// Prompt text — the cursor "types" by revealing characters. Looks
// fast (~3 chars/tick) but matches a realistic prompt.
const promptTarget = "Send @SpringBuyerCredit to all warm buyers from 412 W 78th";
const promptLen = t < PROMPT_START ? 0
: t >= PROMPT_START + T_PROMPT ? promptTarget.length
: Math.ceil((t - PROMPT_START) / T_PROMPT * promptTarget.length);
const promptTyped = promptTarget.slice(0, promptLen);
const promptDone = t >= PROMPT_START + T_PROMPT;
// Per-tile status. Each tile starts T_STAGGER ticks after the
// previous one. States cycle drafting → drafted → sending → sent.
function tileState(i) {
if (t < PLAN_START) return 'pending';
const startedAt = PLAN_START + i * T_STAGGER;
if (t < startedAt) return 'pending';
const localT = t - startedAt;
if (localT < T_DRAFT) return 'drafting';
if (localT < T_DRAFT + T_SEND) return 'sending';
return 'sent';
}
// Live counts for the progress bar + summary stat.
const sentCount = queue.filter((_, i) => tileState(i) === 'sent').length;
const inFlight = queue.filter((_, i) => {
const s = tileState(i);
return s === 'drafting' || s === 'sending';
}).length;
const allSent = sentCount === queue.length;
// The completion time — frozen once everything sends so the
// "X sent in 4.4s" stat doesn't keep climbing. Multiplier matches
// the master tick rate (65ms) so the number lines up with real time.
const completedAt = ALL_SENT_AT * 0.065; // seconds
const navItems = [
{ i: 'home', l: 'Home' },
{ i: 'kiosk', l: 'Kiosk' },
{ i: 'sess', l: 'Sessions' },
{ i: 'leads', l: 'Leads', active: true },
{ i: 'off', l: 'Offers' },
{ i: 'list', l: 'Listings' },
];
return (
{/* Bottom: progress bar. Fades out the moment the stat
band starts to slide up so the two never overlap. */}
{sentCount} SENT · {inFlight} IN FLIGHT · {queue.length - sentCount - inFlight} QUEUED
{allSent ? '✓ ALL SENT' : `${Math.round((sentCount / queue.length) * 100)}%`}
{/* PERSONALIZATION INSPECTOR — slides in from the right
mid-cascade. Shows ONE expanded draft with quote chips
pulled directly from the open-house transcript, so the
visitor sees this isn't boilerplate. */}
{showInspector && (
Thanks for stopping by 412 W 78th on Saturday. You mentioned wanting to close in 60 days and that you're pre-approved at $1.4M — wanted to send the Spring Buyer Credit summary that could shave ~30bps off your rate if we move this quarter.
Also: happy to walk you through the basement on a private — you didn't get to see it yesterday.
✓ APPROVED
SENDS TOMORROW · 9:14 AM
)}
{/* STAT BAND — slides up from the bottom at the climax.
The "four hours → 2.4 seconds" comparison is the
payoff stat that justifies the whole product. */}
{showStatBand && (
4 hrs
→ {completedAt.toFixed(1)}s
14 PERSONALIZED · EVERY QUOTE FROM THE ROOM
)}
);
};
// One recipient tile in the bulk-send grid. State drives an icon +
// label + color: pending (dim), drafting (gold spinner), sending
// (gold paper-plane), sent (green checkmark). Tile fades + shifts
// slightly on each transition so the cascade reads as motion.
function RecipientTile({ lead, state }) {
const isSent = state === 'sent';
const isSending = state === 'sending';
const isDrafting = state === 'drafting';
const isPending = state === 'pending';
const accent = isSent ? 'var(--sage)'
: isSending ? 'var(--gold)'
: isDrafting ? 'var(--gold)'
: 'rgba(255,255,255,0.3)';
return (
);
}
// Small helper for the laptop mock sidebar — one nav row matching the
// real web AppShell visual.
function NavRow({ item }) {
const icon = {
home: <>>,
kiosk: <>>,
sess: <>>,
leads: <>>,
off: <>>,
list: <>>,
}[item.i];
return (
{item.l}
);
}
// HeroDevices — rotating carousel
const HeroDevices = () => {
const [idx, setIdx] = useHD(0);
const tRef = useHDRef(null);
// Per-device dwell times — tuned to each device's full animation
// length plus a beat for the climax hold. Slowed from the original
// TikTok-pace tuning so each phase lands instead of blurring past.
// phone : 80ms × 94 ticks ≈ 7.5s
// ipad : 60ms × 122 ticks (2 guests) ≈ 7.3s
// laptop: 65ms × 120 ticks ≈ 7.8s
const dwellMs = [7500, 7500, 8000];
useHDEf(() => {
function advance() {
setIdx(i => {
const next = (i + 1) % 3;
tRef.current = setTimeout(advance, dwellMs[next]);
return next;
});
}
tRef.current = setTimeout(advance, dwellMs[0]);
return () => clearTimeout(tRef.current);
}, []);
const devices = [
{ id: 'phone', label: 'iPhone', caption: 'Records the room. Tags every voice.' },
{ id: 'ipad', label: 'iPad', caption: 'Hand to a guest. Sign-in does itself.' },
{ id: 'laptop', label: 'Laptop', caption: 'AI drafts each follow-up — you hit send.' },
];
return (