← back to Big Red
public/embed.html
261 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Big Red — chat</title>
<style>
:root {
--bg-1: #1a0506;
--bg-2: #2a0506;
--red: #d8323a;
--red-glow: #ff5560;
--green: #58e070;
--amber: #f0a93a;
--blue: #5fa8ff;
--paper: #f7efe6;
--ink-soft: #c8b8a8;
--ink-faint: #8a7a72;
--line: #44090b;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg-1); color: var(--paper); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", sans-serif; overflow: hidden; }
body {
background:
radial-gradient(circle at 25% 0%, #44090b 0%, transparent 55%),
radial-gradient(circle at 80% 100%, #2a0506 0%, transparent 60%),
linear-gradient(160deg, var(--bg-1), var(--bg-2));
}
.head {
flex: 0 0 auto;
padding: 14px 16px 12px;
display: flex; align-items: center; gap: 12px;
border-bottom: 1px solid var(--line);
}
.av-wrap {
position: relative; width: 48px; height: 48px; flex: 0 0 48px;
}
.av {
width: 48px; height: 48px; border-radius: 50%;
background: #2a0506 center/cover no-repeat;
border: 2px solid var(--red);
box-shadow: 0 0 18px rgba(255,85,96,.5);
filter: saturate(.85);
transition: border-color .25s, box-shadow .25s, filter .25s;
}
.live .av { border-color: var(--green); box-shadow: 0 0 22px rgba(88,224,112,.55); filter: saturate(1); }
.live[data-mode="admin"] .av { border-color: var(--amber); box-shadow: 0 0 22px rgba(240,169,58,.55); }
.live[data-mode="wholesale"] .av { border-color: var(--blue); box-shadow: 0 0 22px rgba(95,168,255,.55); }
.head-info { flex: 1; min-width: 0; }
.name { font-weight: 800; letter-spacing: 0.12em; color: var(--red-glow); font-size: 12px; text-shadow: 0 0 8px rgba(255,85,96,.45); }
.ctx { font-size: 11px; color: var(--ink-faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mode-pill {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 9.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase;
margin-left: 6px;
}
.mode-pill.retail { background: rgba(216,50,58,.25); color: var(--red-glow); }
.mode-pill.wholesale { background: rgba(95,168,255,.22); color: var(--blue); }
.mode-pill.admin { background: rgba(240,169,58,.22); color: var(--amber); }
.msgs {
flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 10px;
}
.msg { font-size: 13.5px; line-height: 1.5; padding: 9px 13px; border-radius: 13px; max-width: 86%; white-space: pre-wrap; word-wrap: break-word; }
.msg.user { align-self: flex-end; background: var(--red); color: #1a0506; border-bottom-right-radius: 4px; }
.msg.assistant { align-self: flex-start; background: rgba(255,239,230,.06); color: var(--paper); border: 1px solid rgba(255,239,230,.08); border-bottom-left-radius: 4px; }
.msg.pending { opacity: 0.65; font-style: italic; }
.placeholder {
color: var(--ink-faint); font-size: 12px; text-align: center;
padding: 60px 24px;
line-height: 1.6;
}
.placeholder code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--red-glow); background: rgba(255,85,96,.08); padding: 1px 4px; border-radius: 3px; }
body.has-msgs .placeholder { display: none; }
.input-row {
flex: 0 0 auto; padding: 10px 12px;
border-top: 1px solid var(--line);
display: flex; gap: 6px; align-items: flex-end;
}
textarea {
flex: 1; resize: none; min-height: 38px; max-height: 120px;
background: rgba(255,239,230,.04); color: var(--paper);
border: 1px solid var(--line); border-radius: 10px;
padding: 9px 12px; font: inherit; font-size: 13px;
outline: none; transition: border-color .12s;
}
textarea:focus { border-color: var(--red); }
button {
background: var(--red); color: #1a0506; border: 0; border-radius: 10px;
padding: 9px 14px; font-weight: 700; font-size: 13px; cursor: pointer;
transition: background .12s, opacity .12s;
height: 38px;
}
button:hover:not(:disabled) { background: var(--red-glow); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.mic { background: rgba(255,239,230,.06); color: var(--paper); border: 1px solid var(--line); padding: 0 12px; width: 44px; }
.mic:hover:not(:disabled) { background: rgba(255,85,96,.18); }
.mic.listening { background: var(--red-glow); color: #1a0506; border-color: var(--red-glow); }
.layout { display: flex; flex-direction: column; height: 100%; }
</style>
</head>
<body class="layout">
<div class="head">
<div class="av-wrap"><div class="av" id="av"></div></div>
<div class="head-info">
<div class="name">BIG RED <span class="mode-pill" id="modePill"></span></div>
<div class="ctx" id="ctx">connecting…</div>
</div>
</div>
<div class="msgs" id="msgs">
<div class="placeholder">
Ask about a DW SKU like <code>DWKK-150018</code> and I'll fetch the live Shopify price.
Mic for voice. Esc to close.
</div>
</div>
<div class="input-row">
<button class="mic" id="micBtn" type="button" title="Hold to speak" aria-label="Voice input">🎙</button>
<textarea id="txt" rows="1" placeholder="Talk to Big Red…"></textarea>
<button id="sendBtn" type="button">Send</button>
</div>
<script>
const params = new URLSearchParams(location.search);
const MODE = (params.get('mode') || 'retail').toLowerCase();
const APP = params.get('app') || '';
const PAGE = params.get('page') || '';
const TITLE = params.get('title') || '';
const HIST = [];
document.body.dataset.mode = MODE;
document.getElementById('modePill').textContent = MODE;
document.getElementById('modePill').className = 'mode-pill ' + MODE;
const ctxEl = document.getElementById('ctx');
if (MODE === 'admin' && APP) ctxEl.textContent = `${APP}${PAGE ? ' · ' + PAGE : ''}`;
else if (MODE === 'wholesale') ctxEl.textContent = 'Trade catalog · vendor + MOQ + lead time';
else ctxEl.textContent = 'Retail · live Shopify price verify';
// Avatar — use what /api/avatar returns; falls back to BR monogram.
fetch('/api/avatar').then(r => r.json()).then(d => {
if (d.exists && d.url) document.getElementById('av').style.backgroundImage = `url('${d.url}')`;
else document.getElementById('av').textContent = 'BR';
}).catch(() => {});
document.body.classList.toggle('live', false);
// "Live" state — pulse on mic active or first message sent.
function setLive(on) { document.body.classList.toggle('live', !!on); }
const msgsEl = document.getElementById('msgs');
const txt = document.getElementById('txt');
const sendBtn = document.getElementById('sendBtn');
const micBtn = document.getElementById('micBtn');
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function render() {
document.body.classList.toggle('has-msgs', HIST.length > 0);
msgsEl.innerHTML = HIST.map(m =>
`<div class="msg ${m.role}${m.pending?' pending':''}">${esc(m.content)}</div>`
).join('') || msgsEl.innerHTML;
if (HIST.length === 0) {
msgsEl.innerHTML = '<div class="placeholder">Ask about a DW SKU like <code>DWKK-150018</code> and I\'ll fetch the live Shopify price. Mic for voice. Esc to close.</div>';
}
msgsEl.scrollTop = msgsEl.scrollHeight;
}
async function send() {
const message = txt.value.trim();
if (!message) return;
txt.value = '';
HIST.push({ role: 'user', content: message });
HIST.push({ role: 'assistant', content: '…', pending: true });
render();
setLive(true);
sendBtn.disabled = true;
try {
const r = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
history: HIST.filter(m => !m.pending).slice(0, -1),
mode: MODE,
app: APP,
page: PAGE,
title: TITLE,
}),
});
const d = await r.json();
HIST.pop();
if (d.reply) {
HIST.push({ role: 'assistant', content: d.reply });
// Speak with TTS if available — fire and forget; doesn't block UI.
tryTts(d.reply).catch(() => {});
} else {
HIST.push({ role: 'assistant', content: '⚠ ' + (d.error || 'no reply') });
}
} catch (e) {
HIST.pop();
HIST.push({ role: 'assistant', content: '⚠ ' + e.message });
}
sendBtn.disabled = false;
render();
txt.focus();
}
async function tryTts(text) {
try {
const r = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!r.ok) return;
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play().catch(() => {});
audio.addEventListener('ended', () => URL.revokeObjectURL(url));
} catch {}
}
sendBtn.addEventListener('click', send);
txt.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
txt.focus();
// Voice input — Web Speech API (Chrome/Safari)
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let rec = null, listening = false;
if (SR) {
rec = new SR();
rec.lang = 'en-US';
rec.interimResults = false;
rec.continuous = false;
rec.onresult = (e) => {
const result = Array.from(e.results).map(r => r[0].transcript).join(' ').trim();
if (result) {
txt.value = result;
send();
}
};
rec.onend = () => { listening = false; micBtn.classList.remove('listening'); };
micBtn.addEventListener('click', () => {
if (listening) { try { rec.stop(); } catch {} return; }
try { rec.start(); listening = true; micBtn.classList.add('listening'); } catch {}
});
} else {
micBtn.style.display = 'none';
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Notify parent widget to close
if (window.parent !== window) window.parent.postMessage({ type: 'bigred:close' }, '*');
}
});
</script>
</body>
</html>