PageSourceSearch

https://chatwith.io/assets/controllers/search_results_controller-hVxksuc.js

js chatwith.io collected 2026-10-01 13:59:31 UTC 12,735 bytes, 312 lines download raw bytes

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.