// Live companion — second-device coaching view. // // The agent records on their phone; this page runs on a second device // (laptop, iPad) on the same Google account. No pairing dance: we // authenticate via the existing fb_session cookie like the rest of the // SPA, ask /live/sessions/current for whatever session is in flight, // and show a glanceable coaching panel + "Check in now" button. // // Flow: // 1. Page checks /auth/me — bounce to sign in if needed. // 2. Poll /live/sessions/current every 3s. While nothing is live, // show a friendly empty state ("Start a recording on your phone…"). // 3. Once a live session is detected, switch to its slim view + // "Check in now" button. // 4. Tap → POST /live/sessions/{id}/check_in → wait for the iPhone's // polling loop to pick it up and trigger a snapshot. Watch for // session.last_check_in_id == our request id to know it landed. async function liveFetchJSON(path, init = {}) { const r = await fetch(path, { credentials: 'include', ...init, headers: { 'Content-Type': 'application/json', ...(init.headers || {}), }, }); if (r.status === 401) { const e = new Error('unauthenticated'); e.status = 401; throw e; } if (!r.ok) { let detail = ''; try { const j = await r.clone().json(); detail = j?.detail || j?.error || j?.message || ''; } catch { try { detail = (await r.text()).slice(0, 280); } catch {} } const e = new Error(detail || `${r.status} ${r.statusText}`); e.status = r.status; throw e; } try { return await r.json(); } catch { return {}; } } function fmtRelativeSeconds(iso) { if (!iso) return 'just now'; const t = Date.parse(iso); if (Number.isNaN(t)) return 'just now'; const secs = Math.max(0, Math.floor((Date.now() - t) / 1000)); if (secs < 30) return 'just now'; if (secs < 60) return `${secs}s ago`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); return `${hrs}h ago`; } function LiveCoach() { const [authState, setAuthState] = React.useState('checking'); // checking | signedout | signedin const [session, setSession] = React.useState(null); // slim _live_session_view, or null const [fetchErr, setFetchErr] = React.useState(''); const [checkInBusy, setCheckInBusy] = React.useState(false); const [pendingCheckInId, setPendingCheckInId] = React.useState(null); const [tick, setTick] = React.useState(0); // forces the "Updated Xs ago" label to reflow // Auth gate. Done once on mount; if signed in we kick off the polling // effect, otherwise show a Sign-in button (foyerSignIn from index.html). React.useEffect(() => { (async () => { const me = await window.foyerMe?.(); setAuthState(me ? 'signedin' : 'signedout'); })(); }, []); // Poll loop — only runs while signed in. The /live/sessions/current // endpoint returns the most-recently-snapshotted in-flight session, // or {session: null} when nothing is live. React.useEffect(() => { if (authState !== 'signedin') return; let alive = true; async function once() { try { let s = null; if (session?.id) { // We're already tracking a session — poll it directly so the // companion keeps watching even after is_live flips to false // (final tick lands a few seconds after End Session). s = await liveFetchJSON(`/live/sessions/${session.id}`); } else { const r = await liveFetchJSON('/live/sessions/current'); s = r.session; } if (!alive) return; setSession(s); setFetchErr(''); if (s && pendingCheckInId && s.last_check_in_id === pendingCheckInId) { setCheckInBusy(false); setPendingCheckInId(null); } } catch (e) { if (!alive) return; if (e.status === 401) { setAuthState('signedout'); return; } setFetchErr(e.message || 'Could not reach the backend.'); } } once(); const t = setInterval(once, 3000); return () => { alive = false; clearInterval(t); }; }, [authState, session?.id, pendingCheckInId]); // 1s ticker for the "Updated Ns ago" label. React.useEffect(() => { const t = setInterval(() => setTick((v) => v + 1), 1000); return () => clearInterval(t); }, []); async function requestCheckIn() { if (!session?.id || checkInBusy) return; setCheckInBusy(true); try { const r = await liveFetchJSON(`/live/sessions/${session.id}/check_in`, { method: 'POST', body: JSON.stringify({}), }); setPendingCheckInId(r.check_in_id); } catch (e) { setCheckInBusy(false); if (e.status === 401) { setAuthState('signedout'); return; } window.foyerToast?.({ message: e.message || 'Check-in failed', kind: 'error' }); } } // ---- render branches ---- if (authState === 'checking') { return (
Use the same Google account you sign in with on the phone. Whatever's recording there will show up here automatically.
Start a session on your phone. Once it's running, this page will switch over and let you ask for live coaching whenever buyers wander off to look around.
{fetchErr && (