← 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>