/* global React */ // ============================================================ // Shared brand components // ============================================================ // Brand mark — twin-houses logo at /mark-400.png (downsized from the // 1270x832 master in /mark.png). Single source for the rail, the crest, // and any marketing surfaces; switch the image and every surface picks // it up. JS identifier kept as `FoyerMark` so the rest of the JSX (which // references it cross-file) doesn't need touching. const FoyerMark = ({ size = 32, rounded = 7 }) => ( Open House Copilot ); const Crest = ({ size = 22, name = 'Open House Copilot' }) => (
{name}
); // Animated brand-mark GIF — drop this wherever the page would have shown a // generic spinner. The native tag handles GIF animation; we just frame // it consistently and trim with border-radius so it matches the iPad app's // FoyerLoadingView. Pass `label` to show a small caption underneath. const FoyerLoader = ({ size = 96, rounded = 14, label, padding = 0 }) => (
Loading {label && (
{label}
)}
); const Eyebrow = ({ children, num }) => (
{num && } {children}
); const Tag = ({ kind = 'buyer', children }) => ( {children || kind} ); const Stat = ({ value, label, suffix }) => (
{value} {suffix && {suffix}}
{label}
); const Hairline = ({ vertical = false, style = {} }) => (
); // ============================================================ // Real-data layer // ============================================================ // // Both /#/app and /#/session need the same shape: the signed-in user, the // list of session summaries, and full session payloads (with visitors, // analysis, lead_state) cached by id. foyerLoad() promises that, fetching // /auth/me + /sessions on first call and expanding every ready session in // parallel. The result is memoized on window.foyerCache so navigating // between routes doesn't re-fetch. // Pulls FastAPI's `{"detail": "..."}` body out of an errored response // so the UI can show what actually went wrong instead of a bare status // code. Falls through to `${status} ${statusText}` for non-JSON bodies. async function _readError(r) { let detail = ''; try { const j = await r.clone().json(); if (j && typeof j === 'object') { detail = j.detail || j.error || j.message || ''; } } catch { try { detail = (await r.text()).slice(0, 280); } catch {} } return detail ? `${detail}` : `${r.status} ${r.statusText}`; } const foyerApi = { async get(path) { const r = await fetch(path, { credentials: 'include' }); if (r.status === 401) throw new Error('unauthenticated'); if (!r.ok) throw new Error(await _readError(r)); return r.json(); }, async post(path, body) { const r = await fetch(path, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }); if (r.status === 401) throw new Error('unauthenticated'); if (!r.ok) throw new Error(await _readError(r)); return r.json(); }, async patch(path, body) { const r = await fetch(path, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }); if (r.status === 401) throw new Error('unauthenticated'); if (!r.ok) throw new Error(await _readError(r)); return r.json(); }, async del(path) { const r = await fetch(path, { method: 'DELETE', credentials: 'include' }); if (r.status === 401) throw new Error('unauthenticated'); if (!r.ok) throw new Error(await _readError(r)); try { return await r.json(); } catch { return {}; } }, }; async function foyerLoad({ force = false } = {}) { if (window.foyerCache && !force) return window.foyerCache.promise; const promise = (async () => { const user = await foyerApi.get('/auth/me'); const list = await foyerApi.get('/sessions'); const summaries = (list.sessions || []); const sessionsById = {}; await Promise.all(summaries.map(async (s) => { if (s.status === 'ready') { try { sessionsById[s.id] = await foyerApi.get(`/sessions/${s.id}`); } catch (e) { // Skip — keep loading the rest. The summary still shows in the list. } } })); return { user, summaries, sessionsById }; })(); window.foyerCache = { promise }; // Drop the cache on failure so a retry actually retries. promise.catch(() => { window.foyerCache = null; }); return promise; } function useFoyerData() { const [state, setState] = React.useState(() => ({ user: null, summaries: [], sessionsById: {}, loading: true, error: null, })); React.useEffect(() => { let alive = true; foyerLoad() .then(({ user, summaries, sessionsById }) => { if (!alive) return; setState({ user, summaries, sessionsById, loading: false, error: null }); }) .catch((e) => { if (!alive) return; if (e?.message === 'unauthenticated') { // Route back to the marketing/login surface. window.location.hash = '#/'; return; } setState((s) => ({ ...s, loading: false, error: e.message || String(e) })); }); return () => { alive = false; }; }, []); return state; } // Helpers used by the dashboard + session detail. Visitor `id` is // ":" the same way iOS does it. function visitorKey(v) { return (v.visitor?.name || '') + ':' + (v.visitor?.speaker || ''); } // Convenience: every visitor across every loaded session, with a `_session` // pointer back to its parent. Used for the inbox-style queue + lead lists. function allLoadedVisitors(sessionsById) { const rows = []; for (const session of Object.values(sessionsById)) { const result = session.result || {}; for (const v of (result.visitors || [])) { rows.push({ ...v, _session: session, _id: visitorKey(v) }); } } return rows; } function leadBucket(leadState) { if (!leadState) return 'needs'; if (leadState.snoozed_until) { const t = Date.parse(leadState.snoozed_until); if (!Number.isNaN(t) && t > Date.now()) return 'snoozed'; } switch (leadState.status) { case 'drafted': case 'sent': return 'needs'; case 'replied': case 'archived': return 'done'; default: return 'needs'; } } function fmtRelative(iso) { if (!iso) return ''; const t = Date.parse(iso); if (Number.isNaN(t)) return ''; const diffMin = Math.floor((Date.now() - t) / 60000); if (diffMin < 1) return 'JUST NOW'; if (diffMin < 60) return `${diffMin}M AGO`; const hours = Math.floor(diffMin / 60); if (hours < 24) return `${hours}H AGO`; const days = Math.floor(hours / 24); if (days < 7) return `${days}D AGO`; return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).toUpperCase(); } function fmtClock(iso) { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return '—'; return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); } function greetingHour() { const h = new Date().getHours(); if (h < 12) return 'morning'; if (h < 17) return 'afternoon'; return 'evening'; } async function foyerSignOut() { try { await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); } catch {} window.foyerCache = null; window.location.hash = '#/'; } // ============================================================ // AppShell — sidebar + main pane shared across all signed-in pages. // Dashboard, SessionsList, SessionDetail, Kiosk all wrap their content // in so navigation is consistent and the sidebar // is one place to change. // ============================================================ // SF-Symbols-inspired stroke icons. One source so the rail + lead detail // + buttons all share the same line weight. Each takes a `size` prop. function Icon({ name, size = 18, active = false }) { const stroke = 'currentColor'; const sw = 1.6; const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke, strokeWidth: sw, strokeLinecap: 'round', strokeLinejoin: 'round' }; switch (name) { case 'home': return active ? : ; case 'record': return ; case 'kiosk': return ; case 'leads': return ; case 'listings': return ; case 'sessions': return ; case 'chevronLeft': return ; case 'chevronRight': return ; case 'logout': return ; case 'plus': return ; case 'send': return ; case 'clock': return ; case 'check': return ; case 'checkCircle': return ; case 'circle': return ; case 'x': return ; case 'archive': return ; case 'inbox': return ; case 'envelope': return ; case 'phone': return ; case 'spark': return ; case 'trash': return ; case 'search': return ; case 'tag': return ; case 'gear': return ; case 'sparkles': return ; case 'pencil': return ; default: return null; } } function AppShell({ active, children, sessionStats }) { const { user, summaries, sessionsById } = useFoyerData(); const [menuOpen, setMenuOpen] = React.useState(false); // Sidebar collapse — persisted across page loads, matches the iPad // app's `sidebarCollapsed` UserDefault. const [collapsed, setCollapsed] = React.useState(() => { return localStorage.getItem('foyer.sidebarCollapsed') === '1'; }); const toggleCollapsed = React.useCallback(() => { setCollapsed(c => { const next = !c; localStorage.setItem('foyer.sidebarCollapsed', next ? '1' : '0'); return next; }); }, []); const recordedCount = summaries.filter(s => (s.kind || 'recorded') !== 'manual').length; const visitors = allLoadedVisitors(sessionsById); const needs = visitors.filter(v => leadBucket(v.lead_state) === 'needs').length; React.useEffect(() => { if (!menuOpen) return; const onClick = (e) => { if (!e.target.closest('.user-card-wrap')) setMenuOpen(false); }; setTimeout(() => document.addEventListener('click', onClick), 0); return () => document.removeEventListener('click', onClick); }, [menuOpen]); // Sidebar sections — mirrors the iPad app's tab order so an agent // bouncing between web + iPad keeps the same mental map. // Open house: Home (Today), Kiosk // Library: Sessions, Leads, Offers, Listings // Account: Profile // Record lives on iPad/iPhone only (the web canvas can't capture audio // through the browser the way the native app can), so it's not in the // nav here — the agent who lands on web first sees a CTA to install // the iOS app from the dashboard. const sections = [ { label: 'Open house', items: [ { id: 'today', label: 'Home', icon: 'home', sub: 'Live overview', hash: '#/app' }, { id: 'kiosk', label: 'Kiosk', icon: 'kiosk', sub: 'Hand to a guest', hash: '#/kiosk' }, ], }, { label: 'Library', items: [ { id: 'sessions', label: 'Sessions', icon: 'sessions', sub: `${recordedCount} recorded`, hash: '#/sessions' }, { id: 'leads', label: 'Leads', icon: 'leads', sub: `${visitors.length} captured · ${needs} need action`, hash: '#/leads' }, { id: 'offers', label: 'Offers', icon: 'tag', sub: 'Campaign library', hash: '#/offers' }, { id: 'listings', label: 'Listings', icon: 'listings', sub: 'Open-house properties', hash: '#/listings' }, ], }, { label: 'Account', items: [ { id: 'profile', label: 'Profile', icon: 'gear', sub: 'Account · Gmail · scripts', hash: '#/profile' }, ], }, ]; const railWidth = collapsed ? 68 : 232; // Click-empty-space-to-expand. Matches iPad behavior — any tap on a // collapsed rail opens it; on an expanded rail this becomes a no-op. const onRailClick = (e) => { if (!collapsed) return; if (e.target.closest('a, button, .user-card-wrap')) return; toggleCollapsed(); }; return (
{children}
); } Object.assign(window, { Crest, FoyerMark, FoyerLoader, Eyebrow, Tag, Stat, Hairline, Icon, foyerApi, foyerLoad, useFoyerData, foyerSignOut, visitorKey, allLoadedVisitors, leadBucket, fmtRelative, fmtClock, greetingHour, AppShell, });