/* global React, Button, Card, Badge, Section, SectionHeader */
const { useState, useEffect, useRef } = React;
// ──────────────────────────────────────────
// VerticalRadioSlider — keyboard-navigable + draggable thumb
// Track has NO fill — only the thumb is colored. Snaps to N positions.
// ──────────────────────────────────────────
function VerticalRadioSlider({ label, options, value, onChange, ariaLabel, side = 'right', fillHeight = false, totalHeight = null }) {
const idx = Math.max(0, options.findIndex(o => o.value === value));
const trackRef = useRef(null);
const [pingKey, setPingKey] = useState(0);
const [dragging, setDragging] = useState(false);
const [dragY, setDragY] = useState(null); // when dragging, raw Y offset (0..trackH)
const N = options.length;
const ROW = 44; // each row height (used when not fillHeight)
const TRACK_PAD = 6; // top/bottom padding inside track for thumb
// When fillHeight, the labels stretch to fill `totalHeight`. Track runs from
// center-of-first-option to center-of-last-option (option half-height ≈ 11px).
const labelsH = fillHeight && totalHeight ? totalHeight : N * ROW;
const OPTION_HALF = fillHeight ? 11 : 0;
const trackH = labelsH - 2 * OPTION_HALF;
const thumbH = 28;
const select = (newIdx) => {
const clamped = Math.max(0, Math.min(N - 1, newIdx));
onChange(options[clamped].value);
setPingKey(k => k + 1);
};
const onKey = (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); select(idx + 1); }
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); select(idx - 1); }
else if (e.key === 'Home') { e.preventDefault(); select(0); }
else if (e.key === 'End') { e.preventDefault(); select(N - 1); }
};
const yToIdx = (clientY) => {
const rect = trackRef.current.getBoundingClientRect();
const y = clientY - rect.top - TRACK_PAD;
const usable = rect.height - 2 * TRACK_PAD;
return Math.round((y / usable) * (N - 1));
};
const onPointerDown = (e) => {
e.preventDefault();
trackRef.current.setPointerCapture(e.pointerId);
setDragging(true);
const rect = trackRef.current.getBoundingClientRect();
setDragY(e.clientY - rect.top);
};
const onPointerMove = (e) => {
if (!dragging) return;
const rect = trackRef.current.getBoundingClientRect();
const y = Math.max(0, Math.min(rect.height, e.clientY - rect.top));
setDragY(y);
};
const onPointerUp = (e) => {
if (!dragging) return;
const newIdx = yToIdx(e.clientY);
setDragging(false);
setDragY(null);
select(newIdx);
};
// Compute thumb position. When dragging: follow cursor. Otherwise: snap to idx.
const usable = trackH - 2 * TRACK_PAD;
const snapY = TRACK_PAD + (idx / Math.max(1, N - 1)) * usable - thumbH / 2;
const thumbY = dragging && dragY != null
? Math.max(0, Math.min(trackH - thumbH, dragY - thumbH / 2))
: snapY;
const Track = (
{/* Spacer matching the label header above + offset to align with first option center */}
{/* Tick marks at each option position */}
{options.map((_, i) => {
const ratio = (N - 1) === 0 ? 0 : i / (N - 1);
return (
);
})}
{/* Left ember indicator dash */}
{/* Grip lines */}
);
const Labels = (
{label}
{/* Hairlines flanking the label */}
{options.map((opt, i) => {
const active = i === idx;
return (
select(i)}
style={{
height: fillHeight ? 'auto' : `${ROW}px`,
paddingTop: fillHeight ? '4px' : 0,
paddingBottom: fillHeight ? '4px' : 0,
background: 'transparent',
border: 'none',
textAlign: side === 'right' ? 'left' : 'right',
paddingLeft: 0, paddingRight: 0,
cursor: 'pointer',
fontFamily: 'IBM Plex Mono, monospace',
fontSize: '12px',
fontWeight: active ? 600 : 400,
letterSpacing: '0.10em',
textTransform: 'uppercase',
color: active ? 'rgba(250,247,242,0.98)' : 'rgba(250,247,242,0.32)',
transition: 'color 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
justifyContent: side === 'right' ? 'flex-start' : 'flex-end',
animation: active ? `labelPing-${pingKey} 0.3s ease-out` : 'none',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'rgba(250,247,242,0.65)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'rgba(250,247,242,0.32)'; }}
>
{opt.label}
);
})}
);
return (
{Track}{Labels}
);
}
// ──────────────────────────────────────────
// Horizontal pill row — used as the mobile collapse for vertical radio
// ──────────────────────────────────────────
function HorizontalPillRow({ label, options, value, onChange }) {
return (
{label}
{options.map(opt => {
const active = opt.value === value;
return (
onChange(opt.value)}
style={{
height: '36px', padding: '0 14px',
borderRadius: '999px',
border: '1px solid ' + (active ? 'var(--color-signal)' : 'rgba(255,255,255,0.15)'),
background: active ? 'rgba(255,106,44,0.12)' : 'transparent',
color: active ? 'var(--color-signal)' : 'rgba(250,247,242,0.7)',
fontWeight: active ? 600 : 400,
fontSize: '13px',
fontFamily: 'inherit',
cursor: 'pointer',
transition: 'all 0.18s ease',
}}>
{opt.label}
);
})}
);
}
// ──────────────────────────────────────────
// Waveform header — gentle idle pulse + active mode
// ──────────────────────────────────────────
function Waveform({ active, intensity = 0.5 }) {
const [tick, setTick] = useState(0);
useEffect(() => {
const i = setInterval(() => setTick(t => t + 1), active ? 60 : 110);
return () => clearInterval(i);
}, [active]);
const N = 64;
const bars = Array.from({ length: N }).map((_, i) => {
if (active) {
const phase = i * 0.34 + tick * 0.22;
return 6 + Math.abs(Math.sin(phase)) * 28 * intensity + Math.abs(Math.sin(phase * 0.5 + tick * 0.1)) * 18 * intensity;
} else {
// Sine wave with phase offset per bar, gentle, around 4–10px tall idle
const phase = i * 0.18 + tick * 0.06;
return 4 + (Math.sin(phase) + 1) * 3; // 4..10
}
});
return (
{bars.map((h, i) => (
))}
);
}
// ──────────────────────────────────────────
// ScreenPane — the "monitor" on the right side of the hardware unit
// Dark interior, signal-orange horizon glow band, tab strip, brand mark
// ──────────────────────────────────────────
function ScreenPane({
messages, isLive, currentSpeaker, mockMessages, mockOverlay, loc,
phase, callTime, voiceLabel, voiceDesc, industryLabel,
}) {
const c = window.BD.COPY[loc];
const scrollRef = useRef(null);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages.length]);
const showMock = !isLive && messages.length === 0;
const list = showMock ? mockMessages : messages;
const fmtTime = (s) => `${String(Math.floor(s/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;
// Glow intensity: idle = soft, live = bright, done = mid
const glowIntensity = phase === 'live' ? 1 : phase === 'done' ? 0.55 : 0.32;
return (
{/* HORIZON GLOW — radial signal-orange band, animated on live */}
{/* Bright horizon line */}
{/* Faint scanlines */}
{/* Tab strip — DEMO / CODE */}
{phase === 'idle' ? c.demo.ready
: phase === 'live' ? c.demo.live
: c.demo.ended}
{fmtTime(callTime)}
{/* Header strip — voice + industry context */}
{c.demo.useCase}: {industryLabel}
{c.demo.voice}: {voiceLabel} · {voiceDesc}
{/* Conversation area */}
{list.map((m, i) => {
const isUser = m.role === 'user';
return (
{isUser ? c.demo.caller : c.demo.ai}
{m.text}
);
})}
{isLive && currentSpeaker === 'ai' && (
)}
{/* Overlay CTA pill — anchored to BOTTOM */}
{showMock && (
)}
{/* Brand mark in bottom-right corner */}
);
}
// ──────────────────────────────────────────
// HardwareStartButton — dark recessed button with mic icon
// ──────────────────────────────────────────
function HardwareStartButton({ phase, onStart, onEnd, onReplay, onReset, copy, isLive }) {
if (phase === 'live') {
return (
{copy.end}
);
}
if (phase === 'done') {
return (
{copy.replay}
↺
);
}
return (
{
const dot = e.currentTarget.querySelector('[data-led]');
if (dot) dot.style.boxShadow = '0 0 14px rgba(255,106,44,0.95)';
}}
onMouseLeave={e => {
const dot = e.currentTarget.querySelector('[data-led]');
if (dot) dot.style.boxShadow = '0 0 8px rgba(255,106,44,0.6)';
}}
>
{copy.startShort || 'START'}
);
}
function hardwareBtnStyle(square) {
return {
flex: 1,
height: '64px',
background: 'linear-gradient(180deg, #1e1e22 0%, #0e0e10 100%)',
color: 'rgba(250,247,242,0.95)',
border: '1px solid rgba(255,255,255,0.10)',
borderRadius: '12px',
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: square ? 'center' : 'flex-start',
gap: '14px',
padding: square ? '0' : '0 18px',
fontFamily: 'IBM Plex Mono, monospace',
fontSize: square ? '18px' : '13px',
letterSpacing: '0.20em',
textTransform: 'uppercase',
boxShadow: [
'inset 0 1px 0 rgba(255,255,255,0.08)',
'inset 0 -1px 0 rgba(0,0,0,0.5)',
'0 4px 10px rgba(0,0,0,0.4)',
].join(', '),
transition: 'transform 0.12s ease',
};
}
function MicIcon() {
return (
);
}
// ──────────────────────────────────────────
// Live conversation scripts
// ──────────────────────────────────────────
const SCRIPTS = {
ko: {
dental: [
{ role: 'ai', text: '안녕하세요, ○○치과입니다. AI 안내원이 응대 도와드립니다.', delay: 1800 },
{ role: 'user', text: '임플란트 가격이 얼마예요?', delay: 1500 },
{ role: 'ai', text: '국산은 100만원부터, 수입산은 130만원부터입니다. 첫 진료 예약 도와드릴까요?', delay: 2200 },
{ role: 'user', text: '네, 토요일 가능한가요?', delay: 1500 },
{ role: 'ai', text: '토요일 오전 10시 가능하세요. 성함 알려주시겠어요?', delay: 2000 },
{ role: 'user', text: '김민지입니다.', delay: 1200 },
{ role: 'ai', text: '네 김민지님, 카카오톡으로 예약 확정 보내드리겠습니다. 감사합니다.', delay: 2200 },
],
academy: [
{ role: 'ai', text: '안녕하세요, ○○학원입니다. AI 안내원이 응대 도와드립니다.', delay: 1800 },
{ role: 'user', text: '중3 수학반 시간표 좀 알 수 있을까요?', delay: 1700 },
{ role: 'ai', text: '심화반은 월·수·금 7시, 종합반은 화·목 8시입니다. 자녀분 성함 알려주시겠어요?', delay: 2300 },
{ role: 'user', text: '박지호, 중3이에요.', delay: 1300 },
{ role: 'ai', text: '목요일 오후 6시 상담 가능하세요. 카카오톡으로 안내드리겠습니다.', delay: 2200 },
],
tax: [
{ role: 'ai', text: '안녕하세요, ○○세무사 사무실입니다.', delay: 1700 },
{ role: 'user', text: '간이과세자 부가세 신고 의뢰하려고 합니다.', delay: 1700 },
{ role: 'ai', text: '네, 사업자등록번호 알려주시면 견적 안내드리겠습니다.', delay: 2200 },
{ role: 'user', text: '오늘 통화 가능한가요?', delay: 1300 },
{ role: 'ai', text: '오후 4시 30분 세무사님과 통화 가능하세요. 카카오톡으로 안내드릴게요.', delay: 2300 },
],
vet: [
{ role: 'ai', text: '안녕하세요, ○○동물병원입니다. AI 안내원입니다.', delay: 1900 },
{ role: 'user', text: '강아지가 사료를 안 먹는데 진료 가능할까요?', delay: 1900 },
{ role: 'ai', text: '네 보호자님, 오늘 오후 4시 30분 또는 5시 30분 가능하세요.', delay: 2200 },
{ role: 'user', text: '4시 30분으로 부탁드립니다.', delay: 1500 },
{ role: 'ai', text: '카카오톡으로 예약 확정 보내드리겠습니다.', delay: 2000 },
],
pension: [
{ role: 'ai', text: '안녕하세요, ○○펜션입니다. AI 안내원이 응대 도와드립니다.', delay: 1800 },
{ role: 'user', text: '이번 주말 4인실 빈 방 있나요?', delay: 1500 },
{ role: 'ai', text: '토요일 한 자리, 일요일 두 자리 가능하세요.', delay: 2000 },
{ role: 'user', text: '토요일로 예약할게요.', delay: 1300 },
{ role: 'ai', text: '카카오톡으로 예약 확정 안내드리겠습니다. 감사합니다.', delay: 2200 },
],
},
en: {
dental: [
{ role: 'ai', text: 'Hello, this is ○○ Dental. You\'re speaking with our AI receptionist.', delay: 1900 },
{ role: 'user', text: 'How much is an implant?', delay: 1500 },
{ role: 'ai', text: 'Domestic from ₩1M, imported from ₩1.3M. Shall I book your first visit?', delay: 2200 },
{ role: 'user', text: 'Yes, are Saturdays open?', delay: 1500 },
{ role: 'ai', text: 'Saturday 10am works. May I have your name?', delay: 2000 },
{ role: 'user', text: 'Kim Minji.', delay: 1100 },
{ role: 'ai', text: 'Confirmation will arrive via KakaoTalk shortly. Thank you.', delay: 2100 },
],
academy: [
{ role: 'ai', text: 'Hello, ○○ Academy — AI receptionist speaking.', delay: 1800 },
{ role: 'user', text: 'Could I get the 9th-grade math schedule?', delay: 1700 },
{ role: 'ai', text: 'Advanced runs Mon/Wed/Fri 7pm, comprehensive Tue/Thu 8pm. Your child\'s name?', delay: 2300 },
{ role: 'user', text: 'Park Jiho, grade 9.', delay: 1200 },
{ role: 'ai', text: 'Thursday 6pm consult works. I\'ll send details via KakaoTalk.', delay: 2200 },
],
tax: [
{ role: 'ai', text: 'Hello, ○○ Tax Office.', delay: 1600 },
{ role: 'user', text: 'I need help filing simplified-taxpayer VAT.', delay: 1700 },
{ role: 'ai', text: 'Sure — please send your business reg number, we\'ll send a quote.', delay: 2200 },
{ role: 'user', text: 'Can I speak with the accountant today?', delay: 1500 },
{ role: 'ai', text: 'The accountant is available at 4:30pm. I\'ll confirm via KakaoTalk.', delay: 2300 },
],
vet: [
{ role: 'ai', text: 'Hello, ○○ Vet — AI receptionist speaking.', delay: 1900 },
{ role: 'user', text: 'My dog won\'t eat — can we come in today?', delay: 1900 },
{ role: 'ai', text: '4:30pm or 5:30pm are available this afternoon.', delay: 2100 },
{ role: 'user', text: '4:30pm, please.', delay: 1100 },
{ role: 'ai', text: 'Confirmation will arrive via KakaoTalk shortly.', delay: 2000 },
],
pension: [
{ role: 'ai', text: 'Hello, ○○ Pension — AI receptionist speaking.', delay: 1800 },
{ role: 'user', text: 'Any 4-person rooms open this weekend?', delay: 1500 },
{ role: 'ai', text: 'Saturday has one, Sunday has two open.', delay: 1900 },
{ role: 'user', text: 'I\'ll take Saturday.', delay: 1100 },
{ role: 'ai', text: 'Confirmation will arrive via KakaoTalk. Thank you.', delay: 2100 },
],
},
};
// ──────────────────────────────────────────
// DemoWidget
// ──────────────────────────────────────────
function DemoWidget({ industryId, setIndustryId, voice, setVoice }) {
const [loc] = window.BD.useLocale();
const c = window.BD.COPY[loc];
// Demo only supports 5 industries (omakase excluded). Coerce.
const demoIds = window.BD.DEMO_INDUSTRY_IDS;
const effectiveId = demoIds.includes(industryId) ? industryId : demoIds[0];
const industry = window.BD.localIndustry(effectiveId, loc);
const setIndustryIdSafe = (id) => setIndustryId(id);
const voiceObj = window.BD.VOICES.find(v => v.value === voice) || window.BD.VOICES[0];
const [phase, setPhase] = useState('idle');
const [messages, setMessages] = useState([]);
const [currentSpeaker, setCurrentSpeaker] = useState(null);
const [callTime, setCallTime] = useState(0);
const timerRef = useRef(null);
const scriptIdxRef = useRef(0);
const stepTimerRef = useRef(null);
const [viewportW, setViewportW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1280);
useEffect(() => {
const onR = () => setViewportW(window.innerWidth);
window.addEventListener('resize', onR);
return () => window.removeEventListener('resize', onR);
}, []);
const startCall = () => {
setPhase('live'); setMessages([]); setCallTime(0); scriptIdxRef.current = 0;
timerRef.current = setInterval(() => setCallTime(t => t + 1), 1000);
advance();
};
const advance = () => {
const script = (SCRIPTS[loc] && SCRIPTS[loc][effectiveId]) || SCRIPTS[loc].dental;
const i = scriptIdxRef.current;
if (i >= script.length) { endCall(); return; }
const step = script[i];
setCurrentSpeaker(step.role);
stepTimerRef.current = setTimeout(() => {
setMessages(prev => [...prev, { id: `${Date.now()}-${i}`, role: step.role, text: step.text, ts: Date.now() }]);
scriptIdxRef.current = i + 1;
stepTimerRef.current = setTimeout(advance, 600);
}, step.delay);
};
const endCall = () => { setPhase('done'); setCurrentSpeaker(null); if (timerRef.current) clearInterval(timerRef.current); };
const reset = () => {
setPhase('idle'); setMessages([]); setCallTime(0); setCurrentSpeaker(null);
if (timerRef.current) clearInterval(timerRef.current);
if (stepTimerRef.current) clearTimeout(stepTimerRef.current);
};
useEffect(() => () => {
if (timerRef.current) clearInterval(timerRef.current);
if (stepTimerRef.current) clearTimeout(stepTimerRef.current);
}, []);
useEffect(() => { reset(); /* eslint-disable-next-line */ }, [effectiveId, voice, loc]);
const isLive = phase === 'live';
const industryOptions = demoIds.map(id => {
const ind = window.BD.localIndustry(id, loc);
return { value: id, label: ind.label };
});
const voiceOptions = window.BD.VOICES.map(v => ({ value: v.value, label: v[loc].name }));
const mockList = (window.BD.MOCK_CONVOS[loc] && window.BD.MOCK_CONVOS[loc][effectiveId]) || window.BD.MOCK_CONVOS[loc].dental;
const isNarrow = viewportW < 1024;
return (
{/* Disclosure ABOVE container, charcoal/50 */}
{c.demo.disclosure}
{/* HARDWARE BEZEL — deep near-black with inner bevel + speaker grille up top */}
{/* Speaker grille — dotted texture across top */}
{/* DESKTOP: 2-column hardware layout — CONTROLS (left) | SCREEN (right) */}
{!isNarrow && (
{/* LEFT — Controls panel */}
{/* Sliders row */}
{/* START button — hardware play button, dark with mic icon */}
{/* RIGHT — Screen */}
)}
{/* MOBILE: stacked */}
{isNarrow && (
)}
{/* Booking confirmation card */}
{phase === 'done' && (
{c.demo.bookingConfirmed}
{c.demo.summary}
{c.demo.sentAt(new Date().toLocaleTimeString(loc === 'ko' ? 'ko-KR' : 'en-US'))}
)}
);
}
Object.assign(window, { DemoWidget });