← 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 { '<': '<', '>': '>', '&': '&', '"': '"' }[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 “' + esc(q) + '”.';
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 “' + esc(q) + '” →</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(); });
});
})();