← back to Small Business Builder
src/render/interview-live.js
516 lines
// Live AI-avatar interview — /biz/:slug/interview/live
//
// The owner clicks "Start" and:
// 1. Browser asks for camera + mic permission.
// 2. Avatar (stylized SVG interviewer, speaking-pulse animation) appears on
// the left. Webcam PIP bottom-right.
// 3. Question is spoken via Web Speech API SpeechSynthesis AND shown big.
// 4. User answers verbally; SpeechRecognition transcribes inline.
// 5. They can edit the transcript before clicking Next.
// 6. Optional "Snap photo" captures a webcam frame → POST /api/biz/photo/upload.
// 7. Final click → POST /api/biz/interview saves all answers.
//
// Browser support: Chrome/Edge full. Safari has SpeechRecognition since 14.1.
// Firefox lacks SpeechRecognition — we fall back to typed input.
import { esc } from '../lib/escape.js';
import { QUESTIONS, SECTIONS, localizeQuestion } from '../lib/interview-questions.js';
// Escape JSON output so it's safe to embed inside a <script> tag.
// JSON.stringify alone is JS-safe but NOT HTML-script-context safe — a string
// containing "</script>" or "<!--" can break out of the surrounding tag.
// Replacing the literal `<`, `>`, `&` with their \u-escaped forms inside the
// JSON string is still valid JSON/JS and renders identically once parsed.
function escScript(value) {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
}
const NEIGHBORHOOD_ACCENT = {
'Silver Lake':'#1f6f70','Echo Park':'#c95a2c','Koreatown':'#c9a23a','K-Town':'#c9a23a',
'Highland Park':'#7a3b3b','DTLA':'#3a3a3a','West Hollywood':'#b6395f','Venice':'#2c5d8a',
'Beverly Hills':'#8a7448','Hollywood':'#7a2530','Sherman Oaks':'#5a6e3a','Santa Monica':'#3a5a6e',
'Culver City':'#6e5a3a','Los Feliz':'#4a6e3a','Westwood':'#5a3a6e','West Adams':'#6e3a4a',
'Los Angeles':'#3a3a3a',
};
export function renderInterviewLive({ business, interview }) {
const b = business;
const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
const existing = interview?.qa_json || {};
// Localize all questions for this business and ship them to the client.
const questionsForClient = QUESTIONS.map(q => {
const lq = localizeQuestion(q, b);
return {
id: q.id,
section: q.section,
sectionLabel: SECTIONS[q.section]?.label || q.section,
q: lq.q,
placeholder: lq.placeholder,
max_chars: q.max_chars,
prefill: existing[q.id] || '',
};
});
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Live Interview · ${esc(b.name)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--ink:#0e0e10;--paper:#f5f5f5;--rule:#262a36;--mute:#7a766f;--accent:${accent};
--serif:"Cormorant Garamond",Georgia,serif;
--sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--ink);color:var(--paper);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased;overflow:hidden}
button{font-family:inherit;cursor:pointer}
/* ===== STAGE ===== */
.stage{display:grid;grid-template-columns:340px 1fr;height:100vh;gap:0}
.left{padding:32px;display:flex;flex-direction:column;align-items:center;justify-content:center;border-right:1px solid var(--rule);background:linear-gradient(180deg,#0a0a0c 0%,#15151a 100%)}
.right{padding:48px 64px;display:flex;flex-direction:column;justify-content:center;position:relative;overflow-y:auto}
/* ===== AVATAR ===== */
.avatar{width:200px;height:200px;border-radius:50%;background:#1a1a1f;position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:24px;border:2px solid var(--accent);overflow:visible}
.avatar svg{width:100%;height:100%}
.avatar.speaking::before{content:"";position:absolute;inset:-12px;border-radius:50%;border:2px solid var(--accent);opacity:.5;animation:pulse 1.4s infinite}
.avatar.speaking::after{content:"";position:absolute;inset:-24px;border-radius:50%;border:2px solid var(--accent);opacity:.25;animation:pulse 1.4s .35s infinite}
@keyframes pulse{0%{transform:scale(.95);opacity:.6}70%{transform:scale(1.08);opacity:0}100%{transform:scale(1.08);opacity:0}}
.avatar-name{font-family:var(--serif);font-size:24px;font-weight:500;letter-spacing:-.01em;color:var(--paper);margin-bottom:4px}
.avatar-role{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:32px}
/* ===== STATUS DOT ===== */
.status{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#888;display:flex;align-items:center;gap:8px;margin-top:auto}
.status .dot{width:8px;height:8px;border-radius:50%;background:#444;display:inline-block}
.status[data-state="speaking"] .dot{background:var(--accent);box-shadow:0 0 8px var(--accent)}
.status[data-state="listening"] .dot{background:#d87a7a;box-shadow:0 0 8px #d87a7a;animation:listen 1s infinite}
.status[data-state="thinking"] .dot{background:#c9a23a}
@keyframes listen{0%,100%{opacity:1}50%{opacity:.4}}
/* ===== WEBCAM PIP ===== */
.cam-wrap{position:absolute;top:24px;right:24px;width:240px;background:#000;border:1px solid var(--rule);border-radius:6px;overflow:hidden;z-index:10;box-shadow:0 12px 40px rgba(0,0,0,.5)}
.cam-wrap video{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;background:#000}
.cam-wrap .cam-meta{padding:6px 10px;font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:#aaa;display:flex;justify-content:space-between;align-items:center;background:#0a0a0c}
.cam-wrap .cam-meta b{color:#d87a7a}
.cam-wrap button{padding:4px 8px;background:var(--accent);color:var(--ink);border:0;font-family:var(--mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;font-weight:600;border-radius:2px}
.cam-wrap button:hover{background:#fff}
.cam-wrap.idle{opacity:.5}
/* ===== QUESTION SCREEN ===== */
.scene{max-width:780px}
.scene-num{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
.scene-section{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#888;margin-bottom:24px}
.scene-q{font-family:var(--serif);font-weight:500;font-size:clamp(28px,3.6vw,46px);line-height:1.2;letter-spacing:-.015em;margin-bottom:32px;min-height:1.6em}
.scene-a{margin-bottom:24px}
.scene-a textarea{width:100%;min-height:140px;padding:18px 20px;background:#15151a;border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:4px;font-family:var(--sans);font-size:18px;line-height:1.55;color:var(--paper);resize:vertical}
.scene-a textarea:focus{outline:none;border-color:var(--accent)}
.scene-a textarea.live{border-left:3px solid #d87a7a}
.scene-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.scene-controls button{padding:13px 22px;background:var(--accent);color:var(--ink);border:0;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:600;border-radius:4px}
.scene-controls button:hover{background:#fff}
.scene-controls button.ghost{background:transparent;color:var(--paper);border:1px solid var(--rule)}
.scene-controls button.ghost:hover{border-color:var(--accent);color:var(--accent);background:transparent}
.scene-controls button.danger{background:transparent;color:#d87a7a;border:1px solid #d87a7a}
.scene-controls button:disabled{opacity:.3;cursor:not-allowed}
.scene-controls .progress{margin-left:auto;font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#888}
.tip{margin-top:32px;padding:14px 18px;background:#15151a;border-left:2px solid var(--accent);font-family:var(--mono);font-size:11px;line-height:1.5;color:#aaa;letter-spacing:.04em}
/* ===== START SCREEN ===== */
.start{max-width:560px}
.start h1{font-family:var(--serif);font-size:64px;font-weight:500;letter-spacing:-.02em;line-height:1;margin-bottom:16px}
.start h1 em{font-style:italic;color:var(--accent)}
.start .lede{font-size:17px;line-height:1.55;color:#bbb;margin-bottom:32px}
.start .perm{display:flex;gap:14px;margin-bottom:36px;flex-wrap:wrap}
.start .perm-pill{padding:8px 14px;background:#15151a;border:1px solid var(--rule);border-radius:99px;font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#bbb}
.start .perm-pill::before{content:"○ ";color:var(--mute)}
.start .perm-pill.ok::before{content:"● ";color:var(--accent)}
.start .perm-pill.deny::before{content:"× ";color:#d87a7a}
.start button.primary{padding:18px 36px;background:var(--accent);color:var(--ink);border:0;font-family:var(--mono);font-size:13px;letter-spacing:.2em;text-transform:uppercase;font-weight:600;border-radius:4px}
.start button.primary:hover{background:#fff}
.start .skip{display:block;margin-top:24px;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#666}
.start .skip a{color:var(--accent)}
/* ===== END SCREEN ===== */
.end{max-width:680px;text-align:center}
.end h2{font-family:var(--serif);font-size:64px;font-weight:500;letter-spacing:-.02em;margin-bottom:16px}
.end h2 em{font-style:italic;color:var(--accent)}
.end p{font-size:17px;color:#bbb;line-height:1.55;margin-bottom:32px}
/* ===== UTIL ===== */
.hidden{display:none !important}
.scene.fade-in{animation:fade .5s ease}
@keyframes fade{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
</style>
</head>
<body>
<div class="stage">
<!-- LEFT: avatar + status -->
<aside class="left">
<div class="avatar" id="avatar">
<!-- Stylized SVG interviewer — abstract, editorial, no uncanny-valley face -->
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="bg" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#2a2a32"/>
<stop offset="100%" stop-color="#0e0e10"/>
</radialGradient>
</defs>
<circle cx="100" cy="100" r="98" fill="url(#bg)"/>
<!-- shoulders -->
<path d="M 30 200 Q 30 150 100 145 Q 170 150 170 200 Z" fill="${accent}" opacity=".22"/>
<!-- head silhouette -->
<ellipse cx="100" cy="92" rx="42" ry="48" fill="#15151a" stroke="${accent}" stroke-width="1.5"/>
<!-- abstract glasses-line as editorial signifier -->
<line x1="68" y1="92" x2="132" y2="92" stroke="${accent}" stroke-width="1.5"/>
<circle cx="83" cy="92" r="9" fill="none" stroke="${accent}" stroke-width="1.5"/>
<circle cx="117" cy="92" r="9" fill="none" stroke="${accent}" stroke-width="1.5"/>
<!-- mouth indicator -->
<line id="mouth" x1="92" y1="115" x2="108" y2="115" stroke="${accent}" stroke-width="2.5" stroke-linecap="round"/>
</svg>
</div>
<div class="avatar-name">The Interviewer</div>
<div class="avatar-role">LA Salon Directory · Editorial</div>
<div class="status" id="status" data-state="idle">
<span class="dot"></span><span id="statusText">Ready</span>
</div>
</aside>
<!-- RIGHT: dynamic content -->
<main class="right">
<!-- Webcam PIP (hidden until camera is up) -->
<div class="cam-wrap idle" id="camWrap">
<video id="cam" autoplay muted playsinline></video>
<div class="cam-meta">
<span><b id="recDot">●</b> Live · <span id="camStatus">offline</span></span>
<button id="snapBtn" disabled>Snap photo</button>
</div>
</div>
<!-- START SCREEN -->
<div class="start" id="startScreen">
<h1>Sit down. <em>Let's talk shop.</em></h1>
<p class="lede">A 5-minute editorial interview about <strong>${esc(b.name)}</strong>. The Interviewer will ask, you talk — your camera and mic do the rest. Pause, edit, or skip any question. We'll save everything to your profile when you're done.</p>
<div class="perm">
<div class="perm-pill" id="permMic">Microphone</div>
<div class="perm-pill" id="permCam">Camera</div>
<div class="perm-pill" id="permTts">Voice</div>
<div class="perm-pill" id="permStt">Transcribe</div>
</div>
<button class="primary" id="startBtn">Start the interview →</button>
<a class="skip" href="/biz/${esc(b.slug)}/interview">Prefer to type? Use the standard form →</a>
</div>
<!-- QUESTION SCENE -->
<div class="scene hidden" id="scene">
<div class="scene-num"><span id="qNum">Q 1 of ${QUESTIONS.length}</span></div>
<div class="scene-section" id="qSection">${esc(SECTIONS.story.label)}</div>
<div class="scene-q" id="qText"></div>
<div class="scene-a">
<textarea id="aText" rows="5" placeholder="Speak naturally — we'll transcribe. Or type if you prefer."></textarea>
</div>
<div class="scene-controls">
<button id="repeatBtn" class="ghost">↻ Repeat question</button>
<button id="micBtn" class="ghost">🎙 Listen again</button>
<button id="skipBtn" class="ghost">Skip</button>
<button id="nextBtn">Save & next →</button>
<span class="progress" id="progressTxt">1 / ${QUESTIONS.length}</span>
</div>
<div class="tip">Tip: walk the camera around the shop while you answer the neighborhood questions — every "Snap photo" click saves a candidate hero image to your gallery.</div>
</div>
<!-- END SCREEN -->
<div class="end hidden" id="endScreen">
<h2>That's a <em>wrap.</em></h2>
<p id="endStats">Saving your interview…</p>
<a class="primary" id="endLink" href="/biz/${esc(b.slug)}" style="display:inline-block;padding:18px 36px;background:var(--accent);color:var(--ink);font-family:var(--mono);font-size:13px;letter-spacing:.2em;text-transform:uppercase;font-weight:600;border-radius:4px;text-decoration:none">View your profile →</a>
</div>
</main>
</div>
<script>
// =================================================================
// State
// =================================================================
const SLUG = ${escScript(b.slug)};
const BIZ_NAME = ${escScript(b.name)};
const QUESTIONS = ${escScript(questionsForClient)};
const ANSWERS = Object.fromEntries(QUESTIONS.map(q => [q.id, q.prefill || '']));
let qIdx = 0;
let recognition = null;
let mediaStream = null;
let speaking = false;
let listening = false;
// =================================================================
// Capabilities probe
// =================================================================
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const ttsOK = 'speechSynthesis' in window;
const sttOK = !!SR;
const camOK = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
document.getElementById('permTts').classList.toggle('ok', ttsOK);
document.getElementById('permTts').classList.toggle('deny', !ttsOK);
document.getElementById('permStt').classList.toggle('ok', sttOK);
document.getElementById('permStt').classList.toggle('deny', !sttOK);
// =================================================================
// Status helpers
// =================================================================
const statusEl = document.getElementById('status');
const statusTxt = document.getElementById('statusText');
const avatarEl = document.getElementById('avatar');
function setStatus(state, txt) {
statusEl.dataset.state = state;
statusTxt.textContent = txt;
avatarEl.classList.toggle('speaking', state === 'speaking');
}
// =================================================================
// Camera + mic — single getUserMedia call
// =================================================================
async function startMedia() {
if (!camOK) {
document.getElementById('permCam').classList.add('deny');
document.getElementById('permMic').classList.add('deny');
return false;
}
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
audio: true,
});
document.getElementById('cam').srcObject = mediaStream;
document.getElementById('camWrap').classList.remove('idle');
document.getElementById('camStatus').textContent = 'rolling';
document.getElementById('snapBtn').disabled = false;
document.getElementById('permCam').classList.add('ok');
document.getElementById('permMic').classList.add('ok');
return true;
} catch (e) {
console.warn('media denied', e);
document.getElementById('permCam').classList.add('deny');
document.getElementById('permMic').classList.add('deny');
document.getElementById('camStatus').textContent = 'denied';
return false;
}
}
// =================================================================
// TTS — speak the question
// =================================================================
function speak(text) {
return new Promise((resolve) => {
if (!ttsOK) return resolve();
speaking = true;
setStatus('speaking', 'Asking…');
// Animate the mouth line a tiny bit while speaking
const mouth = document.getElementById('mouth');
const mouthInterval = setInterval(() => {
const w = 8 + Math.random() * 12;
mouth.setAttribute('x1', String(100 - w / 2));
mouth.setAttribute('x2', String(100 + w / 2));
}, 110);
const u = new SpeechSynthesisUtterance(text);
u.rate = 0.95; u.pitch = 1.0; u.volume = 1;
// Prefer a higher-quality voice if one is available
const voices = speechSynthesis.getVoices();
const preferred = voices.find(v => /Samantha|Karen|Alex|Daniel|Aria|Jenny|Google US English/i.test(v.name));
if (preferred) u.voice = preferred;
u.onend = () => {
clearInterval(mouthInterval);
mouth.setAttribute('x1', '92'); mouth.setAttribute('x2', '108');
speaking = false;
resolve();
};
u.onerror = () => { clearInterval(mouthInterval); speaking = false; resolve(); };
speechSynthesis.cancel();
speechSynthesis.speak(u);
});
}
// =================================================================
// STT — listen for the answer, append to textarea live
// =================================================================
function startListening() {
if (!sttOK) return;
if (recognition) try { recognition.abort(); } catch {}
recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
const ta = document.getElementById('aText');
ta.classList.add('live');
let baseline = ta.value;
if (baseline && !baseline.endsWith(' ')) baseline += ' ';
let finalText = baseline;
recognition.onresult = (ev) => {
let interim = '';
for (let i = ev.resultIndex; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript;
if (ev.results[i].isFinal) finalText += t + ' ';
else interim += t;
}
ta.value = finalText + interim;
};
recognition.onerror = (e) => { console.warn('stt error', e.error); };
recognition.onend = () => {
ta.classList.remove('live');
listening = false;
if (statusEl.dataset.state === 'listening') setStatus('idle', 'Edit if you like');
};
try {
recognition.start();
listening = true;
setStatus('listening', 'Listening…');
} catch (e) { console.warn('stt start failed', e); }
}
function stopListening() {
if (recognition && listening) try { recognition.stop(); } catch {}
}
// =================================================================
// Snap photo — capture canvas frame from webcam, POST to server
// =================================================================
document.getElementById('snapBtn').addEventListener('click', async () => {
const video = document.getElementById('cam');
if (!video.videoWidth) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
const btn = document.getElementById('snapBtn');
btn.disabled = true; btn.textContent = 'saving…';
try {
const res = await fetch('/api/biz/photo/upload', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: SLUG, dataUrl, kind: 'interview-frame' }),
});
const r = await res.json();
btn.textContent = r.ok ? 'saved ✓' : 'failed';
setTimeout(() => { btn.textContent = 'Snap photo'; btn.disabled = false; }, 1400);
} catch (e) {
btn.textContent = 'failed'; setTimeout(() => { btn.textContent = 'Snap photo'; btn.disabled = false; }, 1400);
}
});
// =================================================================
// Question flow
// =================================================================
async function showQuestion(idx) {
if (idx >= QUESTIONS.length) return finish();
qIdx = idx;
const q = QUESTIONS[idx];
document.getElementById('qNum').textContent = 'Q ' + (idx + 1) + ' of ' + QUESTIONS.length;
document.getElementById('qSection').textContent = q.sectionLabel;
document.getElementById('qText').textContent = q.q;
document.getElementById('progressTxt').textContent = (idx + 1) + ' / ' + QUESTIONS.length;
const ta = document.getElementById('aText');
ta.value = ANSWERS[q.id] || '';
ta.placeholder = q.placeholder;
document.getElementById('scene').classList.remove('fade-in'); void document.getElementById('scene').offsetWidth;
document.getElementById('scene').classList.add('fade-in');
await speak(q.q);
// Auto-listen unless user already typed something
if (!ta.value.trim()) startListening();
}
function captureCurrent() {
const q = QUESTIONS[qIdx];
const ta = document.getElementById('aText');
ANSWERS[q.id] = (ta.value || '').trim();
}
document.getElementById('nextBtn').addEventListener('click', () => {
stopListening();
captureCurrent();
showQuestion(qIdx + 1);
});
document.getElementById('skipBtn').addEventListener('click', () => {
stopListening();
ANSWERS[QUESTIONS[qIdx].id] = '';
showQuestion(qIdx + 1);
});
document.getElementById('repeatBtn').addEventListener('click', async () => {
stopListening();
await speak(QUESTIONS[qIdx].q);
});
document.getElementById('micBtn').addEventListener('click', () => {
if (listening) stopListening();
else startListening();
});
// =================================================================
// Finish — save
// =================================================================
async function finish() {
document.getElementById('scene').classList.add('hidden');
document.getElementById('endScreen').classList.remove('hidden');
setStatus('thinking', 'Saving…');
// Stop media
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
if (recognition) try { recognition.abort(); } catch {}
const filled = Object.fromEntries(Object.entries(ANSWERS).filter(([_, v]) => v && v.trim()));
try {
const res = await fetch('/api/biz/interview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: SLUG, qa: filled, recorded_by: 'Live AI Interviewer' }),
});
const r = await res.json();
if (r.ok) {
const stats = document.getElementById('endStats');
stats.textContent = '';
// Build via textContent only — never set innerHTML on user-derived data.
stats.textContent = \`Saved \${r.fields} answers across 4 sections. Your profile is live.\`;
setStatus('idle', 'Done');
} else {
document.getElementById('endStats').textContent = 'Save failed: ' + (r.error || 'unknown');
}
} catch (e) {
document.getElementById('endStats').textContent = 'Save failed: ' + e.message;
}
}
// =================================================================
// Boot
// =================================================================
document.getElementById('startBtn').addEventListener('click', async () => {
const btn = document.getElementById('startBtn');
btn.disabled = true; btn.textContent = 'Starting…';
await startMedia();
// Force voices to load (Chrome needs a kick)
if (ttsOK) speechSynthesis.getVoices();
document.getElementById('startScreen').classList.add('hidden');
document.getElementById('scene').classList.remove('hidden');
await speak(\`Hi. I'm here to interview you about \${BIZ_NAME}. We'll go through about \${QUESTIONS.length} questions. Take your time, edit anything I get wrong, and feel free to walk the camera around the shop while you answer. Ready?\`);
showQuestion(0);
});
// Kick voice loading
if (ttsOK) speechSynthesis.onvoiceschanged = () => {};
</script>
</body>
</html>`;
}