// MiraChat — replaces the old VoiceCoach component on the marketing site. // // What changed (vs the old window.claude / web-speech voice demo): // - Calls the real KeepAfter backend (the same Anthropic-backed Mira the iOS // app uses), via the public /api/coach/public-chat endpoint. // - No voice — chat only. The voice demo was misleading because voice isn't // in the iOS MVP. We don't ship demos for features we don't have. // - 5-message session cap (server-enforced), with a "Download AFTER" CTA // that fades in at message 4 and replaces the input at message 5. // - Session id is generated client-side per page load (no PII, no cookies). // - All responses come from the same Mira system prompt as the app. // // Props: // seedQuestion — optional string. If provided, the question is pre-loaded // as the first user turn and Mira's reply streams in // automatically on mount. // greeting — optional string. Overrides Mira's opening line. // // Public-chat backend is rate-limited (per-IP + global daily cost ceiling) // and Haiku-only, so abuse just hits the "demo unavailable" message rather // than draining the API budget. const KA_BACKEND_URL = (typeof window !== 'undefined' && window.KEEPAFTER_BACKEND_URL) || 'https://after-backend.vercel.app'; const MC_COLORS = { bg: '#ffffff', panel: '#fafaf8', ink: '#0f0f0f', inkSoft: '#52525b', border: '#e2e0db', green: '#3d6b4f', greenDark: '#2a4d38', greenLight: '#e8f0eb', cream: '#fafaf8', }; const APP_STORE_URL = 'https://apps.apple.com/app/id0000000000'; // TODO: real id after App Store launch const DEFAULT_GREETING = "i'm mira, your keepafter coach. food noise, scale stuff, building habits that actually stick — that's my lane. what's going on?"; function genSessionId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); return 'mc-' + Math.random().toString(36).slice(2) + '-' + Date.now(); } function MiraChat({ seedQuestion, greeting }) { const resolvedGreeting = greeting || DEFAULT_GREETING; // Build initial messages synchronously in the useState initializer to avoid // any flash: greeting bubble (assistant) + optional seed question (user). const [messages, setMessages] = React.useState(() => { const init = [ { id: 'a-greeting', role: 'assistant', content: resolvedGreeting }, ]; if (seedQuestion) { init.push({ id: 'u-seed', role: 'user', content: seedQuestion }); } return init; }); const [input, setInput] = React.useState(''); // Start in thinking state immediately when a seed is present so the UI // shows "Thinking…" before the first effect fires. const [thinking, setThinking] = React.useState(!!seedQuestion); const [remaining, setRemaining] = React.useState(5); const [demoOver, setDemoOver] = React.useState(false); const [error, setError] = React.useState(null); const sessionIdRef = React.useRef(genSessionId()); const scrollRef = React.useRef(null); // Guard so the seed effect only fires once even in React Strict Mode. const seedFiredRef = React.useRef(false); React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, thinking]); // ── Core network helper ────────────────────────────────────────────────── // Extracted so both sendMessage() and the seed effect can reuse it. async function requestReply(historyForApi, userText) { let reply = ''; let isLast = false; let demoLimit = false; try { const res = await fetch(`${KA_BACKEND_URL}/api/coach/public-chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: userText, session_id: sessionIdRef.current, history: historyForApi, }), }); const data = await res.json().catch(() => ({})); if (res.status === 429 && data.demo_limit) { demoLimit = true; reply = data.error || 'Demo limit reached.'; } else if (!res.ok) { reply = data.error || 'Mira is unavailable right now. Please try again in a moment.'; setError('network'); } else { reply = (data.reply || '').trim(); if (typeof data.remaining === 'number') setRemaining(Math.max(0, data.remaining)); isLast = Boolean(data.is_last); } } catch (e) { reply = "Mira can't reach the network right now. Try again in a moment."; setError('network'); } setMessages((m) => [...m, { id: 'a-' + Date.now(), role: 'assistant', content: reply }]); setThinking(false); if (demoLimit || isLast || remaining <= 1) { setDemoOver(true); } } // ── Seed effect — fires once on mount if seedQuestion is present ───────── React.useEffect(() => { if (!seedQuestion || seedFiredRef.current) return; seedFiredRef.current = true; const seedHistory = [{ role: 'user', content: seedQuestion }]; requestReply(seedHistory, seedQuestion); }, []); // intentional empty deps — one-shot on mount // ── User-initiated send ────────────────────────────────────────────────── async function sendMessage(textOverride) { const text = (textOverride ?? input).trim(); if (!text || thinking || demoOver) return; const userId = 'u-' + Date.now(); setMessages((m) => [...m, { id: userId, role: 'user', content: text }]); setInput(''); setThinking(true); setError(null); // History we send to the backend (oldest → newest, sans greeting). const history = [...messages, { role: 'user', content: text }] .filter((m) => m.role === 'user' || m.role === 'assistant') .slice(-8) .map((m) => ({ role: m.role, content: m.content })); await requestReply(history, text); } return (
{/* Header */}
Mira
{demoOver ? 'Demo limit reached' : thinking ? 'Thinking…' : 'Ready'}
Live demo · {remaining} left
{/* Messages */}
{messages.map((m) => (
{m.content}
))} {thinking && (
mira is thinking…
)}
{/* Input or CTA */} {demoOver ? (
Mira remembers you in the app.
On the demo she starts fresh every session. In AFTER she remembers your goals, your food noise patterns, and what you told her last week.
Download AFTER
) : (