/* Vooice — Outbound Call with scrubber + phases.
New: play/pause, scrubber, "are you AI?" honesty handoff phase, animated read-back.
*/
const PHASES = [
{ id: 'dial', label: 'Dialing', start: 0, color: '#6A655C' },
{ id: 'connect', label: 'Connected', start: 2.4, color: '#0E0E0F' },
{ id: 'request', label: 'Stating purpose', start: 6.4, color: '#0E0E0F' },
{ id: 'options', label: 'Hearing options', start: 13.5,color: '#0E0E0F' },
{ id: 'readback', label: 'Slot read-back', start: 20.0,color: '#F04E23' },
{ id: 'honesty', label: 'Are you AI?', start: 30.0,color: '#F04E23' },
{ id: 'handoff', label: 'Dialing user in', start: 36.0,color: '#F04E23' },
{ id: 'resume', label: 'Resumed', start: 44.0,color: '#7A8A6E' },
{ id: 'wrap', label: 'Wrapping up', start: 52.0,color: '#7A8A6E' },
{ id: 'closed', label: 'Closed', start: 58.0,color: '#7A8A6E' },
];
const SCRIPT_LINES_V2 = [
{ t: 0, who: 'sys', text: 'Outbound call placed → Bayview Hair Studio · +1 (415) 555-0142' },
{ t: 2.4, who: 'sys', text: 'Connected · 3 rings' },
{ t: 3.0, who: 'callee', text: 'Bayview Hair Studio, this is Marcus, how can I help you?' },
{ t: 6.4, who: 'vooice', text: "Hi Marcus — this is Vooice, calling on behalf of Jordan Reyes. Jordan would like to book a haircut sometime this week, ideally Wednesday or Thursday afternoon." },
{ t: 13.5, who: 'callee', text: 'Sure, let me check… we have Wednesday at 3:30 with Lina, or Thursday at 2 with Marcus — that\u2019s me.' },
{ t: 20.0, who: 'vooice', text: "Thursday at 2 with you works for Jordan's calendar. Could you confirm that's a 45-minute slot, and you're on the books as Marcus Wu?" },
{ t: 26.5, who: 'callee', text: 'Yep — wait, hold on. Are you, like, a real person?' },
{ t: 30.0, who: 'vooice', text: "Honest answer: I'm an AI assistant — Vooice — calling for Jordan. If it'd help, I can dial Jordan onto the line right now to confirm I'm working for them." },
{ t: 35.0, who: 'callee', text: "Yeah, please — that'd be great." },
{ t: 36.0, who: 'sys', text: 'Dialing Jordan onto the line…' },
{ t: 39.5, who: 'sys', text: 'Jordan joined · 3-way' },
{ t: 40.0, who: 'jordan', text: "Hey Marcus, yeah — Vooice is mine, she's good. Just go ahead with what she said." },
{ t: 43.5, who: 'callee', text: "Perfect, thanks Jordan. I'll book Thursday 2pm." },
{ t: 44.0, who: 'sys', text: 'Jordan dropped off' },
{ t: 46.0, who: 'vooice', text: "Thanks Jordan. Marcus — confirming 45 minutes, Marcus Wu, Thursday at 2." },
{ t: 52.0, who: 'callee', text: 'All set. Phone for the reminder?' },
{ t: 55.0, who: 'vooice', text: "+1 (415) 555-0188 — that's Jordan's direct line. I'll add it to their calendar and send a confirmation." },
{ t: 60.0, who: 'callee', text: 'Great, see them Thursday.' },
{ t: 62.0, who: 'vooice', text: 'Thanks Marcus, have a good one.' },
];
const TOTAL_DUR = 65;
function CurrentLineV2(tSec) {
let cur = SCRIPT_LINES_V2[0];
for (const l of SCRIPT_LINES_V2) if (l.t <= tSec) cur = l;
return cur;
}
function CurrentPhase(tSec) {
let cur = PHASES[0];
for (const p of PHASES) if (p.start <= tSec) cur = p;
return cur;
}
function fmtClockV2(s) {
const m = Math.floor(s / 60).toString().padStart(2, '0');
const sec = Math.floor(s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
function OutboundCall({ width = 1320, height = 880, onNavigate }) {
const [tSec, setTSec] = React.useState(8);
const [playing, setPlaying] = React.useState(true);
React.useEffect(() => {
if (!playing) return;
const id = setInterval(() => setTSec(t => (t + 0.1) % TOTAL_DUR), 100);
return () => clearInterval(id);
}, [playing]);
const line = CurrentLineV2(tSec);
const phase = CurrentPhase(tSec);
const speaking =
tSec < 2.4 ? 'silent'
: line.who === 'sys' ? 'silent'
: line.who === 'jordan' ? 'callee'
: line.who;
const showReadback = tSec >= 20 && tSec < 26.5;
const honestyMode = tSec >= 26.5 && tSec < 36;
const handoffMode = tSec >= 36 && tSec < 44;
const threeWay = tSec >= 39.5 && tSec < 44;
return (
{/* HEADER — unified AppHeader */}
{/* LEFT — CALL SUBJECT */}
{/* CENTER */}
{phase.label.toUpperCase()}
{speaking === 'vooice' ? 'Vooice speaking' : speaking === 'callee' ? `${line.who === 'jordan' ? 'Jordan' : 'Marcus'} speaking` : 'Line silent'}
{' · Deepgram Nova‑3 · 220ms endpoint'}
LATENCY 312 ms
JITTER 6 ms
QUALITY ◼◼◼◼◻
{/* Transcript */}
{SCRIPT_LINES_V2.filter(l => l.t <= tSec + 0.05).slice(-5).map((l, i, arr) => (
))}
{/* Bottom — handoff */}
{/* RIGHT — pending actions */}
{/* SCRUBBER */}
{fmtClockV2(tSec)} / {fmtClockV2(TOTAL_DUR)}
{/* FOOTER */}
);
}
// ── Sub-components ──────────────────────────────────────────
function Participant({ name, sub, speaking, accent }) {
return (
);
}
function BubbleV2({ line, isCurrent, tSec }) {
if (line.who === 'sys') {
return (
{line.text}
);
}
const isVoo = line.who === 'vooice';
const isJordan = line.who === 'jordan';
const align = isVoo ? 'flex-start' : 'flex-end';
let text = line.text;
if (isCurrent && isVoo) {
const since = Math.max(0, tSec - line.t);
const targetChars = Math.min(line.text.length, Math.floor(since * 22));
text = line.text.slice(0, targetChars);
}
const bg = isVoo ? VC_TOKENS.ink : isJordan ? VC_TOKENS.signalSoft : VC_TOKENS.paper2;
const fg = isVoo ? VC_TOKENS.paper : VC_TOKENS.ink;
return (
{isVoo ?
:
}
{isVoo ? 'VOOICE' : isJordan ? 'JORDAN (DIALED IN)' : 'MARCUS'} · {fmtClockV2(line.t)}
{text}
{isCurrent && isVoo && text.length < line.text.length && (
)}
);
}
function ActionCard({ step, title, status, rows }) {
const palette = {
pending: { fg: VC_TOKENS.ink3, label: 'PENDING' },
queued: { fg: VC_TOKENS.ink3, label: 'QUEUED' },
ready: { fg: VC_TOKENS.sage, label: 'READY TO WRITE' },
}[status];
return (
{step}
{title}
{palette.label}
{rows.map(([k,v]) => (
{k}
{v}
))}
);
}
function SafetyItem({ label, on, off, hot }) {
const color = off ? VC_TOKENS.ink3 : hot ? VC_TOKENS.signal : VC_TOKENS.sage;
return (
{off ? '×' : '✓'}
{label}
);
}
function HandoffPanelV2({ honestyMode, handoffMode }) {
if (handoffMode) {
return (
You're being dialed in.
Vooice will keep talking until you pick up. Drop off whenever.
3-WAY
);
}
if (honestyMode) {
return (
Marcus asked if she's AI. She said yes.
She's offering to dial you in. Auto-accept in 3s if no input.
HONESTY
);
}
return (
Call going smoothly.
If Marcus asks "are you AI?", I'll answer truthfully and offer to dial you in.
AUTO
);
}
function BigButton({ label, sub, variant = 'ghost' }) {
const isSignal = variant === 'signal';
return (
);
}
function Stat({ label, value, last }) {
return (
{label}
{value}
);
}
// ── Animated popover ─────────────────────────────────
function Popover({ visible, title, countdown, body }) {
const [render, setRender] = React.useState(visible);
const [show, setShow] = React.useState(visible);
React.useEffect(() => {
if (visible) { setRender(true); requestAnimationFrame(() => setShow(true)); }
else { setShow(false); const t = setTimeout(() => setRender(false), 320); return () => clearTimeout(t); }
}, [visible]);
if (!render) return null;
return (
{title}
{countdown}
{body}
);
}
// ── Scrubber with phase markers ──────────────────────
function Scrubber({ tSec, dur, setT }) {
const trackRef = React.useRef(null);
const onClick = e => {
const r = trackRef.current.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
setT(Math.max(0, Math.min(dur, x * dur)));
};
return (
{/* Phase blocks */}
{PHASES.map((p, i) => {
const next = PHASES[i+1];
const start = p.start / dur;
const end = (next ? next.start : dur) / dur;
return (
{p.label}
);
})}
{/* Playhead */}
);
}
window.OutboundCall = OutboundCall;