// 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 (