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.