@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/MODULE.md +118 -3
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +60 -6
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/searchApi.d.ts +13 -6
  7. package/dist/api/searchApi.d.ts.map +1 -1
  8. package/dist/api/searchApi.js.map +1 -1
  9. package/dist/api/types.d.ts +107 -1
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js +29 -0
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/FacetPanelPane.d.ts +13 -2
  14. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  15. package/dist/default/FacetPanelPane.js +3 -1
  16. package/dist/default/FacetPanelPane.js.map +1 -1
  17. package/dist/default/FilterChips.d.ts +88 -3
  18. package/dist/default/FilterChips.d.ts.map +1 -1
  19. package/dist/default/FilterChips.js +161 -13
  20. package/dist/default/FilterChips.js.map +1 -1
  21. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  22. package/dist/default/LocationSummaryLine.js +1 -1
  23. package/dist/default/LocationSummaryLine.js.map +1 -1
  24. package/dist/default/SearchBox.d.ts.map +1 -1
  25. package/dist/default/SearchBox.js +74 -4
  26. package/dist/default/SearchBox.js.map +1 -1
  27. package/dist/default/SearchPage.d.ts +29 -1
  28. package/dist/default/SearchPage.d.ts.map +1 -1
  29. package/dist/default/SearchPage.js +25 -10
  30. package/dist/default/SearchPage.js.map +1 -1
  31. package/dist/default/SearchResultCard.d.ts.map +1 -1
  32. package/dist/default/SearchResultCard.js +83 -37
  33. package/dist/default/SearchResultCard.js.map +1 -1
  34. package/dist/default/SearchResultsPane.d.ts +22 -0
  35. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  36. package/dist/default/SearchResultsPane.js +2 -2
  37. package/dist/default/SearchResultsPane.js.map +1 -1
  38. package/dist/default/SortSelect.d.ts +23 -0
  39. package/dist/default/SortSelect.d.ts.map +1 -1
  40. package/dist/default/SortSelect.js +29 -9
  41. package/dist/default/SortSelect.js.map +1 -1
  42. package/dist/default/cardPhotos.d.ts +65 -0
  43. package/dist/default/cardPhotos.d.ts.map +1 -0
  44. package/dist/default/cardPhotos.js +127 -0
  45. package/dist/default/cardPhotos.js.map +1 -0
  46. package/dist/default/index.d.ts +2 -2
  47. package/dist/default/index.d.ts.map +1 -1
  48. package/dist/default/index.js +1 -1
  49. package/dist/default/index.js.map +1 -1
  50. package/dist/headless/FacetPanel.d.ts +10 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +9 -1
  53. package/dist/headless/FacetPanel.js.map +1 -1
  54. package/dist/headless/useFacetLabels.d.ts +39 -0
  55. package/dist/headless/useFacetLabels.d.ts.map +1 -0
  56. package/dist/headless/useFacetLabels.js +129 -0
  57. package/dist/headless/useFacetLabels.js.map +1 -0
  58. package/dist/headless/useSearchBox.d.ts +71 -0
  59. package/dist/headless/useSearchBox.d.ts.map +1 -1
  60. package/dist/headless/useSearchBox.js +67 -2
  61. package/dist/headless/useSearchBox.js.map +1 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +10 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +55 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +67 -0
  68. package/dist/i18n/keys.js.map +1 -1
  69. package/dist/i18n/ru.d.ts.map +1 -1
  70. package/dist/i18n/ru.js +12 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +7 -5
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +4 -3
  75. package/dist/index.js.map +1 -1
  76. package/dist/model/queries.d.ts +6 -5
  77. package/dist/model/queries.d.ts.map +1 -1
  78. package/dist/model/queries.js +4 -3
  79. package/dist/model/queries.js.map +1 -1
  80. package/dist/model/queryKeys.d.ts +12 -0
  81. package/dist/model/queryKeys.d.ts.map +1 -1
  82. package/dist/model/queryKeys.js +7 -0
  83. package/dist/model/queryKeys.js.map +1 -1
  84. package/dist/model/runtime.d.ts +40 -3
  85. package/dist/model/runtime.d.ts.map +1 -1
  86. package/dist/model/runtime.js +2 -1
  87. package/dist/model/runtime.js.map +1 -1
  88. package/dist/nav/manifest.d.ts +21 -0
  89. package/dist/nav/manifest.d.ts.map +1 -1
  90. package/dist/nav/manifest.js +3 -2
  91. package/dist/nav/manifest.js.map +1 -1
  92. package/dist/state/facets.d.ts +56 -15
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +101 -27
  95. package/dist/state/facets.js.map +1 -1
  96. package/llms.txt +3 -3
  97. package/manifest.json +33 -4
  98. package/nav-manifest.json +4 -3
  99. package/package.json +11 -11
  100. package/src/analytics/generated/events.json +1 -1
  101. package/src/api/generated/schema.ts +60 -6
  102. package/src/api/searchApi.ts +13 -6
  103. package/src/api/types.ts +115 -1
  104. package/src/default/FacetPanelPane.tsx +16 -2
  105. package/src/default/FilterChips.tsx +285 -38
  106. package/src/default/LocationSummaryLine.tsx +6 -1
  107. package/src/default/SearchBox.tsx +134 -6
  108. package/src/default/SearchPage.tsx +79 -5
  109. package/src/default/SearchResultCard.tsx +157 -71
  110. package/src/default/SearchResultsPane.tsx +48 -11
  111. package/src/default/SortSelect.tsx +70 -9
  112. package/src/default/cardPhotos.ts +195 -0
  113. package/src/default/index.ts +10 -2
  114. package/src/headless/FacetPanel.tsx +20 -1
  115. package/src/headless/useFacetLabels.ts +172 -0
  116. package/src/headless/useSearchBox.ts +132 -5
  117. package/src/i18n/es.ts +11 -0
  118. package/src/i18n/keys.ts +69 -0
  119. package/src/i18n/ru.ts +13 -0
  120. package/src/index.ts +22 -3
  121. package/src/model/queries.ts +6 -5
  122. package/src/model/queryKeys.ts +23 -0
  123. package/src/model/runtime.ts +43 -4
  124. package/src/nav/manifest.ts +24 -2
  125. 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
- loadStateFromQuery(suggest),
173
- (data) => data.items as readonly string[]
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 { SEARCH_SORTS } from "./api/types.js";
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 { buildFacetGroups, facetOptionLabel } from "./state/facets.js";
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 {
@@ -6,7 +6,7 @@ import type {
6
6
  RankingResponse,
7
7
  SearchQueryState,
8
8
  SearchResponse,
9
- SuggestResponse,
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
- * Title prefixes from the index, for the search box's typeahead.
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 the list is what exists
70
- * in the catalogue, and choosing one is always a search that has results.
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<SuggestResponse, StapelApiError> {
87
+ }): UseQueryResult<SuggestAnswer, StapelApiError> {
87
88
  const api = useSearchApi();
88
89
  const q = params.q.trim();
89
90
  const limit = params.limit;
@@ -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
  };
@@ -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({ baseUrl: "/search/api/v1/" });
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 type SearchRuntime = ModuleRuntime<SearchApi>;
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 type CreateSearchRuntimeOptions = CreateModuleRuntimeOptions;
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
- return createModuleRuntime((client) => createSearchApi(client), options);
70
+ const base = createModuleRuntime((client) => createSearchApi(client), options);
71
+ return { ...base, resolveImage: options.resolveImage };
33
72
  }
@@ -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.title",
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.title",
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" },