← back to Big Red

public/widget.js

405 lines

// Big Red embed widget — drops a circular avatar launcher into the LOWER-LEFT
// of the host page. Click → expands to an iframe of the Big Red chat UI.
// The launcher avatar is RED when closed and turns GREEN when open (live chat).
// The avatar is a woman's face on customer-facing (retail) sites; vendor info
// is NEVER surfaced to end users — see server.js MODE_PERSONAS.retail.
//
// Usage on a host page:
//   <script src="https://<big-red-host>/widget.js"
//           data-host="https://<big-red-host>"
//           defer></script>
//
// Idempotent — safe to load twice; only one launcher is mounted.
(function () {
  if (window.__bigRedMounted) return;
  window.__bigRedMounted = true;

  const me = document.currentScript;
  const host =
    (me && me.dataset && me.dataset.host) ||
    (me && me.src ? new URL(me.src, location.href).origin : '') ||
    'http://localhost:9935';

  // Context — lets Big Red toggle between modes per site.
  //   data-mode="retail"  → DW SKU lookup, live Shopify verify, customer voice
  //   data-mode="wholesale" → trade pricing, vendor margin, B2B voice
  //   data-mode="admin"   → full Claude access, app-aware, tool-use enabled
  //   data-app="<slug>"   → which internal app the bot is embedded in
  //                         (admin-console, video-gallery, wallco-ai, etc.)
  // Defaults: retail / current hostname.
  const mode = (me && me.dataset && me.dataset.mode) || 'retail';
  const appSlug = (me && me.dataset && me.dataset.app) || location.hostname.split('.')[0] || 'unknown';

  const css = `
    .bigred-launcher {
      position: fixed;
      left: 22px;
      bottom: 22px;
      z-index: 99990;
      width: 64px;
      height: 64px;
      border-radius: 50%;
      border: 0;
      padding: 0;
      cursor: pointer;
      background: #1a0506;
      overflow: hidden;
      box-shadow:
        0 0 0 3px #d8323a,
        0 0 0 5px rgba(216,50,58,.18),
        0 8px 24px rgba(216,50,58,.5),
        0 2px 10px rgba(0,0,0,.4);
      transition: right .26s cubic-bezier(.2,.7,.2,1), transform .18s cubic-bezier(.2,.7,.2,1), box-shadow .25s ease;
      animation: bigred-pulse-red 2.6s ease-in-out infinite;
    }
    /* When the panel is open, launcher slides RIGHT of the panel so they sit
       side-by-side hugging the lower-left corner. */
    .bigred-launcher.live { left: calc(22px + min(440px, calc(100vw - 44px)) + 12px); }
    .bigred-launcher img {
      width: 100%; height: 100%; display: block;
      object-fit: cover; object-position: center 28%;
      filter: saturate(.6) brightness(.92);
      transition: filter .25s ease;
    }
    .bigred-launcher::after {
      /* red wash overlay (closed state) */
      content: '';
      position: absolute; inset: 0;
      border-radius: 50%;
      background: radial-gradient(circle at 35% 30%, rgba(255,85,96,.18) 0%, rgba(139,20,25,.42) 65%, rgba(58,10,12,.55) 100%);
      mix-blend-mode: multiply;
      transition: opacity .28s ease, background .28s ease;
      pointer-events: none;
    }
    .bigred-launcher:hover { transform: scale(1.08); }
    .bigred-launcher:active { transform: scale(0.94); }

    .bigred-launcher.live {
      box-shadow:
        0 0 0 3px #2fbf4a,
        0 0 0 5px rgba(47,191,74,.20),
        0 8px 28px rgba(47,191,74,.55),
        0 2px 10px rgba(0,0,0,.4);
      animation: bigred-pulse-green 1.8s ease-in-out infinite;
    }
    .bigred-launcher.live img { filter: saturate(.85) brightness(1); }
    .bigred-launcher.live::after {
      background: radial-gradient(circle at 35% 30%, rgba(120,255,140,.22) 0%, rgba(40,160,70,.30) 65%, rgba(20,80,40,.42) 100%);
    }

    @keyframes bigred-pulse-red {
      0%, 100% { box-shadow:
        0 0 0 3px #d8323a,
        0 0 0 5px rgba(216,50,58,.18),
        0 8px 24px rgba(216,50,58,.5),
        0 2px 10px rgba(0,0,0,.4); }
      50% { box-shadow:
        0 0 0 3px #ff5560,
        0 0 0 7px rgba(255,85,96,.22),
        0 8px 32px rgba(255,85,96,.7),
        0 2px 12px rgba(0,0,0,.45); }
    }
    @keyframes bigred-pulse-green {
      0%, 100% { box-shadow:
        0 0 0 3px #2fbf4a,
        0 0 0 5px rgba(47,191,74,.20),
        0 8px 24px rgba(47,191,74,.5),
        0 2px 10px rgba(0,0,0,.4); }
      50% { box-shadow:
        0 0 0 3px #58e070,
        0 0 0 8px rgba(88,224,112,.26),
        0 8px 32px rgba(88,224,112,.72),
        0 2px 12px rgba(0,0,0,.45); }
    }

    .bigred-tag {
      position: fixed;
      left: 96px;
      bottom: 36px;
      z-index: 99990;
      pointer-events: none;
      font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", sans-serif;
      font-weight: 900;
      letter-spacing: 0.22em;
      font-size: 10px;
      color: #ff5560;
      text-shadow: 0 0 12px rgba(255,85,96,.55), 0 0 2px rgba(0,0,0,.6);
      opacity: 0;
      transform: translateX(-6px);
      transition: opacity .25s, transform .25s, color .25s, text-shadow .25s;
    }
    .bigred-launcher:hover + .bigred-tag { opacity: 1; transform: translateX(0); }
    /* Hide the tag when live — the panel is already showing context */
    .bigred-launcher.live + .bigred-tag { opacity: 0; }

    .bigred-panel {
      position: fixed;
      left: 22px;
      bottom: 22px;
      z-index: 99991;
      width: min(440px, calc(100vw - 44px));
      height: min(660px, calc(100vh - 130px));
      background: #1a0506;
      border: 1px solid #8b1419;
      border-radius: 18px;
      box-shadow:
        0 0 0 1px rgba(255,85,96,.18),
        0 24px 80px rgba(0,0,0,.55),
        0 8px 24px rgba(216,50,58,.35);
      overflow: hidden;
      display: flex;
      flex-direction: column;
      transform: translateY(20px) scale(.96);
      opacity: 0;
      pointer-events: none;
      transition: transform .22s cubic-bezier(.2,.7,.2,1), opacity .18s;
    }
    .bigred-panel.open {
      transform: translateY(0) scale(1);
      opacity: 1;
      pointer-events: auto;
    }
    .bigred-bar {
      height: 38px;
      flex: 0 0 38px;
      background: linear-gradient(180deg, #2a0506 0%, #1a0506 100%);
      border-bottom: 1px solid #44090b;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 12px 0 16px;
      color: #ff5560;
      font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", sans-serif;
      font-size: 11px;
      font-weight: 900;
      letter-spacing: 0.22em;
      text-shadow: 0 0 12px rgba(255,85,96,.5);
    }
    .bigred-bar .live-dot {
      display: inline-block; width: 7px; height: 7px; border-radius: 50%;
      background: #58e070; margin-right: 8px; vertical-align: middle;
      box-shadow: 0 0 8px #58e070;
      animation: bigred-livedot 1.4s ease-in-out infinite;
    }
    @keyframes bigred-livedot {
      0%, 100% { opacity: 1; transform: scale(1); }
      50% { opacity: 0.55; transform: scale(.85); }
    }
    .bigred-bar .close {
      background: transparent; border: 0; color: #ff5560; cursor: pointer;
      width: 26px; height: 26px; border-radius: 6px; font-size: 18px; line-height: 1;
      display: flex; align-items: center; justify-content: center;
      transition: background .12s;
    }
    .bigred-bar .close:hover { background: rgba(255,85,96,.16); }
    .bigred-frame {
      flex: 1; width: 100%; border: 0; background: #1a0506;
    }
    @media (max-width: 640px) {
      .bigred-launcher { left: 16px; bottom: 16px; width: 56px; height: 56px; }
      /* On mobile there's no room for side-by-side — launcher hides behind
         the panel and reveals again on close. */
      .bigred-launcher.live { left: 16px; opacity: 0; pointer-events: none; }
      .bigred-tag { left: 80px; bottom: 28px; font-size: 9px; }
      .bigred-panel { left: 8px; right: 8px; bottom: 8px; width: auto; height: min(560px, calc(100vh - 40px)); }
    }

    /* Per-mode launcher ring — closed state only. Live (open) state stays
       green across all modes to signal "actively chatting" universally.
       Wholesale = trade-blue, admin = operator-amber, retail = default red. */
    .bigred-launcher[data-mode="wholesale"] {
      box-shadow:
        0 0 0 3px #5fa8ff,
        0 0 0 5px rgba(95,168,255,.18),
        0 8px 24px rgba(95,168,255,.5),
        0 2px 10px rgba(0,0,0,.4);
      animation: bigred-pulse-blue 2.8s ease-in-out infinite;
    }
    .bigred-launcher[data-mode="wholesale"]::after {
      background: radial-gradient(circle at 35% 30%, rgba(140,190,255,.18) 0%, rgba(46,96,180,.40) 65%, rgba(20,50,120,.52) 100%);
    }
    .bigred-launcher[data-mode="admin"] {
      box-shadow:
        0 0 0 3px #f0a93a,
        0 0 0 5px rgba(240,169,58,.22),
        0 8px 24px rgba(240,169,58,.5),
        0 2px 10px rgba(0,0,0,.4);
      animation: bigred-pulse-amber 2.2s ease-in-out infinite;
    }
    .bigred-launcher[data-mode="admin"]::after {
      background: radial-gradient(circle at 35% 30%, rgba(255,220,140,.20) 0%, rgba(180,120,30,.40) 65%, rgba(90,55,15,.55) 100%);
    }
    /* Live state always wins — green ring overrides mode ring while open. */
    .bigred-launcher.live[data-mode] {
      box-shadow:
        0 0 0 3px #2fbf4a,
        0 0 0 5px rgba(47,191,74,.20),
        0 8px 28px rgba(47,191,74,.55),
        0 2px 10px rgba(0,0,0,.4);
      animation: bigred-pulse-green 1.8s ease-in-out infinite;
    }
    @keyframes bigred-pulse-blue {
      0%, 100% { box-shadow:
        0 0 0 3px #5fa8ff,
        0 0 0 5px rgba(95,168,255,.18),
        0 8px 24px rgba(95,168,255,.5),
        0 2px 10px rgba(0,0,0,.4); }
      50% { box-shadow:
        0 0 0 3px #8cc3ff,
        0 0 0 7px rgba(140,195,255,.24),
        0 8px 32px rgba(140,195,255,.72),
        0 2px 12px rgba(0,0,0,.45); }
    }
    @keyframes bigred-pulse-amber {
      0%, 100% { box-shadow:
        0 0 0 3px #f0a93a,
        0 0 0 5px rgba(240,169,58,.22),
        0 8px 24px rgba(240,169,58,.5),
        0 2px 10px rgba(0,0,0,.4); }
      50% { box-shadow:
        0 0 0 3px #ffcc60,
        0 0 0 7px rgba(255,204,96,.28),
        0 8px 32px rgba(255,204,96,.72),
        0 2px 12px rgba(0,0,0,.45); }
    }
    /* Tag color follows mode in closed state */
    .bigred-launcher[data-mode="wholesale"]:hover + .bigred-tag {
      color: #8cc3ff;
      text-shadow: 0 0 12px rgba(140,195,255,.55), 0 0 2px rgba(0,0,0,.6);
    }
    .bigred-launcher[data-mode="admin"]:hover + .bigred-tag {
      color: #ffcc60;
      text-shadow: 0 0 12px rgba(255,204,96,.55), 0 0 2px rgba(0,0,0,.6);
    }
  `;

  const style = document.createElement('style');
  style.id = 'bigred-widget-style';
  style.textContent = css;
  document.head.appendChild(style);

  const launcher = document.createElement('button');
  launcher.type = 'button';
  launcher.className = 'bigred-launcher';
  // data-mode drives the closed-state ring color via CSS attribute selectors
  // (retail = default red, wholesale = blue, admin = amber).
  launcher.setAttribute('data-mode', mode);
  launcher.setAttribute('aria-label', mode === 'admin'
    ? 'Open Big Red (admin operator mode)'
    : mode === 'wholesale'
      ? 'Open Big Red (trade chat)'
      : 'Open Big Red voice chat');
  // Avatar image — a woman's face on customer-facing (retail/wholesale) sites,
  // Steve's face only in admin mode. Default file is /avatar/woman.jpg; the
  // host page can override via data-avatar="<url>". Falls back to a feminine
  // SVG silhouette if the image is missing so the launcher never renders blank.
  const customAvatar = me && me.dataset && me.dataset.avatar;
  const avatarSrc = customAvatar
    || (mode === 'admin' ? host + '/avatar/steve.jpg' : host + '/avatar/woman.jpg');
  const avatarImg = document.createElement('img');
  avatarImg.alt = '';
  avatarImg.src = avatarSrc + (avatarSrc.includes('?') ? '&' : '?') + 'ts=' + Date.now();
  avatarImg.onerror = () => {
    // Avatar file missing — render an inline feminine silhouette SVG so the
    // launcher still looks like a person, not a monogram. Used universally
    // for retail/wholesale; admin falls back to the same silhouette tinted.
    avatarImg.style.display = 'none';
    const wrap = document.createElement('div');
    wrap.style.cssText = 'width:100%;height:100%;display:flex;align-items:flex-end;justify-content:center;background:radial-gradient(circle at 50% 35%,#7a1418 0%,#3a0a0c 70%,#1a0506 100%);overflow:hidden;';
    wrap.innerHTML = '<svg viewBox="0 0 64 64" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
      + '<defs><radialGradient id="brHair" cx="50%" cy="35%" r="55%">'
      + '<stop offset="0%" stop-color="#3a0a0c"/><stop offset="100%" stop-color="#1a0506"/>'
      + '</radialGradient></defs>'
      // hair (back layer, frames the face)
      + '<path d="M14 30 C14 16 22 8 32 8 C42 8 50 16 50 30 L52 44 L46 42 L46 36 C46 36 44 40 32 40 C20 40 18 36 18 36 L18 42 L12 44 Z" fill="url(#brHair)"/>'
      // face
      + '<ellipse cx="32" cy="30" rx="11" ry="13" fill="#f3d8c0"/>'
      // hair sweep over forehead
      + '<path d="M21 22 C26 17 38 17 43 22 C40 19 36 18 32 18 C28 18 24 19 21 22 Z" fill="#1a0506"/>'
      // eyes (closed soft arcs)
      + '<path d="M26 30 Q28 28 30 30" stroke="#3a0a0c" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
      + '<path d="M34 30 Q36 28 38 30" stroke="#3a0a0c" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
      // mouth
      + '<path d="M29 36 Q32 38 35 36" stroke="#a8323a" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
      // neck + shoulders (so the silhouette fills the circle bottom)
      + '<path d="M27 41 L27 46 L20 50 L20 64 L44 64 L44 50 L37 46 L37 41 Z" fill="#f3d8c0"/>'
      + '<path d="M16 56 C20 50 28 49 32 49 C36 49 44 50 48 56 L48 64 L16 64 Z" fill="#5a0d10"/>'
      + '</svg>';
    launcher.appendChild(wrap);
  };
  launcher.appendChild(avatarImg);

  const tag = document.createElement('div');
  tag.className = 'bigred-tag';
  tag.textContent = mode === 'admin'
    ? 'OPERATOR · BIG RED'
    : mode === 'wholesale'
      ? 'TRADE · BIG RED'
      : 'TALK TO BIG RED';

  const panel = document.createElement('div');
  panel.className = 'bigred-panel';
  panel.setAttribute('role', 'dialog');
  panel.setAttribute('aria-label', 'Big Red voice chat');
  panel.innerHTML = `
    <div class="bigred-bar">
      <span><span class="live-dot"></span>BIG RED · VOICE</span>
      <button class="close" aria-label="Close">×</button>
    </div>
    <iframe class="bigred-frame" title="Big Red voice chat"
            allow="microphone; camera; clipboard-read; clipboard-write; autoplay"
            src="about:blank"
            loading="lazy"></iframe>
  `;

  document.body.appendChild(launcher);
  document.body.appendChild(tag);
  document.body.appendChild(panel);

  const iframe = panel.querySelector('.bigred-frame');
  const closeBtn = panel.querySelector('.close');

  function open() {
    if (iframe.src === 'about:blank' || !iframe.src.startsWith(host)) {
      // Pass mode + app + page URL so Big Red knows which persona to load
      // and what context the user is currently looking at.
      const params = new URLSearchParams({
        embed: '1',
        voice: '1',
        mode,
        app: appSlug,
        page: location.pathname + location.search,
        title: document.title.slice(0, 120),
      });
      iframe.src = host + '/embed.html?' + params.toString();
    }
    launcher.classList.add('live');
    launcher.setAttribute('aria-label', 'Close Big Red (currently live)');
    tag.textContent = 'LIVE — TAP TO HIDE';
    panel.classList.add('open');
  }
  function close() {
    launcher.classList.remove('live');
    launcher.setAttribute('aria-label', 'Open Big Red voice chat');
    tag.textContent = 'TALK TO BIG RED';
    panel.classList.remove('open');
  }
  function toggle() {
    if (panel.classList.contains('open')) close(); else open();
  }
  launcher.addEventListener('click', toggle);
  closeBtn.addEventListener('click', close);

  // Esc closes the panel
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && panel.classList.contains('open')) close();
  });
  // The embed iframe posts {type:'bigred:close'} on Esc inside the chat.
  window.addEventListener('message', (e) => {
    if (e && e.data && e.data.type === 'bigred:close') close();
  });

  // Public API
  window.BigRed = { open, close, toggle, host, mode, app: appSlug };
})();