1/* stimulusFetch: 'lazy' */ 2import { Controller } from '@hotwired/stimulus'; 3import instantsearch from 'instantsearch.js'; 4import { 5 connectSearchBox, 6 connectHits, 7 connectRefinementList, 8 connectPagination, 9 connectStats, 10 connectSortBy, 11} from 'instantsearch.js/es/connectors'; 12import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter'; 13 14/** 15 * search-results 16 * 17 * Full-page search powered by Typesense + InstantSearch.js. 18 * Browser queries Typesense directly using a search-only scoped API key 19 * (admin key never exposed â CLAUDE.md). 20 * 21 * Usage: 22 * data-controller="search-results" on the page wrapper 23 * data-search-results-search-key-value="..." â search-only Typesense key 24 * data-search-results-url-value="..." â browser-reachable Typesense URL 25 * data-search-results-q-value="..." â initial query from ?q= param 26 */ 27export default class extends Controller { 28 static targets = [ 29 'input', 30 'hits', 31 'facetCountry', 32 'facetRegion', 33 'facetL1', 34 'facetL2', 35 'facetTags', 36 'pagination', 37 'stats', 38 'sort', 39 ]; 40 41 static values = { searchKey: String, url: String, collection: String, q: String, filterBy: String }; 42 43 /** @type {import('instantsearch.js').InstantSearch|null} */ 44 #is = null; 45 46 /** @type {((query: string) => void)|null} */ 47 #refine = null; 48 49 connect() { 50 const { hostname, port, protocol } = new URL(this.urlValue); 51 52 const adapter = new TypesenseInstantSearchAdapter({ 53 server: { 54 apiKey: this.searchKeyValue, 55 nodes: [{ host: hostname, port: port || '443', protocol: protocol.replace(':', '') }], 56 }, 57 additionalSearchParameters: { 58 query_by: 'name,l1,l2,country,region', 59 query_by_weights: '4,2,2,1,1', 60 per_page: 20, 61 }, 62 }); 63 64 // Virtual search box â no own input rendered; we drive it from the #input target. 65 const virtualSearchBox = connectSearchBox(({ refine }) => { 66 this.#refine = refine; 67 }); 68 69 const customHits = connectHits(({ hits }) => { 70 this.#renderHits(hits); 71 }); 72 73 const countryFacet = connectRefinementList((s) => this.#renderFacet(this.facetCountryTarget, 'Country', s)); 74 const regionFacet = connectRefinementList((s) => this.#renderFacet(this.facetRegionTarget, 'Region', s)); 75 const l1Facet = connectRefinementList((s) => this.#renderFacet(this.facetL1Target, 'Category', s)); 76 const l2Facet = connectRefinementList((s) => this.#renderFacet(this.facetL2Target, 'Subcategory', s)); 77 const tagsFacet = connectRefinementList((s) => this.#renderFacet(this.facetTagsTarget, 'Tags', s)); 78 79 const customPagination = connectPagination((s) => this.#renderPagination(s)); 80 81 const customStats = connectStats(({ nbHits, query }) => { 82 const label = query 83 ? `${nbHits.toLocaleString()} results for "${query}"` 84 : `${nbHits.toLocaleString()} businesses`; 85 this.statsTarget.textContent = label; 86 }); 87 88 const customSort = connectSortBy(({ options, currentRefinement, refine }) => { 89 // Reuse .list-filter__sort-btn + is-active â same pattern as country/region pages. 90 this.sortTarget.innerHTML = ` 91 <div class="list-filter__sort" role="group" aria-label="Sort order"> 92 ${options.map((o) => ` 93 <button 94 type="button" 95 class="list-filter__sort-btn ${o.value === currentRefinement ? 'is-active' : ''}" 96 data-sort-value="${o.value}" 97 >${o.label}</button> 98 `).join('')} 99 </div> 100 `; 101 this.sortTarget.querySelectorAll('[data-sort-value]').forEach((btn) => { 102 btn.addEventListener('click', () => refine(btn.dataset.sortValue)); 103 }); 104 }); 105 106 this.#is = instantsearch({ 107 indexName: this.collectionValue, 108 searchClient: adapter.searchClient, 109 // Pre-select refinements from filter_by context (e.g. country:spain). 110 // Using initialUiState instead of additionalSearchParameters.filter_by 111 // so the facet checkboxes are checked and the user can clear them. 112 initialUiState: { 113 businesses: { 114 refinementList: this.#parseFilterBy(this.filterByValue), 115 }, 116 }, 117 }); 118 119 this.#is.addWidgets([ 120 virtualSearchBox({}), 121 customHits({}), 122 countryFacet({ attribute: 'country' }), 123 regionFacet({ attribute: 'region' }), 124 l1Facet({ attribute: 'l1' }), 125 l2Facet({ attribute: 'l2' }), 126 tagsFacet({ attribute: 'tags' }), 127 customPagination({}), 128 customStats({}), 129 customSort({ 130 items: [ 131 { label: 'Relevance', value: this.collectionValue }, 132 { label: 'Most visited', value: `${this.collectionValue}/sort/visits:desc` }, 133 ], 134 }), 135 ]); 136 137 this.#is.start(); 138 139 // Seed from ?q= URL param 140 if (this.qValue) { 141 this.#refine?.(this.qValue); 142 } 143 144 this.inputTarget.addEventListener('input', (e) => { 145 this.#refine?.(e.target.value.trim()); 146 }); 147 } 148 149 disconnect() { 150 this.#is?.dispose(); 151 } 152 153 // ââ Rendering âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 154 155 #renderHits(hits) { 156 if (!hits.length) { 157 const q = this.qValue ? `"${this.qValue}"` : ''; 158 const link = this.filterByValue 159 ? `<a href="/search${this.qValue ? '?q=' + encodeURIComponent(this.qValue) : ''}" class="search-results__empty-link">Search in all countries â</a>` 160 : ''; 161 this.hitsTarget.innerHTML = ` 162 <div class="search-results__empty"> 163 <svg class="search-results__empty-icon" viewB
163ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 164 <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/> 165 <path d="M8 11h6M11 8v6" stroke-width="1.5"/> 166 </svg> 167 <p class="search-results__empty-title">No results${q ? ' for ' + q : ''}</p> 168 <p class="search-results__empty-sub">Try different keywords or check your spelling</p> 169 ${link} 170 </div>`; 171 this.sortTarget.style.display = 'none'; 172 return; 173 } 174 this.sortTarget.style.display = ''; 175 176 this.hitsTarget.innerHTML = hits.map((h) => ` 177 <a class="biz-card biz-card--link" href="${h.path}"> 178 <div class="biz-card__avatar">${h.name[0].toUpperCase()}</div> 179 <div class="biz-card__body"> 180 <div class="biz-card__name">${h.name}</div> 181 <div class="biz-card__meta"> 182 <span class="biz-card__location">${h.l2_name}</span> 183 <span class="biz-card__sep">·</span> 184 <span class="biz-card__location">${h.region_name}, ${h.country_name}</span> 185 <span class="biz-card__sep">·</span> 186 <span class="biz-card__visits">ð ${h.visits.toLocaleString()}</span> 187 </div> 188 </div> 189 <div class="biz-card__actions"> 190 <span class="btn-view" role="presentation">View â</span> 191 </div> 192 </a> 193 `).join(''); 194 } 195 196 /** 197 * Parses a Typesense filter_by string into an InstantSearch refinementList map. 198 * "country:spain && region:madrid" â { country: ['spain'], region: ['madrid'] } 199 * Returns an empty object for empty/missing input (no initial refinements). 200 * @param {string} filterBy 201 * @returns {Record<string, string[]>} 202 */ 203 #parseFilterBy(filterBy) { 204 if (!filterBy) return {}; 205 206 const result = {}; 207 208 for (const field of ['country', 'region', 'l1', 'l2']) { 209 const match = filterBy.match(new RegExp(`${field}:([a-z0-9-]+)`, 'i')); 210 if (match) { 211 result[field] = [match[1]]; 212 } 213 } 214 215 return result; 216 } 217 218 /** 219 * Converts a slug to a human-readable label. 220 * "united-arab-emirates" â "United Arab Emirates" 221 * @param {string} slug 222 */ 223 #humanize(slug) { 224 return slug 225 .split('-') 226 .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) 227 .join(' '); 228 } 229 230 #renderFacet(target, label, { items, refine, canRefine }) { 231 if (!canRefine || !items.length) { 232 target.hidden = true; 233 return; 234 } 235 236 target.hidden = false; 237 target.innerHTML = ` 238 <div class="search-facet"> 239 <div class="search-facet__label">${label}</div> 240 ${items.slice(0, 10).map((item) => ` 241 <label class="search-facet__item ${item.isRefined ? 'is-refined' : ''}"> 242 <input 243 type="checkbox" 244 ${item.isRefined ? 'checked' : ''} 245 data-value="${item.value}" 246 data-facet-refine 247 > 248 <span class="search-facet__item-label">${this.#humanize(item.label)}</span> 249 <span class="search-facet__count">${item.count}</span> 250 </label> 251 `).join('')} 252 </div> 253 `; 254 255 target.querySelectorAll('[data-facet-refine]').forEach((el) => { 256 el.addEventListener('change', () => refine(el.dataset.value)); 257 }); 258 } 259 260 /** 261 * Reuses the existing .pagination / .pagination__btn / .pagination__page 262 * classes from _layout.scss â same pattern as category/region pages. 263 */ 264 #renderPagination({ currentRefinement, nbPages, refine, isFirstPage, isLastPage }) { 265 if (nbPages <= 1) { 266 this.paginationTarget.innerHTML = ''; 267 return; 268 } 269
270 // Window of 5 pages around current (mirrors Twig pagination logic) 271 const winStart = Math.max(0, currentRefinement - 2); 272 const winEnd = Math.min(nbPages - 1, currentRefinement + 2); 273 274 const pages = []; 275 276 if (winStart > 0) { 277 pages.push(`<button class="pagination__page" data-page="0">1</button>`); 278 if (winStart > 1) pages.push(`<span class="pagination__ellipsis">â¦</span>`); 279 } 280 281 for (let p = winStart; p <= winEnd; p++) { 282 if (p === currentRefinement) { 283 pages.push(`<span class="pagination__page pagination__page--active" aria-current="page">${p + 1}</span>`); 284 } else { 285 pages.push(`<button class="pagination__page" data-page="${p}">${p + 1}</button>`); 286 } 287 } 288 289 if (winEnd < nbPages - 1) { 290 if (winEnd < nbPages - 2) pages.push(`<span class="pagination__ellipsis">â¦</span>`); 291 pages.push(`<button class="pagination__page" data-page="${nbPages - 1}">${nbPages}</button>`); 292 } 293 294 this.paginationTarget.innerHTML = ` 295 <nav class="pagination" aria-label="Pagination"> 296 ${isFirstPage 297 ? `<span class="pagination__btn pagination__btn--disabled" aria-disabled="true">â Prev</span>` 298 : `<button class="pagination__btn" data-page="${currentRefinement - 1}" aria-label="Previous page">â Prev</button>` 299 } 300 ${pages.join('')} 301 ${isLastPage 302 ? `<span class="pagination__btn pagination__btn--disabled" aria-disabled="true">Next â</span>` 303 : `<button class="pagination__btn" data-page="${currentRefinement + 1}" aria-label="Next page">Next â</button>` 304 } 305 </nav> 306 `; 307 308 this.paginationTarget.querySelectorAll('[data-page]').forEach((btn) => { 309 btn.addEventListener('click', () => refine(Number(btn.dataset.page))); 310 }); 311 } 312}
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.