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