/* 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 ( ); })}
); 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 ( ); })}
); } // ────────────────────────────────────────── // 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 */}
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 && (
{mockOverlay}
)} {/* 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 ( ); } if (phase === 'done') { return (
); } return ( ); } 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 });