← 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 };
})();