← back to Wallco Ai

public/global-search.js

160 lines

/*!
 * global-search.js — live typeahead overlay for wallco.ai's universal search.
 * Consumes the already-built /api/search (universalSearch: categories + pages +
 * designs, scored, did-you-mean). Hijacks the header search input (id
 * global-search-input) and renders an instant dropdown as you type. Submitting
 * (Enter on no selection) still falls through to the full /search page.
 * Self-contained: injects its own CSS, zero deps. Progressive enhancement —
 * if JS is off, the form's GET /search still works.
 */
(function () {
  'use strict';
  function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }

  ready(function () {
    var input = document.getElementById('global-search-input');
    if (!input) return;
    var form = input.closest('form') || input.parentNode;

    // ── styles (scoped under .gs-*) ─────────────────────────────────────────
    if (!document.getElementById('gs-style')) {
      var st = document.createElement('style');
      st.id = 'gs-style';
      st.textContent = [
        '.gs-wrap{position:relative;flex:1;min-width:0}',
        '.gs-panel{position:absolute;left:0;right:0;top:calc(100% + 6px);z-index:60;max-height:min(70vh,520px);overflow:auto;',
        '  background:var(--card-bg,#faf8f3);border:1px solid var(--line,#d8d0c0);border-radius:14px;',
        '  box-shadow:0 18px 50px rgba(0,0,0,.22);padding:6px;display:none;font:13px var(--sans,system-ui)}',
        '.gs-panel.open{display:block}',
        '.gs-sec{padding:6px 8px 2px}',
        '.gs-sec-h{font:600 10px/1 var(--sans,system-ui);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#8a7e68);margin:6px 6px 4px}',
        '.gs-item{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:9px;text-decoration:none;color:var(--ink,#1f1808);cursor:pointer}',
        '.gs-item:hover,.gs-item.gs-active{background:rgba(0,0,0,.06)}',
        '.gs-thumb{flex:0 0 auto;width:38px;height:38px;border-radius:7px;background-size:cover;background-position:center 12%;background-color:#e9e3d6}',
        '.gs-body{min-width:0;flex:1}',
        '.gs-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
        '.gs-sub{font-size:11px;color:var(--ink-faint,#8a7e68);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
        '.gs-empty{padding:14px 12px;color:var(--ink-faint,#8a7e68);font-size:12.5px}',
        '.gs-dym{margin-top:6px}',
        '.gs-dym a{display:inline-block;margin:2px 4px 0 0;padding:3px 9px;border:1px solid var(--line,#d8d0c0);border-radius:999px;color:var(--ink,#1f1808);text-decoration:none;font-size:12px}',
        '.gs-dym a:hover{background:rgba(0,0,0,.06)}',
        '.gs-all{display:block;text-align:center;padding:9px;margin-top:4px;border-top:1px solid var(--line,#e7e0d2);color:var(--ink,#1f1808);text-decoration:none;font-size:12px;font-weight:500}',
        '.gs-all:hover{background:rgba(0,0,0,.05)}'
      ].join('');
      document.head.appendChild(st);
    }

    // wrap the input so the panel can anchor to it
    var wrap = document.createElement('span');
    wrap.className = 'gs-wrap';
    input.parentNode.insertBefore(wrap, input);
    wrap.appendChild(input);
    var panel = document.createElement('div');
    panel.className = 'gs-panel';
    panel.setAttribute('role', 'listbox');
    wrap.appendChild(panel);

    var esc = function (s) { return String(s == null ? '' : s).replace(/[<>&"]/g, function (c) { return { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]; }); };
    var debounceT = null, lastQ = '', items = [], activeIdx = -1, ctrl = null;

    function close() { panel.classList.remove('open'); activeIdx = -1; }
    function open() { if (panel.innerHTML.trim()) panel.classList.add('open'); }

    function render(data) {
      var q = data.q || '';
      var r = data.results || {};
      var designs = r.designs || [], cats = r.categories || [], pages = r.pages || [];
      var html = '';
      items = [];
      var allUrl = '/search?q=' + encodeURIComponent(q);

      if (designs.length) {
        html += '<div class="gs-sec"><div class="gs-sec-h">Designs</div>';
        designs.slice(0, 8).forEach(function (d) {
          items.push(d.url);
          html += '<a class="gs-item" role="option" href="' + esc(d.url) + '">'
            + '<span class="gs-thumb" style="background-image:url(\'' + esc(d.image_url || '') + '\')"></span>'
            + '<span class="gs-body"><span class="gs-title">' + esc(d.title || 'Untitled') + '</span>'
            + '<span class="gs-sub">' + esc(d.category || '') + '</span></span></a>';
        });
        html += '</div>';
      }
      if (cats.length) {
        html += '<div class="gs-sec"><div class="gs-sec-h">Collections</div>';
        cats.slice(0, 6).forEach(function (c) {
          items.push(c.url);
          html += '<a class="gs-item" role="option" href="' + esc(c.url) + '">'
            + '<span class="gs-thumb" style="background-image:url(\'' + esc(c.thumb || '') + '\')"></span>'
            + '<span class="gs-body"><span class="gs-title">' + esc(c.title || c.slug) + '</span>'
            + '<span class="gs-sub">' + (c.count || 0) + ' design' + (c.count === 1 ? '' : 's') + '</span></span></a>';
        });
        html += '</div>';
      }
      if (pages.length) {
        html += '<div class="gs-sec"><div class="gs-sec-h">Pages</div>';
        pages.slice(0, 6).forEach(function (p) {
          items.push(p.path || p.url);
          html += '<a class="gs-item" role="option" href="' + esc(p.path || p.url) + '">'
            + '<span class="gs-body"><span class="gs-title">' + esc(p.title || '') + '</span>'
            + '<span class="gs-sub">' + esc(p.blurb || '') + '</span></span></a>';
        });
        html += '</div>';
      }

      if (!designs.length && !cats.length && !pages.length) {
        html += '<div class="gs-empty">No matches for &ldquo;' + esc(q) + '&rdquo;.';
        if (data.did_you_mean && data.did_you_mean.length) {
          html += '<div class="gs-dym">Did you mean: ' + data.did_you_mean.map(function (s) {
            return '<a href="/search?q=' + encodeURIComponent(s.word) + '">' + esc(s.word) + '</a>';
          }).join('') + '</div>';
        }
        html += '</div>';
      } else {
        html += '<a class="gs-all" href="' + esc(allUrl) + '">See all results for &ldquo;' + esc(q) + '&rdquo; &rarr;</a>';
        items.push(allUrl);
      }
      panel.innerHTML = html;
      activeIdx = -1;
      open();
    }

    function fetchResults(q) {
      if (ctrl) try { ctrl.abort(); } catch (e) {}
      ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
      fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=8', ctrl ? { signal: ctrl.signal } : undefined)
        .then(function (r) { return r.json(); })
        .then(function (data) { if ((input.value || '').trim() === q) render(data); })
        .catch(function () {});
    }

    input.setAttribute('autocomplete', 'off');
    input.addEventListener('input', function () {
      var q = (input.value || '').trim();
      clearTimeout(debounceT);
      if (q.length < 2) { close(); panel.innerHTML = ''; lastQ = q; return; }
      debounceT = setTimeout(function () { if (q !== lastQ) { lastQ = q; fetchResults(q); } }, 180);
    });
    input.addEventListener('focus', function () { if ((input.value || '').trim().length >= 2 && panel.innerHTML.trim()) open(); });

    var nodes = function () { return panel.querySelectorAll('.gs-item, .gs-all'); };
    function setActive(i) {
      var ns = nodes(); if (!ns.length) return;
      activeIdx = (i + ns.length) % ns.length;
      ns.forEach(function (n, k) { n.classList.toggle('gs-active', k === activeIdx); });
      ns[activeIdx].scrollIntoView({ block: 'nearest' });
    }
    input.addEventListener('keydown', function (e) {
      if (!panel.classList.contains('open')) return;
      if (e.key === 'ArrowDown') { e.preventDefault(); setActive(activeIdx + 1); }
      else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(activeIdx - 1); }
      else if (e.key === 'Enter') {
        var ns = nodes();
        if (activeIdx >= 0 && ns[activeIdx]) { e.preventDefault(); window.location.href = ns[activeIdx].getAttribute('href'); }
        // else: let the form submit to /search (default behavior)
      } else if (e.key === 'Escape') { close(); }
    });

    document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
  });
})();