/* global React, Crest, AppShell, Tag, Eyebrow, Hairline, useFoyerData, foyerApi, foyerLoad, fmtRelative, fmtClock */ const SessionDetail = () => { const { user, summaries, sessionsById, loading, error } = useFoyerData(); const [search, setSearch] = React.useState(''); const [filter, setFilter] = React.useState('all'); const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(''); // We can't change the AI's tag — keep filter local, drop the tag-swap UI. const [activeVisitorKey, setActiveVisitorKey] = React.useState(null); const [sending, setSending] = React.useState(false); const [savedDraftKey, setSavedDraftKey] = React.useState(null); // Visitor-swap crossfade. Mirrors the route-frame transition in index.html // so clicking a different lead in the rail doesn't snap-cut the detail // pane. Local because session-detail handles its own intra-page nav. const [visitorPhase, setVisitorPhase] = React.useState('idle'); const [shownVisitorKey, setShownVisitorKey] = React.useState(null); // Audio playback const audioRef = React.useRef(null); const [isPlaying, setIsPlaying] = React.useState(false); const [audioTime, setAudioTime] = React.useState(0); const [audioDuration, setAudioDuration] = React.useState(0); // Pick which session to show. Set by the Sessions list / Dashboard via // window.foyerActiveSessionId before navigating here. If nothing's set // (e.g. agent typed /#/session directly), bounce to the list page so // they can pick rather than getting dropped into something arbitrary. const targetId = window.foyerActiveSessionId; React.useEffect(() => { if (!targetId) { window.foyerGo('#/sessions'); } }, [targetId]); const session = targetId ? sessionsById[targetId] : null; const result = session?.result; const allVisitors = result?.visitors || []; // Initial active visitor: name passed from dashboard, else first by score. React.useEffect(() => { if (!result || allVisitors.length === 0) return; if (activeVisitorKey && allVisitors.some(v => keyOf(v) === activeVisitorKey)) return; const target = window.foyerActiveVisitorName ? allVisitors.find(v => v.visitor.name === window.foyerActiveVisitorName) : null; const initial = target || allVisitors.slice().sort((a, b) => (b.analysis?.score || 0) - (a.analysis?.score || 0) )[0]; setActiveVisitorKey(keyOf(initial)); setShownVisitorKey(keyOf(initial)); setDraft(initial?.analysis?.followUpDraft || initial?.analysis?.follow_up_draft || ''); }, [result]); // Crossfade when activeVisitorKey changes: fade out the current detail // pane, swap to the new visitor, fade back in. ~380ms total. React.useEffect(() => { if (!activeVisitorKey) return; if (activeVisitorKey === shownVisitorKey) return; setVisitorPhase('out'); const t1 = setTimeout(() => { setShownVisitorKey(activeVisitorKey); requestAnimationFrame(() => setVisitorPhase('in')); }, 180); return () => clearTimeout(t1); }, [activeVisitorKey]); React.useEffect(() => { if (visitorPhase !== 'in') return; const t = setTimeout(() => setVisitorPhase('idle'), 220); return () => clearTimeout(t); }, [visitorPhase]); // Search / tag-filter narrows the left rail. const filteredVisitors = allVisitors.filter(v => { const t = (v.analysis?.tag || '').toLowerCase(); if (filter !== 'all' && t !== filter) return false; if (search && !v.visitor.name.toLowerCase().includes(search.toLowerCase())) return false; return true; }); // For the left rail we want immediate feedback (highlight tracks the // clicked row right away). For the detail pane we render `shown` so the // fade-out shows the previous visitor's content until the swap. const v = allVisitors.find(x => keyOf(x) === shownVisitorKey) || allVisitors.find(x => keyOf(x) === activeVisitorKey); if (loading) { return LOADING SESSION…; } if (error) { return Couldn't load: {error}; } if (!targetId) { // Effect above is bouncing to /#/sessions — render an empty placeholder // so we don't flash the previous content while the route changes. return OPENING SESSIONS…; } if (!session || !result) { return This session is still processing — pull up the list and try again in a moment.; } if (!v) { return This session has no guests detected.; } const tag = (v.analysis?.tag || '').toLowerCase(); const followUpDraft = v.analysis?.followUpDraft || v.analysis?.follow_up_draft || ''; const utterances = result.utterances || []; const visitorSpeaker = v.visitor.speaker; const agentSpeaker = result.agent_speaker; const visitorTurns = utterances.filter(u => u.speaker === visitorSpeaker || u.speaker === agentSpeaker ); return (
{/* "Back to all sessions" + lead list (left rail of the inner pane) */}
← All sessions
{/* LEAD LIST */}
Session
{session.address || 'Untitled session'}
{new Date(session.created_at).toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}
{allVisitors.length} GUEST{allVisitors.length === 1 ? '' : 'S'} · {(session.kind || 'recorded').toUpperCase()}
{/* search */}
setSearch(e.target.value)} placeholder="Search guests…" style={{ width: '100%', background: 'transparent', border: 0, borderBottom: '1px solid var(--hairline)', padding: '10px 0', color: 'var(--cream)', fontSize: 13, fontFamily: 'var(--sans)', outline: 'none', }} />
{/* filter chips */}
{['all', 'buyer', 'seller', 'browser'].map(t => ( ))}
{filteredVisitors.map(vis => { const isActive = keyOf(vis) === activeVisitorKey; const visTag = (vis.analysis?.tag || '').toLowerCase(); const visDraft = vis.analysis?.followUpDraft || vis.analysis?.follow_up_draft || ''; return (
{ setActiveVisitorKey(keyOf(vis)); setDraft(visDraft); setEditing(false); }} style={{ padding: '16px 28px', borderLeft: isActive ? '2px solid var(--gold)' : '2px solid transparent', background: isActive ? 'var(--gold-soft)' : 'transparent', borderBottom: '1px solid var(--hairline)', borderRadius: 0, margin: 0, }}>
{vis.visitor.name} {vis.analysis?.score ?? '—'}
{(vis.analysis?.tag || '').toUpperCase()} · SPOKE {vis.analysis?.wordsSpoken ?? vis.analysis?.words_spoken ?? 0}W
{vis.analysis?.summary}
); })}
{/* DETAIL PANE */}
{session.kind !== 'manual' && ( )}
{/* header */}
{(v.analysis?.tag || '').toUpperCase()} · {leadStateLabel(v.lead_state)}

{v.visitor.name}

{(v.visitor.email || '—').toUpperCase()}{v.visitor.phone ? ` · ${v.visitor.phone}` : ''}
{v.analysis?.score ?? '—'}/100
{/* summary */}
The read

{v.analysis?.summary}

{v.analysis?.tagReason || v.analysis?.tag_reason ? (

Why {v.analysis?.tag}: {v.analysis?.tagReason || v.analysis?.tag_reason}

) : null}
{/* signals */} {Array.isArray(v.analysis?.signals) && v.analysis.signals.length > 0 && (
{v.analysis.signals.map((s, i) => (
Signal {String(i + 1).padStart(2, '0')}
{s}
))}
)} {/* transcript */} {visitorTurns.length > 0 && (
Conversation YOU + {v.visitor.name.toUpperCase()} · {visitorTurns.length} TURNS
{visitorTurns.map((line, i) => { const isAgent = line.speaker === agentSpeaker; const playheadMs = audioTime * 1000; const isActive = isPlaying && line.start_ms != null && line.end_ms != null && playheadMs >= line.start_ms && playheadMs <= line.end_ms; return (
{ const a = audioRef.current; if (a) { a.currentTime = Math.max(0, (line.start_ms || 0) / 1000); if (!isPlaying) a.play().catch(() => {}); } }} style={{ display: 'grid', gridTemplateColumns: '60px 1fr', gap: 16, padding: '12px 0', cursor: 'pointer' }} >
{fmtTimestamp(line.start_ms || 0)}
{isAgent ? `${(user?.name || 'You').split(' ')[0]} (you)` : v.visitor.name}
{line.text}
); })}
)} {/* follow-up */}
Drafted follow-up {leadStateLabel(v.lead_state)}
To: {v.visitor.email || '—'}
Subject: Great meeting you{session.address ? ` at ${session.address}` : ''}
{editing ? (