PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/search_suggest_controller-ed0f9ff6.js

js littlebirdelectronics.com.au collected 2026-09-25 14:11:23 UTC 17,280 bytes, 405 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Amazon-style search autocomplete with recent searches ("keep shopping for")
4// and live product suggestions.
5//
6// <form data-controller="ecommerce--search-suggest"
7//       data-ecommerce--search-suggest-url-value="/products/suggest"
8//       data-ecommerce--search-suggest-search-url-value="/products">
9//   <input data-ecommerce--search-suggest-target="input"
10//          data-action="input->ecommerce--search-suggest#onInput focus->ecommerce--search-suggest#onFocus keydown->ecommerce--search-suggest#onKeydown">
11//   <button data-ecommerce--search-suggest-target="clear"
12//           data-action="ecommerce--search-suggest#clear" class="hidden">×</button>
13//   <div data-ecommerce--search-suggest-target="panel" class="hidden"></div>
14// </form>
15const STORAGE_KEY = "lb_recent_searches"
16const MAX_RECENT = 6
17
18export default class extends Controller {
19  static targets = ["input", "panel", "clear", "scope"]
20  static values = { url: String, searchUrl: String }
21
22  connect() {
23    this.timeout = null
24    this.boundDocClick = this.onDocClick.bind(this)
25    document.addEventListener("click", this.boundDocClick)
26    this.element.addEventListener("submit", this.onSubmit.bind(this))
27    this.updateClearVisibility()
28  }
29
30  disconnect() {
31    clearTimeout(this.timeout)
32    document.removeEventListener("click", this.boundDocClick)
33  }
34
35  onFocus() {
36    const q = this.inputTarget.value.trim()
37    if (q.length < 2) {
38      this.renderRecent()
39    } else {
40      this.fetchSuggestions(q)
41    }
42  }
43
44  onInput() {
45    this.updateClearVisibility()
46    const q = this.inputTarget.value.trim()
47    clearTimeout(this.timeout)
48    if (q.length < 2) {
49      this.renderRecent()
50      return
51    }
52    this.timeout = setTimeout(() => this.fetchSuggestions(q), 180)
53  }
54
55  onKeydown(event) {
56    if (event.key === "Escape") {
57      this.hide()
58      this.inputTarget.blur()
59    }
60  }
61
62  onSubmit() {
63    const q = this.inputTarget.value.trim()
64    if (q) this.saveRecent(q)
65  }
66
67  clear(event) {
68    event?.preventDefault()
69    this.inputTarget.value = ""
70    this.updateClearVisibility()
71    this.renderRecent()
72    this.inputTarget.focus()
73  }
74
75  // Re-run the dropdown when the scope <select> changes so it previews the
76  // newly-selected content type (help, blog, pages, products…) immediately.
77  onScopeChange() {
78    const q = this.inputTarget.value.trim()
79    if (q.length < 2) {
80      this.renderRecent()
81    } else {
82      this.fetchSuggestions(q)
83    }
84  }
85
86  // The selected search scope: a keyword (products/all/help/blog/pages/…) sent
87  // to the suggest endpoint, plus the path its "See all results" link targets.
88  // Falls back to "all" when there's no scope <select> (e.g. the mobile bar).
89  currentScope() {
90    const fallback = { keyword: "all", seeAllUrl: this.searchUrlValue || "/products" }
91    if (!this.hasScopeTarget) return fallback
92    const opt = this.scopeTarget.selectedOptions[0]
93    if (!opt) return fallback
94    return { keyword: opt.dataset.scope || "all", seeAllUrl: opt.value || fallback.seeAllUrl }
95  }
96
97  async fetchSuggestions(q) {
98    try {
99      const url = new URL(this.urlValue, window.location.origin)
100      url.searchParams.set("q", q)
101      url.searchParams.set("scope", this.currentScope().keyword)
102      const res = await fetch(url, { headers: { Accept: "application/json" } })
103      if (!res.ok) return
104      const data = await res.json()
105      if (data.override) {
106        this.renderOverride(q, data.override)
107      } else {
108        this.renderResults(q, data.sections || [])
109      }
110    } catch (_) {
111      // silent
112    }
113  }
114
115  // Admin-curated override: hand-picked links replace the live products, with an
116  // optional row of "try also" terms above the "See all results" link.
117  renderOverride(q, override) {
118    const esc = this.escape
119    const searchUrl = this.searchUrlValue || "/products"
120
121    const itemsHtml = (override.items || []).map(it => {
122      const leading = it.image
123        ? `<img src="${esc(it.image)}" class="h-10 w-10 rounded object-contain bg-white" alt="">`
124        : `<div class="h-10 w-10 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-gray-500 dark:text-gray-300">${it.icon_svg || ""}</div>`
125
126      // Sub-links render as their OWN pill-link anchors below the parent — kept
127      // outside the parent <a> so the parent row doesn't swallow their clicks
128      // (nested anchors are invalid and would hijack the target).
129      const subs = it.children || []
130      const subsHtml = subs.length === 0 ? "" : `
131        <div class="flex flex-wrap gap-1.5 mt-1.5 pl-[3.25rem]">
132          ${subs.map(s => `
133            <a href="${esc(this.safeUrl(s.url))}" class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-0.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700">
134              ${esc(s.title)}
135            </a>`).join("")}
136        </div>`
137
138      // The optional subtitle is a muted second row beneath the title (regular
139      // weight; the title above carries the emphasis with font-medium).
140      const subtitleHtml = it.subtitle
141        ? `<span class="block text-xs text-gray-500 dark:text-gray-400 line-clamp-1">${esc(it.subtitle)}</span>`
142        : ""
143
144      return `
145        <div class="px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/60">
146          <a href="${esc(this.safeUrl(it.url))}" class="flex items-center gap-3 -mx-1 px-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800">
147            ${leading}
148            <span class="min-w-0 flex-1">
149              <span class="block text-sm font-medium text-gray-800 dark:text-gray-100 line-clamp-2">${esc(it.title)}</span>
150              ${subtitleHtml}
151            </span>
152          </a>
153          ${subsHtml}
154        </div>`
155    }).join("")
156
157    const terms = override.related_terms || []
158    const termsHtml = terms.length === 0 ? "" : `
159      <div class="border-t border-gray-100 dark:border-gray-800 px-4 py-2.5">
160        <div class="flex flex-wrap gap-2">
161          ${terms.map(t => `
162            <button type="button" data-action="ecommerce--search-suggest#useTerm" data-term="${esc(t)}"
163                    class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700">
164              ${esc(t)}
165            </button>`).join("")}
166        </div>
167      </div>`
168
169    this.panelTarget.innerHTML = `
170      ${itemsHtml ? `<div class="py-1">${itemsHtml}</div>` : ""}
171      ${termsHtml}
172      <a href="${searchUrl}?q=${encodeURIComponent(q)}" class="block border-t border-gray-100 dark:border-gray-800 px-4 py-2.5 text-sm font-semibold text-brand-700 dark:text-brand-300 hover:bg-gray-100 dark:hover:bg-gray-800">
173        See all results for “${esc(q)}”
174      </a>`
175    this.show()
176  }
177
178  // "Try also" chip: drop the term into the search box and re-run the dropdown.
179  useTerm(event) {
180    event.preventDefault()
181    const term = event.currentTarget.dataset.term || ""
182    this.inputTarget.value = term
183    this.updateClearVisibility()
184    this.inputTarget.focus()
185    const q = term.trim()
186    if (q.length < 2) {
187      this.renderRecent()
188    } else {
189      this.fetchSuggestions(q)
190    }
191  }
192
193  // Render the scope-aware sections payload: zero or more {kind, label, items}
194  // groups (products render as image+SKU rows, everything else as doc rows),
195  // optionally preceded by recent searches and followed by a scope-aware
196  // "See all results" link.
197  renderResults(q, sections) {
198    const esc = this.escape
199    const scope = this.currentScope()
200    const groups = (Array.isArray(sections) ? sections : []).filter(s => s.items && s.items.length)
201    const total = groups.reduce((n, s) => n + s.items.length, 0)
202
203    // Recent searches are generic query terms — only worth showing alongside the
204    // product-oriented scopes, not when previewing help/blog/pages.
205    const showRecent = ["all", "products", "education"].includes(scope.keyword)
206    const recent = showRecent ? this.loadRecent().filter(r => r.toLowerCase() !== q.toLowerCase()).slice(0, 3) : []
207    const recentHtml = recent.length === 0 ? "" : `
208      <div class="py-1">
209        ${recent.map(r => `
210          <a href="${esc(scope.seeAllUrl)}?q=${encodeURIComponent(r)}" class="flex items-center gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-800">
211            <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
212            <span class="text-sm text-gray-700 dark:text-gray-200">${esc(r)}</span>
213          </a>`).join("")}
214      </div>
215      <div class="border-t border-gray-100 dark:border-gray-800"></div>`
216
217    const bodyHtml = total === 0
218      ? `<div class="px-4 py-3 text-sm text-gray-500">No matches for “${esc(q)}”</div>`
219      : groups.map(s => this.renderSection(s, q)).join("")
220
221    this.panelTarget.innerHTML = `
222      ${recentHtml}
223      ${bodyHtml}
224      <a href="${esc(scope.seeAllUrl)}?q=${encodeURIComponent(q)}" class="block border-t border-gray-100 dark:border-gray-800 px-4 py-2.5 text-sm font-semibold text-brand-700 dark:text-brand-300 hover:bg-gray-100 dark:hover:bg-gray-800">
225        See all results for “${esc(q)}”
226      </a>`
227    this.show()
228  }
229
230  renderSection(section, q) {
231    const esc = this.escape
232    const label = section.label
233      ? `<div class="px-4 pt-3 pb-1 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">${esc(section.label)}</div>`
234      : ""
235    const rows = section.kind === "products"
236      ? section.items.map(p => this.renderProductRow(p, q)).join("")
237      : section.items.map(d => this.renderDocRow(d, q)).join("")
238    return `${label}<div class="py-1">${rows}</div>`
239  }
240
241  renderProductRow(p, q) {
242    const esc = this.escape
243    return `
244      <a href="${esc(p.url)}" class="flex items-center gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-800">
245        ${p.image ? `<img src="${esc(p.image)}" class="h-10 w-10 shrink-0 rounded object-contain bg-white" alt="">` : `<div class="h-10 w-10 shrink-0 rounded bg-gray-100 dark:bg-gray-800"></div>`}
246        <span class="min-w-0 flex-1">
247          <span class="block text-sm text-gray-800 dark:text-gray-100 line-clamp-2">${esc(p.name)}</span>
248          ${this.variantCodesHtml(p.variants, q)}
249        </span>
250      </a>`
251  }
252
253  // Help/blog/page row: a document icon, the title (query highlighted) and an
254  // optional muted subtitle clamped to one line.
255  renderDocRow(d, q) {
256    const subtitle = d.subtitle
257      ? `<span class="block truncate text-xs text-gray-500 dark:text-gray-400">${this.highlightMatch(d.subtitle, q)}</span>`
258      : ""
259    return `
260      <a href="${this.escape(this.safeUrl(d.url))}" class="flex items-start gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-800">
261        <svg class="mt-0.5 h-5 w-5 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>
262        <span class="min-w-0 flex-1">
263          <span class="block text-sm text-gray-800 dark:text-gray-100 line-clamp-2">${this.highlightMatch(d.title, q)}</span>
264          ${subtitle}
265        </span>
266      </a>`
267  }
268
269  renderRecent() {
270    const esc = this.escape
271    const scope = this.currentScope()
272    // Recent searches are generic product-oriented query terms — only relevant
273    // under the product scopes, not when previewing help/blog/pages. (Mirrors
274    // the showRecent gate in renderResults.)
275    const showRecent = ["all", "products", "education"].includes(scope.keyword)
276    const recent = showRecent ? this.loadRecent() : []
277    if (recent.length === 0) { this.hide(); return }
278    this.panelTarget.innerHTML = `
279      <div class="flex items-center justify-between px-4 pt-3 pb-1">
280        <span class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">Keep shopping for</span>
281        <button type="button" data-action="ecommerce--search-suggest#clearRecent" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Clear</button>
282      </div>
283      <div class="py-1">
284        ${recent.map(r => `
285          <div class="flex items-center">
286            <a href="${esc(scope.seeAllUrl)}?q=${encodeURIComponent(r)}" class="flex-1 flex items-center gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-800">
287              <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
288              <span class="text-sm text-gray-800 dark:text-gray-100">${esc(r)}</span>
289            </a>
290            <button type="button" data-action="ecommerce--search-suggest#removeRecent" data-term="${esc(r)}" class="px-3 py-2 text-gray-400 hover:text-gray-700" aria-label="Remove">×</button>
291          </div>`).join("")}
292      </div>`
293    this.show()
294  }
295
296  removeRecent(event) {
297    event.preventDefault()
298    const term = event.currentTarget.dataset.term
299    const recent = this.loadRecent().filter(r => r !== term)
300    this.saveRecentList(recent)
301    this.renderRecent()
302  }
303
304  clearRecent(event) {
305    event.preventDefault()
306    this.saveRecentList([])
307    this.hide()
308    this.inputTarget.focus()
309  }
310
311  saveRecent(term) {
312    const list = this.loadRecent().filter(r => r.toLowerCase() !== term.toLowerCase())
313    list.unshift(term)
314    this.saveRecentList(list.slice(0, MAX_RECENT))
315  }
316
317  loadRecent() {
318    try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [] }
319    catch (_) { return [] }
320  }
321
322  saveRecentList(list) {
323    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)) } catch (_) {}
324  }
325
326  show() { this.panelTarget.classList.remove("hidden") }
327  hide() { this.panelTarget.classList.add("hidden") }
328
329  updateClearVisibility() {
330    if (!this.hasClearTarget) return
331    this.clearTarget.classList.toggle("hidden", this.inputTarget.value.length === 0)
332  }
333
334  onDocClick(event) {
335    if (!this.element.contains(event.target)) this.hide()
336  }
337
338  escape(text) {
339    const d = document.createElement("div")
340    d.textContent = text == null ? "" : String(text)
341    return d.innerHTML
342  }
343
344  // One muted line of SKU/MPN codes under a product suggestion. Each variant's
345  // codes are joined by a middot; variants are comma-separated and the line is
346  // clamped to a single row with an ellipsis. Matches of the typed query glow
347  // yellow so the shopper can see why the product surfaced.
348  variantCodesHtml(variants, q) {
349    if (!Array.isArray(variants) || variants.length === 0) return ""
350    const tokens = variants
351      .map(v =>
351 [v.sku, v.mpn].filter(Boolean).map(c => this.highlightMatch(c, q)).join(" · "))
352      .filter(Boolean)
353    if (tokens.length === 0) return ""
354    return `<span class="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">${tokens.join(", ")}</span>`
355  }
356
357  // Escape `text`, then wrap every matched span in a yellow <mark>. The query is
358  // split into whitespace tokens (longest first) and each token is highlighted
359  // wherever it appears, mirroring the server's word-by-word title match — so a
360  // multi-word query like "changing item" lights up both words even when the
361  // title interleaves them. Works on the raw string and escapes every slice, so
362  // DB text is never trusted as HTML.
363  highlightMatch(text, q) {
364    const esc = this.escape
365    const str = String(text == null ? "" : text)
366    const tokens = String(q == null ? "" : q).trim().toLowerCase().split(/\s+/).filter(Boolean)
367    if (tokens.length === 0) return esc(str)
368    tokens.sort((a, b) => b.length - a.length)
369
370    const lower = str.toLowerCase()
371    const hit = new Array(str.length).fill(false)
372    for (const t of tokens) {
373      let i = 0
374      let idx
375      while ((idx = lower.indexOf(t, i)) !== -1) {
376        for (let k = idx; k < idx + t.length; k++) hit[k] = true
377        i = idx + t.length
378      }
379    }
380
381    const MARK_OPEN = `<mark class="rounded-sm bg-yellow-200 text-gray-900 dark:bg-yellow-400/30 dark:text-yellow-50">`
382    let out = ""
383    let run = ""
384    let inMark = false
385    for (let k = 0; k < str.length; k++) {
386      if (hit[k] !== inMark) {
387        out += inMark ? `${MARK_OPEN}${esc(run)}</mark>` : esc(run)
388        run = ""
389        inMark = hit[k]
390      }
391      run += str[k]
392    }
393    return out + (inMark ? `${MARK_OPEN}${esc(run)}</mark>` : esc(run))
394  }
395
396  // Only allow site-relative paths and http(s) URLs through to an href, so a
397  // curated custom link can never carry a javascript:/data: scheme. Mirrors the
398  // server-side validation on Ecommerce::SearchSuggestionLink.
399  safeUrl(url) {
400    const s = String(url == null ? "" : url).trim()
401    if (s.startsWith("/")) return s
402    if (/^https?:\/\//i.test(s)) return s
403    return "#"
404  }
405}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.