/* 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. */}
{ready ? 'SESSION READY' : transcribing ? 'TRANSCRIBING' : ending ? 'STOPPING…' : 'LISTENING'} {ready ? `${transcriptLines.length} LEADS` : `${mm}:${ss}`}
{/* 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.
{transcriptLines.slice(0, linesShown).map((ln, i) => ( ))}
) : transcribing ? ( // TRANSCRIBING — wave fades, spinner shows
SEPARATING 5 VOICES…
Tagging each guest
) : ( // 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 && (
{showOfflineFlash ? ( ) : ( )} {ready ? `5 LEADS · SCORED` : showOfflineFlash ? 'WORKS OFFLINE' : `SAVED · ${savedPulse}m AGO`}
)} {/* Tab bar — same as actual app */}
{[ { i: 'home', l: 'Home' }, { i: 'rec', l: 'Record', active: true }, { i: 'kiosk', l: 'Kiosk' }, { i: 'leads', l: 'Leads' }, { i: 'more', l: 'More' }, ].map(t => (
{t.i === 'home' && } {t.i === 'rec' && <>} {t.i === 'kiosk' && <>} {t.i === 'leads' && <>} {t.i === 'more' && <>} {t.l}
))}
); }; // 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. */}
WORKING WITH AN AGENT?
{['no', 'yes'].map(opt => { const selected = agentSet && guest.hasAgent === opt; return ( {opt === 'no' ? 'Not yet' : 'Yes'} ); })}
{/* 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 && (
Welcome, {guest.name.split(' ')[0]}
Saved · ready to listen
LEAD · {guest.hasAgent === 'no' ? 'BUYER (UNREPPED)' : 'BUYER (HAS AGENT)'}
)}
); }; // 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 (
{/* Browser chrome */}
{['#3a3328','#3a3328','#3a3328'].map((c, i) => ( ))}
openhousecopilot.com /#/leads
{/* App: sidebar + bulk send panel */}
{/* SIDEBAR */}
OPEN HOUSE
{navItems.slice(0, 2).map(it => )}
LIBRARY
{navItems.slice(2).map(it => )}
JH
John H.
{/* MAIN — bulk send cockpit */}
{/* Header */}
Leads
{allSent ? `✓ ${queue.length} SENT IN ${completedAt.toFixed(1)}s` : `${sentCount}/${queue.length} SENT`}
{/* Ask-your-inbox prompt — the bulk send trigger. The cursor types the prompt, then "BUILDING PLAN…", then the recipient count. */}
{promptTyped.split('@SpringBuyerCredit').map((part, i, arr) => ( {part} {i < arr.length - 1 && ( @SpringBuyerCredit )} ))} {!promptDone && ( )} {showBuild && ( BUILDING PLAN… )} {showPlan && ( {queue.length} RECIPIENTS · PERSONALIZED )}
{/* Recipient grid — 4 columns × ~4 rows for 14 tiles */}
{queue.map((q, i) => ( ))}
{/* 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 && (
S
Sarah Chen
DRAFT · PERSONALIZED FROM TRANSCRIPT
INTENT 94
{['Pre-approved $1.4M', '60-day close', 'Loves the kitchen'].map(chip => ( {chip} ))}
Hi Sarah —
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 (
{/* Avatar with state-driven status pip */}
{lead.n.charAt(0)} {isSent && ( )} {isSending && ( )} {isDrafting && ( )}
{lead.n}
{isSent ? '✓ SENT' : isSending ? 'SENDING…' : isDrafting ? 'DRAFTING…' : 'QUEUED'}
); } // 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 (
{icon} {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 (
{devices.map((d, i) => { if (idx !== i) return null; const anim = d.id === 'laptop' ? 'hdLaptopOpen 1.1s cubic-bezier(.22,1,.36,1) both' : d.id === 'ipad' ? 'hdIPadIn 0.9s cubic-bezier(.22,1,.36,1) both' : 'hdPhoneIn 0.85s cubic-bezier(.22,1,.36,1) both'; const scale = d.id === 'laptop' ? 0.58 : d.id === 'ipad' ? 0.62 : 0.92; const base = d.id === 'laptop' ? { w: 880, h: 580 } : d.id === 'ipad' ? { w: 920, h: 660 } : { w: 300, h: 612 }; return (
{d.id === 'phone' && } {d.id === 'ipad' && } {d.id === 'laptop' && }
); })}
{devices.map((d, i) => ( ))}
); }; Object.assign(window, { HeroDevices, HDIPhone, HDIPad, HDLaptop, VoiceWave });