← back to Wallco Ai
public/js/chat.js
174 lines
/* wallco.ai chat panel — drop-in widget.
*
* Usage:
* <script src="/js/chat.js" defer
* data-context="catalog" ← "catalog" or "design:<id>"
* data-cta="Chat with our designer"></script>
*
* Renders a floating chat button → opens a panel pinned to the bottom-right.
* Persists session_uuid in localStorage so conversations survive reloads.
*/
(function () {
if (window.__WALLCO_CHAT_LOADED__) return; window.__WALLCO_CHAT_LOADED__ = true;
// Mobile kill-switch — Steve 2026-05-21. Chat is desktop-only; on phones
// the FAB and panel were eating the bottom of the catalog. Bail before
// any DOM injection so we save the bytes + the layout.
// Threshold matches the existing 768px mobile breakpoint used elsewhere
// in wallco.ai's stylesheet (and matches the standard tablet/phone cut).
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) {
return;
}
const tag = document.currentScript || document.querySelector('script[src*="/js/chat.js"]');
// Auto-detect /design/:id URLs; otherwise catalog
let CTX_KEY = (tag && tag.dataset.context) || 'catalog';
const designMatch = location.pathname.match(/^\/design\/(\d+)/);
if (designMatch && CTX_KEY === 'catalog') CTX_KEY = 'design:' + designMatch[1];
const CTA = (tag && tag.dataset.cta) || (CTX_KEY.startsWith('design:') ? 'Request a variant' : 'Chat with our designer');
const STORAGE_KEY = 'wallco.chat.' + CTX_KEY;
const isDesign = CTX_KEY.startsWith('design:');
const designId = isDesign ? CTX_KEY.split(':')[1] : null;
const ENDPOINT = isDesign ? `/api/chat/design/${designId}` : '/api/chat/catalog';
// ─── inject stylesheet
if (!document.querySelector('link[href="/css/chat.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/chat.css';
document.head.appendChild(link);
}
// ─── DOM
const fab = document.createElement('button');
fab.id = 'wallco-chat-fab'; fab.type = 'button';
fab.innerHTML = `<span class="fab-spark">✦</span> <span>${CTA}</span>`;
document.body.appendChild(fab);
const panel = document.createElement('div');
panel.id = 'wallco-chat-panel';
panel.innerHTML = `
<header>
<div>
<h3 class="h-title">${isDesign ? 'Vary this design' : 'Design concierge'}</h3>
<div class="h-sub">${isDesign ? 'Color · scale · mood' : 'Browse · filter · generate'}</div>
</div>
<button class="h-close" type="button" aria-label="Close">×</button>
</header>
<div id="wallco-chat-log"></div>
<div class="wc-suggest">
${(isDesign
? ['Make it more muted', 'Swap gold for silver', 'Tighter pattern']
: ['Show me sapphire damasks', 'Olive florals please', 'Art deco fan in brass']
).map(s => `<button type="button">${s}</button>`).join('')}
</div>
<form id="wallco-chat-form">
<textarea placeholder="${isDesign ? 'How should we change it?' : 'Search or describe a new design…'}" rows="1"></textarea>
<button type="submit">Send</button>
</form>`;
document.body.appendChild(panel);
const log = panel.querySelector('#wallco-chat-log');
const form = panel.querySelector('#wallco-chat-form');
const ta = form.querySelector('textarea');
const submit = form.querySelector('button');
const closeBtn= panel.querySelector('.h-close');
fab.addEventListener('click', () => { panel.classList.add('is-open'); fab.style.display = 'none'; ta.focus(); });
closeBtn.addEventListener('click', () => { panel.classList.remove('is-open'); fab.style.display = ''; });
// Suggestion chips
panel.querySelectorAll('.wc-suggest button').forEach(b => b.addEventListener('click', () => {
ta.value = b.textContent; form.requestSubmit();
}));
// Restore session
let sessionUuid = localStorage.getItem(STORAGE_KEY) || null;
if (sessionUuid) restoreSession();
async function restoreSession() {
try {
const r = await fetch('/api/chat/session/' + sessionUuid);
if (!r.ok) return;
const j = await r.json();
(j.messages || []).forEach(m => addMsg(m.role, m.content));
} catch {}
}
function addMsg(role, text) {
const d = document.createElement('div');
d.className = 'wc-msg ' + role;
d.textContent = text;
log.appendChild(d); log.scrollTop = log.scrollHeight;
return d;
}
function addThinking() {
const d = document.createElement('div');
d.className = 'wc-msg thinking';
d.innerHTML = '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
log.appendChild(d); log.scrollTop = log.scrollHeight;
return d;
}
function addSearchGrid(designs) {
const wrap = document.createElement('div'); wrap.className = 'wc-actions';
designs.forEach(d => {
const c = document.createElement('a');
c.className = 'wc-card'; c.href = '/design/' + d.id; c.target = '_self';
c.innerHTML = `<img src="${d.image_url}" alt="${d.title}"><div class="cap">${d.title.slice(0,40)}</div>`;
wrap.appendChild(c);
});
log.appendChild(wrap); log.scrollTop = log.scrollHeight;
}
function addNewDesign(design, label) {
const w = document.createElement('div'); w.className = 'wc-new';
w.innerHTML = `<img src="${design.image_url}" alt="generated">
<div class="meta">
<div class="title">${label || 'Original'}</div>
<div class="swatch"><span class="dot" style="background:${design.dominant_hex}"></span>${design.dominant_hex}</div>
<a href="/design/${design.id}" class="link">View design →</a>
</div>`;
log.appendChild(w); log.scrollTop = log.scrollHeight;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = ta.value.trim(); if (!msg) return;
addMsg('user', msg);
ta.value = ''; submit.disabled = true;
const thinking = addThinking();
try {
const r = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, session_uuid: sessionUuid })
});
const j = await r.json();
thinking.remove();
if (j.session_uuid && j.session_uuid !== sessionUuid) {
sessionUuid = j.session_uuid; localStorage.setItem(STORAGE_KEY, sessionUuid);
}
const reply = (j.reply || '').replace(/```[\s\S]*?```/g, '').trim();
if (reply) addMsg('assistant', reply);
else if (j.actions && j.actions[0]) {
// Server didn't render a preamble — synthesize one
if (j.actions[0].type === 'search_results') addMsg('assistant', `Here are ${j.actions[0].count} matches:`);
if (j.actions[0].type === 'new_design') addMsg('assistant', 'Generated a new design for you.');
if (j.actions[0].type === 'variant') addMsg('assistant', "Here's a variant.");
}
(j.actions || []).forEach(a => {
if (a.type === 'search_results') addSearchGrid(a.designs || []);
if (a.type === 'new_design') addNewDesign(a.design, 'New original');
if (a.type === 'variant') addNewDesign(a.design, 'Variant of #' + a.parent_id);
});
} catch (err) {
thinking.remove();
const e2 = document.createElement('div'); e2.className = 'wc-msg error'; e2.textContent = 'Sorry — ' + err.message; log.appendChild(e2);
}
submit.disabled = false; ta.focus();
});
// Enter to submit (Shift+Enter = newline)
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
});
})();