@stapel/search-react 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +178 -0
- package/MODULE.md +118 -3
- package/README.md +42 -0
- package/dist/api/generated/schema.d.ts +60 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/searchApi.d.ts +13 -6
- package/dist/api/searchApi.d.ts.map +1 -1
- package/dist/api/searchApi.js.map +1 -1
- package/dist/api/types.d.ts +107 -1
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +29 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +13 -2
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +3 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +88 -3
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +161 -13
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +1 -1
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchBox.d.ts.map +1 -1
- package/dist/default/SearchBox.js +74 -4
- package/dist/default/SearchBox.js.map +1 -1
- package/dist/default/SearchPage.d.ts +29 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +25 -10
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +83 -37
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +22 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +2 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +23 -0
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +29 -9
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/cardPhotos.d.ts +65 -0
- package/dist/default/cardPhotos.d.ts.map +1 -0
- package/dist/default/cardPhotos.js +127 -0
- package/dist/default/cardPhotos.js.map +1 -0
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +10 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +9 -1
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useFacetLabels.d.ts +39 -0
- package/dist/headless/useFacetLabels.d.ts.map +1 -0
- package/dist/headless/useFacetLabels.js +129 -0
- package/dist/headless/useFacetLabels.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +71 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -1
- package/dist/headless/useSearchBox.js +67 -2
- package/dist/headless/useSearchBox.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +10 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +55 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +67 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +12 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +6 -5
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +4 -3
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -0
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +7 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/runtime.d.ts +40 -3
- package/dist/model/runtime.d.ts.map +1 -1
- package/dist/model/runtime.js +2 -1
- package/dist/model/runtime.js.map +1 -1
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +3 -2
- package/dist/nav/manifest.js.map +1 -1
- package/dist/state/facets.d.ts +56 -15
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +101 -27
- package/dist/state/facets.js.map +1 -1
- package/llms.txt +3 -3
- package/manifest.json +33 -4
- package/nav-manifest.json +4 -3
- package/package.json +11 -11
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +60 -6
- package/src/api/searchApi.ts +13 -6
- package/src/api/types.ts +115 -1
- package/src/default/FacetPanelPane.tsx +16 -2
- package/src/default/FilterChips.tsx +285 -38
- package/src/default/LocationSummaryLine.tsx +6 -1
- package/src/default/SearchBox.tsx +134 -6
- package/src/default/SearchPage.tsx +79 -5
- package/src/default/SearchResultCard.tsx +157 -71
- package/src/default/SearchResultsPane.tsx +48 -11
- package/src/default/SortSelect.tsx +70 -9
- package/src/default/cardPhotos.ts +195 -0
- package/src/default/index.ts +10 -2
- package/src/headless/FacetPanel.tsx +20 -1
- package/src/headless/useFacetLabels.ts +172 -0
- package/src/headless/useSearchBox.ts +132 -5
- package/src/i18n/es.ts +11 -0
- package/src/i18n/keys.ts +69 -0
- package/src/i18n/ru.ts +13 -0
- package/src/index.ts +22 -3
- package/src/model/queries.ts +6 -5
- package/src/model/queryKeys.ts +23 -0
- package/src/model/runtime.ts +43 -4
- package/src/nav/manifest.ts +24 -2
- package/src/state/facets.ts +123 -39
|
@@ -21,10 +21,36 @@
|
|
|
21
21
|
*
|
|
22
22
|
* The suggestion list is asked for the DEBOUNCED prefix, not the draft — one
|
|
23
23
|
* request per pause, not per keystroke, on an endpoint the backend throttles.
|
|
24
|
+
*
|
|
25
|
+
* ── The box reaches the CATALOGUE, not only the titles ────────────────────
|
|
26
|
+
*
|
|
27
|
+
* stapel-search 0.7.0 answers `/suggest` with two halves. Until it did, typing
|
|
28
|
+
* a word that names a section of the catalogue answered listing titles and
|
|
29
|
+
* nothing else: on a live classified deployment the search field could not
|
|
30
|
+
* reach a category at all, and the owner's own navigation canon rules out both
|
|
31
|
+
* of the usual workarounds — a picker, and a client-side typeahead over the
|
|
32
|
+
* whole tree.
|
|
33
|
+
*
|
|
34
|
+
* The server's answer is neither, and it carries the one thing a client-side
|
|
35
|
+
* matcher can never have: the number of LIVE listings behind each destination,
|
|
36
|
+
* computed as one aggregate over the index and equal to what `/query` reports
|
|
37
|
+
* for the same category. That number is what tells "Menswear / Shorts" from
|
|
38
|
+
* "Childrenswear / Shorts".
|
|
39
|
+
*
|
|
40
|
+
* Everything here is absent-safe. A server that predates 0.7.0 sends no
|
|
41
|
+
* `categories` key, `suggestCategories` is empty, and the box behaves exactly
|
|
42
|
+
* as it did — which is not a hypothetical: the key appeared mid-session on a
|
|
43
|
+
* stand that was redeployed underneath a running client.
|
|
24
44
|
*/
|
|
25
45
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
26
46
|
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
27
47
|
import type { LoadState } from "@stapel/core";
|
|
48
|
+
import {
|
|
49
|
+
SUGGEST_DEGRADED_CATEGORIES,
|
|
50
|
+
SUGGEST_DEGRADED_ROLLUP,
|
|
51
|
+
suggestTerms,
|
|
52
|
+
} from "../api/types.js";
|
|
53
|
+
import type { SuggestAnswer, SuggestCategory } from "../api/types.js";
|
|
28
54
|
import { useSearchState } from "./SearchStateProvider.js";
|
|
29
55
|
import { useSuggest } from "../model/queries.js";
|
|
30
56
|
import {
|
|
@@ -55,6 +81,42 @@ export interface SearchBoxBag {
|
|
|
55
81
|
readonly suggestState: LoadState<readonly string[]>;
|
|
56
82
|
/** A suggestion request is in flight for a prefix nothing is shown for yet. */
|
|
57
83
|
readonly suggestLoading: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* CATEGORY destinations for the typed prefix, server-ranked by live listing
|
|
86
|
+
* count — the primary half of the answer (stapel-search 0.7.0).
|
|
87
|
+
*
|
|
88
|
+
* Empty on a server that sends no `categories` key, and empty when the
|
|
89
|
+
* server could not reach a category provider: see
|
|
90
|
+
* {@link SearchBoxBag.categoriesUnavailable} for why a surface must tell
|
|
91
|
+
* those two apart from "nothing matched".
|
|
92
|
+
*
|
|
93
|
+
* A category with a count of `0` is already filtered out — see
|
|
94
|
+
* {@link SearchBoxBag.categoryCountsUnknown}.
|
|
95
|
+
*/
|
|
96
|
+
readonly categories: readonly SuggestCategory[];
|
|
97
|
+
/**
|
|
98
|
+
* The server HAS no category provider for this answer
|
|
99
|
+
* (`degraded: ["category_suggestions"]`), so `categories` being empty says
|
|
100
|
+
* nothing about the catalogue.
|
|
101
|
+
*
|
|
102
|
+
* A surface must render no group at all rather than an empty one: a heading
|
|
103
|
+
* over nothing is the box telling a person the catalogue has no section by
|
|
104
|
+
* that name, which is a claim this answer did not make. It is deliberately
|
|
105
|
+
* NOT a banner either — the reader is mid-word, the terms half still
|
|
106
|
+
* answers, and a provider being down is the operator's business, the same
|
|
107
|
+
* ruling `degradationAudience` already applies to an engine shortfall.
|
|
108
|
+
*/
|
|
109
|
+
readonly categoriesUnavailable: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The counts on {@link SearchBoxBag.categories} are not answers
|
|
112
|
+
* (`degraded: ["category_rollup"]`): with no ancestry every stored path is
|
|
113
|
+
* one segment long, so every count would read `0` for a mechanical reason.
|
|
114
|
+
*
|
|
115
|
+
* The rows are still real destinations and are still offered; a surface
|
|
116
|
+
* prints the path and omits the number, because a catalogue of zeros is
|
|
117
|
+
* worse than a list with no counts.
|
|
118
|
+
*/
|
|
119
|
+
readonly categoryCountsUnknown: boolean;
|
|
58
120
|
/** Characters a prefix needs before the index is asked. */
|
|
59
121
|
readonly minSuggestChars: number;
|
|
60
122
|
readonly maxLength: number;
|
|
@@ -65,6 +127,25 @@ export interface SearchBoxBag {
|
|
|
65
127
|
submit(value?: string): void;
|
|
66
128
|
/** Empty the box AND the search (an empty `q` is a valid browse). */
|
|
67
129
|
clear(): void;
|
|
130
|
+
/**
|
|
131
|
+
* Follow a category suggestion: narrow the SERP to that section.
|
|
132
|
+
*
|
|
133
|
+
* Two decisions, both load-bearing.
|
|
134
|
+
*
|
|
135
|
+
* The `category` parameter is set to the server's own
|
|
136
|
+
* {@link SuggestCategory.category} string, VERBATIM. The server joins the
|
|
137
|
+
* ancestry itself precisely so a client cannot invent a different join and
|
|
138
|
+
* silently miss, so nothing here rebuilds it from the path or the slug.
|
|
139
|
+
*
|
|
140
|
+
* The typed text is CLEARED in the same write. The row promised a count —
|
|
141
|
+
* "Menswear / Shorts, 1 240 listings" — and that number is the
|
|
142
|
+
* section's, not the section's intersected with a title search for the word
|
|
143
|
+
* that found it. Keeping `q` would land a person on strictly fewer results
|
|
144
|
+
* than the row they tapped had just quoted, which is the row lying about
|
|
145
|
+
* where it goes. One `patch` call, so it is also one history entry: Back
|
|
146
|
+
* returns to the search the person typed.
|
|
147
|
+
*/
|
|
148
|
+
chooseCategory(category: SuggestCategory): void;
|
|
68
149
|
}
|
|
69
150
|
|
|
70
151
|
export interface UseSearchBoxOptions {
|
|
@@ -81,8 +162,39 @@ export interface UseSearchBoxOptions {
|
|
|
81
162
|
/** One frozen empty list, so a render with no suggestions is a stable value. */
|
|
82
163
|
const NO_SUGGESTIONS: readonly string[] = [];
|
|
83
164
|
|
|
165
|
+
/** The same, for the categories half. */
|
|
166
|
+
const NO_CATEGORIES: readonly SuggestCategory[] = [];
|
|
167
|
+
|
|
168
|
+
/** Does this answer name a shortfall? Absent-safe on every older server. */
|
|
169
|
+
function degradedWith(answer: SuggestAnswer | undefined, literal: string): boolean {
|
|
170
|
+
return answer?.degraded?.includes(literal) === true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The destinations worth offering out of one answer.
|
|
175
|
+
*
|
|
176
|
+
* A category with a count of `0` is a section with nothing in it: following it
|
|
177
|
+
* lands the buyer on an empty SERP, which is a dead end dressed as a
|
|
178
|
+
* destination — and the server does return those (it reads the count with a
|
|
179
|
+
* `0` default and ranks them last), so somebody has to drop them.
|
|
180
|
+
*
|
|
181
|
+
* The one exception is `category_rollup`, where EVERY count is `0` because the
|
|
182
|
+
* ancestry never arrived rather than because the sections are empty. Filtering
|
|
183
|
+
* on the count there would delete the whole group for a reason that has
|
|
184
|
+
* nothing to do with what is in the catalogue, so the rows are kept and the
|
|
185
|
+
* numbers are what the surface omits.
|
|
186
|
+
*/
|
|
187
|
+
export function offerableCategories(
|
|
188
|
+
answer: SuggestAnswer | undefined
|
|
189
|
+
): readonly SuggestCategory[] {
|
|
190
|
+
const categories = answer?.categories;
|
|
191
|
+
if (categories === undefined || categories.length === 0) return NO_CATEGORIES;
|
|
192
|
+
if (degradedWith(answer, SUGGEST_DEGRADED_ROLLUP)) return categories;
|
|
193
|
+
return categories.filter((category) => category.count > 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
84
196
|
export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
|
85
|
-
const { state, setText } = useSearchState();
|
|
197
|
+
const { state, setText, patch } = useSearchState();
|
|
86
198
|
const committed = state.q;
|
|
87
199
|
const debounceMs = options.debounceMs ?? SEARCH_BOX_DEBOUNCE_MS;
|
|
88
200
|
|
|
@@ -168,10 +280,21 @@ export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
|
|
168
280
|
enabled: options.suggest !== false,
|
|
169
281
|
});
|
|
170
282
|
|
|
171
|
-
const suggestState = mapLoad(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
283
|
+
const suggestState = mapLoad(loadStateFromQuery(suggest), suggestTerms);
|
|
284
|
+
|
|
285
|
+
// The READY answer only. A category row is a navigation control, and one
|
|
286
|
+
// rendered out of a stale answer would send a person somewhere the current
|
|
287
|
+
// prefix never named.
|
|
288
|
+
const answer = suggest.data;
|
|
289
|
+
const categories = offerableCategories(answer);
|
|
290
|
+
|
|
291
|
+
const chooseCategory = (category: SuggestCategory): void => {
|
|
292
|
+
setDraftState("");
|
|
293
|
+
cancel();
|
|
294
|
+
// One write, so one history entry — see `SearchBoxBag.chooseCategory` for
|
|
295
|
+
// why the text goes with it.
|
|
296
|
+
patch({ q: "", category: category.category });
|
|
297
|
+
};
|
|
175
298
|
|
|
176
299
|
return {
|
|
177
300
|
draft,
|
|
@@ -180,10 +303,14 @@ export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
|
|
180
303
|
suggestions: suggestState.status === "ready" ? suggestState.data : NO_SUGGESTIONS,
|
|
181
304
|
suggestState,
|
|
182
305
|
suggestLoading: suggest.isLoading && suggest.fetchStatus === "fetching",
|
|
306
|
+
categories,
|
|
307
|
+
categoriesUnavailable: degradedWith(answer, SUGGEST_DEGRADED_CATEGORIES),
|
|
308
|
+
categoryCountsUnknown: degradedWith(answer, SUGGEST_DEGRADED_ROLLUP),
|
|
183
309
|
minSuggestChars: SUGGEST_MIN_CHARS,
|
|
184
310
|
maxLength: SEARCH_QUERY_MAX_CHARS,
|
|
185
311
|
setDraft,
|
|
186
312
|
submit,
|
|
187
313
|
clear,
|
|
314
|
+
chooseCategory,
|
|
188
315
|
};
|
|
189
316
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -40,12 +40,18 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
40
40
|
"search.results.untitled": "Sin título",
|
|
41
41
|
"search.results.open": "Abrir",
|
|
42
42
|
"search.results.image_alt": "Foto de {title}",
|
|
43
|
+
"search.results.photo_alt": "Foto {index} de {total}: {title}",
|
|
44
|
+
"search.results.photos": "Fotos",
|
|
45
|
+
"search.results.photo_unavailable": "Foto no disponible",
|
|
43
46
|
|
|
44
47
|
"search.box.label": "Buscar",
|
|
45
48
|
"search.box.placeholder": "¿Qué estás buscando?",
|
|
46
49
|
"search.box.submit": "Buscar",
|
|
47
50
|
"search.box.clear": "Borrar la búsqueda",
|
|
48
51
|
"search.box.suggestions": "Sugerencias",
|
|
52
|
+
"search.box.categories": "Secciones",
|
|
53
|
+
"search.box.category_count.one": "{count} anuncio",
|
|
54
|
+
"search.box.category_count.other": "{count} anuncios",
|
|
49
55
|
|
|
50
56
|
"search.sort.label": "Orden",
|
|
51
57
|
"search.sort.relevance": "Más relevantes",
|
|
@@ -93,6 +99,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
93
99
|
"search.filters.dismiss": "Cerrar los filtros",
|
|
94
100
|
"search.filters.chips_label": "Filtros",
|
|
95
101
|
"search.filters.all": "Todos los filtros",
|
|
102
|
+
"search.filters.short": "Filtros",
|
|
96
103
|
"search.filters.chip_more": ", +{count}",
|
|
97
104
|
|
|
98
105
|
"search.category.title": "Categoría",
|
|
@@ -150,6 +157,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
150
157
|
"search.ranking.inactive": "No aplicado: {reason}",
|
|
151
158
|
"search.ranking.notes": "Notas",
|
|
152
159
|
"search.ranking.link": "Cómo se ordenan estos resultados",
|
|
160
|
+
|
|
161
|
+
"search.nav.results": "Buscar",
|
|
162
|
+
"search.nav.ranking": "Orden de resultados",
|
|
163
|
+
"search.nav.ranking.short": "Orden",
|
|
153
164
|
};
|
|
154
165
|
|
|
155
166
|
/** Register the es bundle. Call AFTER `registerSearchI18n`. */
|
package/src/i18n/keys.ts
CHANGED
|
@@ -56,6 +56,16 @@ export const SEARCH_I18N_KEYS = {
|
|
|
56
56
|
resultsOpen: "search.results.open",
|
|
57
57
|
/** Alt text for the card photo — the card's own title, in a sentence. */
|
|
58
58
|
resultsImageAlt: "search.results.image_alt",
|
|
59
|
+
/** Alt text for one photo of a GALLERY: its place in the strip, and the
|
|
60
|
+
* card's title. A row of ten photos all called "Photo of X" is ten
|
|
61
|
+
* identical announcements. */
|
|
62
|
+
resultsPhotoAlt: "search.results.photo_alt",
|
|
63
|
+
/** The photo strip's accessible name. `SkinCarousel` takes its label from
|
|
64
|
+
* the caller for exactly this reason: the token bridge owns no i18n. */
|
|
65
|
+
resultsPhotos: "search.results.photos",
|
|
66
|
+
/** The card stores a reference and nothing resolved it — a sentence gets
|
|
67
|
+
* the wiring fixed; an empty grey box teaches nobody anything. */
|
|
68
|
+
resultsPhotoUnavailable: "search.results.photo_unavailable",
|
|
59
69
|
|
|
60
70
|
// ── the query box ────────────────────────────────────────────────────────
|
|
61
71
|
boxLabel: "search.box.label",
|
|
@@ -64,6 +74,22 @@ export const SEARCH_I18N_KEYS = {
|
|
|
64
74
|
boxClear: "search.box.clear",
|
|
65
75
|
/** Accessible name of the suggestion list under the box. */
|
|
66
76
|
boxSuggestions: "search.box.suggestions",
|
|
77
|
+
/**
|
|
78
|
+
* The heading over the CATEGORY half of the type-ahead (stapel-search
|
|
79
|
+
* 0.7.0). The rows under it are DESTINATIONS — a section of the catalogue —
|
|
80
|
+
* and not, like everything else in the menu, another set of words to search
|
|
81
|
+
* for; a group with no heading would read as more of the same.
|
|
82
|
+
*/
|
|
83
|
+
boxCategories: "search.box.categories",
|
|
84
|
+
/**
|
|
85
|
+
* "1 240 listings" beside a category row — how many LIVE listings a buyer
|
|
86
|
+
* would see there, the same number the SERP reports for it.
|
|
87
|
+
*
|
|
88
|
+
* A PLURAL FAMILY: render with `tPlural`, never `t`. It counts a noun in
|
|
89
|
+
* words, so Russian needs its four endings and a single string would be
|
|
90
|
+
* right only for 5-20 — the exact defect the results count already fixed.
|
|
91
|
+
*/
|
|
92
|
+
boxCategoryCount: "search.box.category_count",
|
|
67
93
|
|
|
68
94
|
// ── sort ─────────────────────────────────────────────────────────────────
|
|
69
95
|
sortLabel: "search.sort.label",
|
|
@@ -129,6 +155,9 @@ export const SEARCH_I18N_KEYS = {
|
|
|
129
155
|
/** The leading, icon-only chip: the whole panel. Icon-only means the name
|
|
130
156
|
* exists ONLY here, so this key is the control's entire accessibility. */
|
|
131
157
|
filtersAll: "search.filters.all",
|
|
158
|
+
/** The same door, named for a 390px row: the location line's trailing link
|
|
159
|
+
* sits beside a place name and cannot spend six characters on "All". */
|
|
160
|
+
filtersShort: "search.filters.short",
|
|
132
161
|
/** A chip filtering on more than one value: "Bosch, +2". Not a plural
|
|
133
162
|
* family — nothing is being counted in words. */
|
|
134
163
|
filtersChipMore: "search.filters.chip_more",
|
|
@@ -207,6 +236,34 @@ export const SEARCH_I18N_KEYS = {
|
|
|
207
236
|
rankingInactive: "search.ranking.inactive",
|
|
208
237
|
rankingNotes: "search.ranking.notes",
|
|
209
238
|
rankingLink: "search.ranking.link",
|
|
239
|
+
|
|
240
|
+
// ── nav (the destination's own name, not the page's heading) ─────────────
|
|
241
|
+
/**
|
|
242
|
+
* What the MENU calls the results screen.
|
|
243
|
+
*
|
|
244
|
+
* Separate from `search.results.title` on purpose. That key captions the
|
|
245
|
+
* list of matches — a heading over rows, which is why it reads "Results" —
|
|
246
|
+
* and a menu entry pointing at `/s` labelled "Results" tells a visitor
|
|
247
|
+
* nothing about where they would be going: results of what? The destination
|
|
248
|
+
* is the search itself. One key, one job; a translator asked to make
|
|
249
|
+
* "Results" work as both a page heading and a menu label has to pick which
|
|
250
|
+
* one to get wrong.
|
|
251
|
+
*/
|
|
252
|
+
navResults: "search.nav.results",
|
|
253
|
+
/** The menu name of the P2B disclosure PAGE — see {@link navResults} for
|
|
254
|
+
* why it is not `search.ranking.title`, which is the page's own heading and
|
|
255
|
+
* a whole sentence. */
|
|
256
|
+
navRanking: "search.nav.ranking",
|
|
257
|
+
/**
|
|
258
|
+
* The compact form for a phone dock — see `NavEntry.shortLabelKey`. A
|
|
259
|
+
* five-cell dock at 390px gives a destination roughly ten characters, and
|
|
260
|
+
* "Ranking disclosure" ellipsizes to a fragment there.
|
|
261
|
+
*
|
|
262
|
+
* `search.nav.results` needs no short form: it is already one word in every
|
|
263
|
+
* locale this pair ships, and declaring a short key identical to the long
|
|
264
|
+
* one only gives a translator two strings to keep in sync.
|
|
265
|
+
*/
|
|
266
|
+
navRankingShort: "search.nav.ranking.short",
|
|
210
267
|
} as const;
|
|
211
268
|
|
|
212
269
|
export type SearchI18nKey =
|
|
@@ -233,6 +290,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
233
290
|
SEARCH_I18N_KEYS.resultsCountExact,
|
|
234
291
|
SEARCH_I18N_KEYS.filtersShowCount,
|
|
235
292
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
293
|
+
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
236
294
|
];
|
|
237
295
|
|
|
238
296
|
/**
|
|
@@ -270,12 +328,18 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
270
328
|
"search.results.untitled": "Untitled",
|
|
271
329
|
"search.results.open": "Open",
|
|
272
330
|
"search.results.image_alt": "Photo of {title}",
|
|
331
|
+
"search.results.photo_alt": "Photo {index} of {total}: {title}",
|
|
332
|
+
"search.results.photos": "Photos",
|
|
333
|
+
"search.results.photo_unavailable": "Photo unavailable",
|
|
273
334
|
|
|
274
335
|
"search.box.label": "Search",
|
|
275
336
|
"search.box.placeholder": "What are you looking for?",
|
|
276
337
|
"search.box.submit": "Search",
|
|
277
338
|
"search.box.clear": "Clear the search",
|
|
278
339
|
"search.box.suggestions": "Suggestions",
|
|
340
|
+
"search.box.categories": "Sections",
|
|
341
|
+
"search.box.category_count.one": "{count} listing",
|
|
342
|
+
"search.box.category_count.other": "{count} listings",
|
|
279
343
|
|
|
280
344
|
"search.sort.label": "Sort",
|
|
281
345
|
"search.sort.relevance": "Most relevant",
|
|
@@ -323,6 +387,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
323
387
|
"search.filters.dismiss": "Close the filters",
|
|
324
388
|
"search.filters.chips_label": "Filters",
|
|
325
389
|
"search.filters.all": "All filters",
|
|
390
|
+
"search.filters.short": "Filters",
|
|
326
391
|
"search.filters.chip_more": ", +{count}",
|
|
327
392
|
|
|
328
393
|
"search.category.title": "Category",
|
|
@@ -379,6 +444,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
379
444
|
"search.ranking.inactive": "Not applied: {reason}",
|
|
380
445
|
"search.ranking.notes": "Notes",
|
|
381
446
|
"search.ranking.link": "How these results are ordered",
|
|
447
|
+
|
|
448
|
+
"search.nav.results": "Search",
|
|
449
|
+
"search.nav.ranking": "Ranking disclosure",
|
|
450
|
+
"search.nav.ranking.short": "Ranking",
|
|
382
451
|
};
|
|
383
452
|
|
|
384
453
|
/**
|
package/src/i18n/ru.ts
CHANGED
|
@@ -63,12 +63,20 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
63
63
|
"search.results.untitled": "Без названия",
|
|
64
64
|
"search.results.open": "Открыть",
|
|
65
65
|
"search.results.image_alt": "Фото: {title}",
|
|
66
|
+
"search.results.photo_alt": "Фото {index} из {total}: {title}",
|
|
67
|
+
"search.results.photos": "Фотографии",
|
|
68
|
+
"search.results.photo_unavailable": "Фото недоступно",
|
|
66
69
|
|
|
67
70
|
"search.box.label": "Поиск",
|
|
68
71
|
"search.box.placeholder": "Что ищете?",
|
|
69
72
|
"search.box.submit": "Найти",
|
|
70
73
|
"search.box.clear": "Очистить запрос",
|
|
71
74
|
"search.box.suggestions": "Подсказки",
|
|
75
|
+
"search.box.categories": "Разделы",
|
|
76
|
+
"search.box.category_count.one": "{count} объявление",
|
|
77
|
+
"search.box.category_count.few": "{count} объявления",
|
|
78
|
+
"search.box.category_count.many": "{count} объявлений",
|
|
79
|
+
"search.box.category_count.other": "{count} объявления",
|
|
72
80
|
|
|
73
81
|
"search.sort.label": "Сортировка",
|
|
74
82
|
"search.sort.relevance": "По релевантности",
|
|
@@ -120,6 +128,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
120
128
|
"search.filters.dismiss": "Закрыть фильтры",
|
|
121
129
|
"search.filters.chips_label": "Фильтры",
|
|
122
130
|
"search.filters.all": "Все фильтры",
|
|
131
|
+
"search.filters.short": "Фильтры",
|
|
123
132
|
"search.filters.chip_more": ", +{count}",
|
|
124
133
|
|
|
125
134
|
"search.category.title": "Категория",
|
|
@@ -177,6 +186,10 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
177
186
|
"search.ranking.inactive": "Не применяется: {reason}",
|
|
178
187
|
"search.ranking.notes": "Примечания",
|
|
179
188
|
"search.ranking.link": "Как упорядочены результаты",
|
|
189
|
+
|
|
190
|
+
"search.nav.results": "Поиск",
|
|
191
|
+
"search.nav.ranking": "Порядок результатов",
|
|
192
|
+
"search.nav.ranking.short": "Порядок",
|
|
180
193
|
};
|
|
181
194
|
|
|
182
195
|
/** Register the ru bundle. Call AFTER `registerSearchI18n` so it layers over
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,12 @@
|
|
|
44
44
|
export { createSearchApi, searchQueryParams } from "./api/searchApi.js";
|
|
45
45
|
export type { SearchApi } from "./api/searchApi.js";
|
|
46
46
|
export type { Schemas } from "./api/types.js";
|
|
47
|
-
export {
|
|
47
|
+
export {
|
|
48
|
+
SEARCH_SORTS,
|
|
49
|
+
SUGGEST_DEGRADED_CATEGORIES,
|
|
50
|
+
SUGGEST_DEGRADED_ROLLUP,
|
|
51
|
+
suggestTerms,
|
|
52
|
+
} from "./api/types.js";
|
|
48
53
|
export type {
|
|
49
54
|
FacetMeta,
|
|
50
55
|
FacetSelection,
|
|
@@ -59,6 +64,9 @@ export type {
|
|
|
59
64
|
SearchQueryState,
|
|
60
65
|
SearchRange,
|
|
61
66
|
SearchResponse,
|
|
67
|
+
SuggestAnswer,
|
|
68
|
+
SuggestCategory,
|
|
69
|
+
SuggestCategoryMatch,
|
|
62
70
|
SuggestParams,
|
|
63
71
|
SuggestResponse,
|
|
64
72
|
} from "./api/types.js";
|
|
@@ -100,7 +108,12 @@ export type {
|
|
|
100
108
|
SearchDegradationAudience,
|
|
101
109
|
} from "./state/degradations.js";
|
|
102
110
|
|
|
103
|
-
export {
|
|
111
|
+
export {
|
|
112
|
+
FACETABLE_FEATURE_TYPES,
|
|
113
|
+
buildFacetGroups,
|
|
114
|
+
facetOptionLabel,
|
|
115
|
+
isFacetableFeature,
|
|
116
|
+
} from "./state/facets.js";
|
|
104
117
|
export type {
|
|
105
118
|
BuildFacetGroupsInput,
|
|
106
119
|
FacetGroup,
|
|
@@ -132,6 +145,7 @@ export {
|
|
|
132
145
|
export { createSearchRuntime } from "./model/runtime.js";
|
|
133
146
|
export type {
|
|
134
147
|
SearchRuntime,
|
|
148
|
+
SearchImageResolver,
|
|
135
149
|
CreateSearchRuntimeOptions,
|
|
136
150
|
} from "./model/runtime.js";
|
|
137
151
|
export {
|
|
@@ -157,8 +171,13 @@ export { SearchResults } from "./headless/SearchResults.js";
|
|
|
157
171
|
export type { SearchPageInfo, SearchResultsBag } from "./headless/SearchResults.js";
|
|
158
172
|
export { FacetPanel, useFacetPanel } from "./headless/FacetPanel.js";
|
|
159
173
|
export type { FacetPanelBag } from "./headless/FacetPanel.js";
|
|
174
|
+
export { useHostFacetLabels } from "./headless/useFacetLabels.js";
|
|
175
|
+
export type {
|
|
176
|
+
FacetLabelRequest,
|
|
177
|
+
FacetLabelResolver,
|
|
178
|
+
} from "./headless/useFacetLabels.js";
|
|
160
179
|
export { useAppliedSort } from "./headless/useAppliedSort.js";
|
|
161
|
-
export { useSearchBox } from "./headless/useSearchBox.js";
|
|
180
|
+
export { offerableCategories, useSearchBox } from "./headless/useSearchBox.js";
|
|
162
181
|
export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
|
|
163
182
|
export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
|
|
164
183
|
export type {
|
package/src/model/queries.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
RankingResponse,
|
|
7
7
|
SearchQueryState,
|
|
8
8
|
SearchResponse,
|
|
9
|
-
|
|
9
|
+
SuggestAnswer,
|
|
10
10
|
} from "../api/types.js";
|
|
11
11
|
import { SUGGEST_MAX_LIMIT, SUGGEST_MIN_CHARS } from "../state/limits.js";
|
|
12
12
|
import { useSearchApi } from "./context.js";
|
|
@@ -63,11 +63,12 @@ export function useSearchQuery(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
66
|
+
* What the box offers under the cursor: CATEGORIES and title prefixes.
|
|
67
67
|
*
|
|
68
68
|
* Not from a query log: stapel-search keeps none, which is a privacy decision
|
|
69
|
-
* before it is a product one (`services.suggest`). So
|
|
70
|
-
* in the catalogue
|
|
69
|
+
* before it is a product one (`services.suggest`). So both halves are what
|
|
70
|
+
* exists in the catalogue — choosing a term is always a search that has
|
|
71
|
+
* results, and choosing a category is always a section that exists.
|
|
71
72
|
*
|
|
72
73
|
* `enabled` is the debounce's partner, not its replacement: the CALLER holds a
|
|
73
74
|
* debounced prefix (see `useSearchBox`) and this hook refuses to ask about a
|
|
@@ -83,7 +84,7 @@ export function useSuggest(params: {
|
|
|
83
84
|
readonly q: string;
|
|
84
85
|
readonly limit?: number;
|
|
85
86
|
readonly enabled?: boolean;
|
|
86
|
-
}): UseQueryResult<
|
|
87
|
+
}): UseQueryResult<SuggestAnswer, StapelApiError> {
|
|
87
88
|
const api = useSearchApi();
|
|
88
89
|
const q = params.q.trim();
|
|
89
90
|
const limit = params.limit;
|
package/src/model/queryKeys.ts
CHANGED
|
@@ -32,6 +32,22 @@ export const searchQueryKeys: {
|
|
|
32
32
|
query(
|
|
33
33
|
params: SearchQueryKeyParams
|
|
34
34
|
): readonly ["search", "query", SearchQueryKeyParams];
|
|
35
|
+
/**
|
|
36
|
+
* One group's captions from the HOST's resolver — the seam that names a
|
|
37
|
+
* vocabulary-backed facet value on a server too old to caption it itself.
|
|
38
|
+
*
|
|
39
|
+
* Keyed on the slug, the values asked about and the locale. The values are
|
|
40
|
+
* SORTED by the caller before they get here: the same group arrives in
|
|
41
|
+
* count order, and count order changes on every click, so an unsorted key
|
|
42
|
+
* would re-ask the host for the same words each time a sibling facet moved.
|
|
43
|
+
* The locale is in the key because a caption is copy — one cache entry per
|
|
44
|
+
* language, never one shared between two.
|
|
45
|
+
*/
|
|
46
|
+
facetLabels(
|
|
47
|
+
slug: string,
|
|
48
|
+
values: readonly string[],
|
|
49
|
+
locale?: string
|
|
50
|
+
): readonly ["search", "facet-labels", string, readonly string[], string | null];
|
|
35
51
|
/** The P2B disclosure. `type` is optional on the endpoint; normalized to
|
|
36
52
|
* `null` so an absent type and an explicit `undefined` cannot cache twice. */
|
|
37
53
|
ranking(type?: string): readonly ["search", "ranking", string | null];
|
|
@@ -43,6 +59,13 @@ export const searchQueryKeys: {
|
|
|
43
59
|
} = {
|
|
44
60
|
all: [ROOT],
|
|
45
61
|
query: (params) => [ROOT, "query", params],
|
|
62
|
+
facetLabels: (slug, values, locale) => [
|
|
63
|
+
ROOT,
|
|
64
|
+
"facet-labels",
|
|
65
|
+
slug,
|
|
66
|
+
values,
|
|
67
|
+
locale ?? null,
|
|
68
|
+
],
|
|
46
69
|
ranking: (type) => [ROOT, "ranking", type ?? null],
|
|
47
70
|
suggest: (type, q, limit) => [ROOT, "suggest", type, q, limit ?? null],
|
|
48
71
|
};
|
package/src/model/runtime.ts
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
import { createModuleRuntime } from "@stapel/core";
|
|
2
2
|
import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
|
|
3
|
+
import type { StapelImage } from "@stapel/image";
|
|
3
4
|
import { createSearchApi } from "../api/searchApi.js";
|
|
4
5
|
import type { SearchApi } from "../api/searchApi.js";
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Turn a stored CDN reference into something renderable.
|
|
9
|
+
*
|
|
10
|
+
* ── Why this is a seam and not a URL builder ───────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* A search card's photo fields (`image`, `images`) hold what the indexed doc
|
|
13
|
+
* type stores, and in this fleet that is an OPAQUE `<type>/<hash>` reference —
|
|
14
|
+
* the same unit `Listing.images` holds. No contract here resolves a stranger's
|
|
15
|
+
* reference: stapel-cdn's `file/exists/` is owner-scoped, so
|
|
16
|
+
* `@stapel/cdn-react`'s `useCdnRef` answers for a person's OWN draft and can
|
|
17
|
+
* never render somebody else's gallery. A pair that guessed `${base}/${ref}`
|
|
18
|
+
* would be writing a contract nobody agreed to, and it would break the first
|
|
19
|
+
* deployment that signs its media paths.
|
|
20
|
+
*
|
|
21
|
+
* So the deployment hands its own knowledge in once, exactly as
|
|
22
|
+
* `@stapel/listings-react`'s `ListingImageResolver` does — one seam, one
|
|
23
|
+
* spelling, and a container that already has a resolver passes the SAME
|
|
24
|
+
* function to both runtimes.
|
|
25
|
+
*
|
|
26
|
+
* A `StapelImage` rather than a string is what buys the variant ladder: with
|
|
27
|
+
* `variants` populated, `@stapel/image`'s `<Image>` measures the slot and
|
|
28
|
+
* picks a tier. A resolver with nothing for a reference returns `undefined`,
|
|
29
|
+
* and the skin then says "photo unavailable" instead of drawing a broken
|
|
30
|
+
* `<img>`.
|
|
31
|
+
*/
|
|
32
|
+
export type SearchImageResolver = (ref: string) => StapelImage | undefined;
|
|
33
|
+
|
|
6
34
|
/**
|
|
7
35
|
* The wired search runtime — core's `ModuleRuntime` bound to this pair's API
|
|
8
36
|
* (slim wave §21/S2). The returned `client` is what the host injects into
|
|
@@ -13,7 +41,10 @@ import type { SearchApi } from "../api/searchApi.js";
|
|
|
13
41
|
* storefront's catalogue, category and search pages need nothing but:
|
|
14
42
|
*
|
|
15
43
|
* ```tsx
|
|
16
|
-
* const runtime = createSearchRuntime({
|
|
44
|
+
* const runtime = createSearchRuntime({
|
|
45
|
+
* baseUrl: "/search/api/v1/",
|
|
46
|
+
* resolveImage: (ref) => myCdn.describe(ref),
|
|
47
|
+
* });
|
|
17
48
|
* <SearchProvider runtime={runtime}>…</SearchProvider>
|
|
18
49
|
* ```
|
|
19
50
|
*
|
|
@@ -22,12 +53,20 @@ import type { SearchApi } from "../api/searchApi.js";
|
|
|
22
53
|
* calls carry it — this pair neither requires nor waits for one, which is why
|
|
23
54
|
* its read hooks are deliberately not gated on `useActiveSessionReady`.
|
|
24
55
|
*/
|
|
25
|
-
export
|
|
56
|
+
export interface SearchRuntime extends ModuleRuntime<SearchApi> {
|
|
57
|
+
/** See {@link SearchImageResolver}. Absent = a card that stores references
|
|
58
|
+
* draws the placeholder and names the reason. */
|
|
59
|
+
readonly resolveImage: SearchImageResolver | undefined;
|
|
60
|
+
}
|
|
26
61
|
|
|
27
|
-
export
|
|
62
|
+
export interface CreateSearchRuntimeOptions extends CreateModuleRuntimeOptions {
|
|
63
|
+
/** See {@link SearchImageResolver}. */
|
|
64
|
+
readonly resolveImage?: SearchImageResolver;
|
|
65
|
+
}
|
|
28
66
|
|
|
29
67
|
export function createSearchRuntime(
|
|
30
68
|
options: CreateSearchRuntimeOptions
|
|
31
69
|
): SearchRuntime {
|
|
32
|
-
|
|
70
|
+
const base = createModuleRuntime((client) => createSearchApi(client), options);
|
|
71
|
+
return { ...base, resolveImage: options.resolveImage };
|
|
33
72
|
}
|
package/src/nav/manifest.ts
CHANGED
|
@@ -15,13 +15,34 @@
|
|
|
15
15
|
* `menuVisibleDefault: false` on both: the results page is a navigation TARGET
|
|
16
16
|
* reached from the header's search box, and the ranking disclosure is a
|
|
17
17
|
* footer link. Neither is a menu item — the same treatment `auth.login` gets.
|
|
18
|
+
*
|
|
19
|
+
* ── A nav label is not a page heading ─────────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* Both entries carry their OWN label keys (`search.nav.*`) rather than
|
|
22
|
+
* borrowing the surfaces' captions. `search.results.title` is the heading over
|
|
23
|
+
* a list of matches, so it reads "Results" — correct above rows, and useless
|
|
24
|
+
* in a menu, where "Results" answers nothing about where the link goes. The
|
|
25
|
+
* destination is the search. `search.ranking.title` is worse in the other
|
|
26
|
+
* direction: it is a whole sentence ("How these results are ordered"), which
|
|
27
|
+
* is right on the page it captions and cannot be a menu row.
|
|
28
|
+
*
|
|
29
|
+
* Sharing one key would also make the two uses move together forever: a
|
|
30
|
+
* translator improving the heading would silently rewrite the menu, and there
|
|
31
|
+
* is no locale in which the best heading and the best menu label are reliably
|
|
32
|
+
* the same words.
|
|
33
|
+
*
|
|
34
|
+
* `shortLabelKey` on the disclosure for the same reason one step smaller: a
|
|
35
|
+
* five-cell phone dock gives a destination roughly ten characters, and
|
|
36
|
+
* "Ranking disclosure" ellipsizes there to a fragment a person has to guess
|
|
37
|
+
* at. `search.results` declares none — its label is already one word in every
|
|
38
|
+
* locale this pair ships.
|
|
18
39
|
*/
|
|
19
40
|
import type { NavEntry } from "@stapel/core";
|
|
20
41
|
|
|
21
42
|
export const navEntries: readonly NavEntry[] = [
|
|
22
43
|
{
|
|
23
44
|
id: "search.results",
|
|
24
|
-
labelKey: "search.results
|
|
45
|
+
labelKey: "search.nav.results",
|
|
25
46
|
icon: "SearchOutlined",
|
|
26
47
|
route: { path: "/s" },
|
|
27
48
|
component: { export: "SearchPage", subpath: "default" },
|
|
@@ -40,7 +61,8 @@ export const navEntries: readonly NavEntry[] = [
|
|
|
40
61
|
// be mistaken — by a person or by `stapel/no-string-paths` — for the API
|
|
41
62
|
// operation of the same name.
|
|
42
63
|
id: "search.ranking",
|
|
43
|
-
labelKey: "search.ranking
|
|
64
|
+
labelKey: "search.nav.ranking",
|
|
65
|
+
shortLabelKey: "search.nav.ranking.short",
|
|
44
66
|
icon: "OrderedListOutlined",
|
|
45
67
|
route: { path: "/ranking-disclosure" },
|
|
46
68
|
component: { export: "RankingDisclosurePane", subpath: "default" },
|