@stapel/search-react 0.11.0 → 0.12.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 (111) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/MODULE.md +111 -0
  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/SearchResultsPane.d.ts +22 -0
  32. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  33. package/dist/default/SearchResultsPane.js +2 -2
  34. package/dist/default/SearchResultsPane.js.map +1 -1
  35. package/dist/default/SortSelect.d.ts +23 -0
  36. package/dist/default/SortSelect.d.ts.map +1 -1
  37. package/dist/default/SortSelect.js +29 -9
  38. package/dist/default/SortSelect.js.map +1 -1
  39. package/dist/default/index.d.ts +2 -2
  40. package/dist/default/index.d.ts.map +1 -1
  41. package/dist/default/index.js +1 -1
  42. package/dist/default/index.js.map +1 -1
  43. package/dist/headless/FacetPanel.d.ts +10 -0
  44. package/dist/headless/FacetPanel.d.ts.map +1 -1
  45. package/dist/headless/FacetPanel.js +9 -1
  46. package/dist/headless/FacetPanel.js.map +1 -1
  47. package/dist/headless/useFacetLabels.d.ts +39 -0
  48. package/dist/headless/useFacetLabels.d.ts.map +1 -0
  49. package/dist/headless/useFacetLabels.js +129 -0
  50. package/dist/headless/useFacetLabels.js.map +1 -0
  51. package/dist/headless/useSearchBox.d.ts +71 -0
  52. package/dist/headless/useSearchBox.d.ts.map +1 -1
  53. package/dist/headless/useSearchBox.js +67 -2
  54. package/dist/headless/useSearchBox.js.map +1 -1
  55. package/dist/i18n/es.d.ts.map +1 -1
  56. package/dist/i18n/es.js +7 -0
  57. package/dist/i18n/es.js.map +1 -1
  58. package/dist/i18n/keys.d.ts +45 -0
  59. package/dist/i18n/keys.d.ts.map +1 -1
  60. package/dist/i18n/keys.js +54 -0
  61. package/dist/i18n/keys.js.map +1 -1
  62. package/dist/i18n/ru.d.ts.map +1 -1
  63. package/dist/i18n/ru.js +9 -0
  64. package/dist/i18n/ru.js.map +1 -1
  65. package/dist/index.d.ts +6 -4
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +4 -3
  68. package/dist/index.js.map +1 -1
  69. package/dist/model/queries.d.ts +6 -5
  70. package/dist/model/queries.d.ts.map +1 -1
  71. package/dist/model/queries.js +4 -3
  72. package/dist/model/queries.js.map +1 -1
  73. package/dist/model/queryKeys.d.ts +12 -0
  74. package/dist/model/queryKeys.d.ts.map +1 -1
  75. package/dist/model/queryKeys.js +7 -0
  76. package/dist/model/queryKeys.js.map +1 -1
  77. package/dist/nav/manifest.d.ts +21 -0
  78. package/dist/nav/manifest.d.ts.map +1 -1
  79. package/dist/nav/manifest.js +3 -2
  80. package/dist/nav/manifest.js.map +1 -1
  81. package/dist/state/facets.d.ts +56 -15
  82. package/dist/state/facets.d.ts.map +1 -1
  83. package/dist/state/facets.js +101 -27
  84. package/dist/state/facets.js.map +1 -1
  85. package/llms.txt +2 -2
  86. package/manifest.json +22 -2
  87. package/nav-manifest.json +4 -3
  88. package/package.json +11 -11
  89. package/src/analytics/generated/events.json +1 -1
  90. package/src/api/generated/schema.ts +60 -6
  91. package/src/api/searchApi.ts +13 -6
  92. package/src/api/types.ts +115 -1
  93. package/src/default/FacetPanelPane.tsx +16 -2
  94. package/src/default/FilterChips.tsx +285 -38
  95. package/src/default/LocationSummaryLine.tsx +6 -1
  96. package/src/default/SearchBox.tsx +134 -6
  97. package/src/default/SearchPage.tsx +79 -5
  98. package/src/default/SearchResultsPane.tsx +48 -11
  99. package/src/default/SortSelect.tsx +70 -9
  100. package/src/default/index.ts +10 -2
  101. package/src/headless/FacetPanel.tsx +20 -1
  102. package/src/headless/useFacetLabels.ts +172 -0
  103. package/src/headless/useSearchBox.ts +132 -5
  104. package/src/i18n/es.ts +8 -0
  105. package/src/i18n/keys.ts +56 -0
  106. package/src/i18n/ru.ts +10 -0
  107. package/src/index.ts +21 -3
  108. package/src/model/queries.ts +6 -5
  109. package/src/model/queryKeys.ts +23 -0
  110. package/src/nav/manifest.ts +24 -2
  111. package/src/state/facets.ts +123 -39
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The HOST LABEL SEAM — the third source of a facet option's caption, and the
3
+ * only one that can reach a vocabulary.
4
+ *
5
+ * ── The defect ────────────────────────────────────────────────────────────
6
+ *
7
+ * A live classified deployment's category schema types `vendor`, `model`,
8
+ * `memory_size` and `color_ref_select` as `ref_select`: their config carries
9
+ * no `options` table at all, only a POINTER —
10
+ * `{"optionsRef": {"level": "Vendor", "vocabulary": "avito-phone-catalog"}}`.
11
+ * The words live in the vocabulary the pointer names, which is a different
12
+ * service with a different client. So the two label sources this pair already
13
+ * has both come up empty: the answer's `facet_labels` because the deployed
14
+ * server predates stapel-search 0.4.0 and sends no such key, and the schema
15
+ * because a pointer is not a table. The chips printed `apple`, `128-gb`,
16
+ * `chernyy` — storage slugs, at buyers.
17
+ *
18
+ * This pair must not grow a vocabulary client to fix that: it would tie every
19
+ * storefront's search to a service it may not run, and the host ALREADY has
20
+ * that client (`@stapel/attributes-react`'s `VocabularyClientProvider` is
21
+ * mounted for the composer). So the fetch is the host's and the discipline is
22
+ * this module's — the same division as `renderGeoFilter` and
23
+ * `renderCategoryFilter`, one seam lower because what crosses it is data
24
+ * rather than a control.
25
+ *
26
+ * ── Precedence, stated once ───────────────────────────────────────────────
27
+ *
28
+ * 1. the answer's `facet_labels` — the server saw the write-time snapshot;
29
+ * 2. the feature def's inline `options` table — for the plain `select`
30
+ * family, which carries its words with it;
31
+ * 3. this resolver;
32
+ * 4. the raw value.
33
+ *
34
+ * `buildFacetGroups` has already applied 1 and 2 by the time this hook runs,
35
+ * and it leaves an option it could not name with `label === value` — which is
36
+ * what makes "did anyone name this?" answerable without a second lookup. Only
37
+ * those values are asked about, so the host is never called for a value the
38
+ * server or the schema already captioned, and a resolver that returns nothing
39
+ * for a value leaves the raw value on screen. A chip that silently dropped an
40
+ * option would be worse than one showing a slug: the option is real, it has a
41
+ * count, and it is the only way to reach those documents.
42
+ *
43
+ * ── The fetch discipline ──────────────────────────────────────────────────
44
+ *
45
+ * TanStack Query, exactly like the pair's other three reads, and for the four
46
+ * reasons that made it right there:
47
+ *
48
+ * - **batched per group.** One call per facet slug carrying every unresolved
49
+ * value of it, not one call per chip.
50
+ * - **cached.** `staleTime: Infinity` — a vocabulary term's caption changes
51
+ * when somebody edits the catalogue, not between two clicks on a filter.
52
+ * - **deduplicated.** The chip row, the filter panel and the page's layout
53
+ * probe each call `useFacetPanel`, so three components ask for the same
54
+ * group's captions in one render pass; one query key means one request.
55
+ * - **aborted on supersession.** The `signal` is the query's own, so a
56
+ * resolver that honours it stops a request whose answer nobody will read.
57
+ */
58
+ import { useQueries } from "@tanstack/react-query";
59
+ import { mapLoad } from "@stapel/core";
60
+ import type { LoadState } from "@stapel/core";
61
+ import type { FeatureDef } from "@stapel/attributes-react";
62
+ import { searchQueryKeys } from "../model/queryKeys.js";
63
+ import type { FacetGroup } from "../state/facets.js";
64
+
65
+ /** What the host is asked to name. */
66
+ export interface FacetLabelRequest {
67
+ /** The facet slug — `vendor`, `color_ref_select`. */
68
+ readonly slug: string;
69
+ /**
70
+ * The category's own def for the slug, when the host supplied a schema.
71
+ * It carries the `optionsRef` pointer (vocabulary + level) a resolver needs
72
+ * to know WHICH vocabulary these codes belong to, so a host does not have to
73
+ * keep a second copy of the schema to answer.
74
+ */
75
+ readonly feature: FeatureDef | undefined;
76
+ /**
77
+ * The values nobody has named yet, sorted. Only the unresolved ones: a
78
+ * resolver is never asked about a value the server or the schema captioned.
79
+ */
80
+ readonly values: readonly string[];
81
+ }
82
+
83
+ /**
84
+ * A host's answer: `{value: caption}`.
85
+ *
86
+ * Partial answers are the expected case, not an error — a code the vocabulary
87
+ * no longer holds simply is not in the map, and its chip keeps printing the
88
+ * raw value. Returning `{}` is a legitimate "I cannot name any of these".
89
+ */
90
+ export type FacetLabelResolver = (
91
+ request: FacetLabelRequest,
92
+ options: { readonly signal: AbortSignal }
93
+ ) => Promise<Readonly<Record<string, string>>>;
94
+
95
+ /** One frozen empty list, so a render with no unresolved values is stable. */
96
+ const NO_REQUESTS: readonly FacetLabelRequest[] = [];
97
+
98
+ /** The values of one group nobody has named — see the precedence note above. */
99
+ function unresolvedValues(group: FacetGroup): readonly string[] {
100
+ return group.options
101
+ .filter((option) => option.label === option.value)
102
+ .map((option) => option.value)
103
+ .sort((a, b) => a.localeCompare(b));
104
+ }
105
+
106
+ function isCaptionMap(value: unknown): value is Readonly<Record<string, string>> {
107
+ return typeof value === "object" && value !== null && !Array.isArray(value);
108
+ }
109
+
110
+ /**
111
+ * Fill in what the server and the schema could not name, through the host.
112
+ *
113
+ * Returns the groups UNCHANGED — same object identity — when there is no
114
+ * resolver or nothing came back, so a page with the seam unwired pays neither
115
+ * a request nor a re-render.
116
+ */
117
+ export function useHostFacetLabels(
118
+ groups: LoadState<readonly FacetGroup[]>,
119
+ resolve: FacetLabelResolver | undefined,
120
+ locale: string | undefined
121
+ ): LoadState<readonly FacetGroup[]> {
122
+ const requests: readonly FacetLabelRequest[] =
123
+ resolve === undefined || groups.status !== "ready"
124
+ ? NO_REQUESTS
125
+ : groups.data
126
+ .map((group) => ({
127
+ slug: group.slug,
128
+ feature: group.feature,
129
+ values: unresolvedValues(group),
130
+ }))
131
+ .filter((request) => request.values.length > 0);
132
+
133
+ const answers = useQueries({
134
+ queries: requests.map((request) => ({
135
+ queryKey: searchQueryKeys.facetLabels(request.slug, request.values, locale),
136
+ queryFn: async ({ signal }: { signal: AbortSignal }): Promise<unknown> =>
137
+ resolve === undefined ? {} : await resolve(request, { signal }),
138
+ staleTime: Number.POSITIVE_INFINITY,
139
+ // A vocabulary that answered 500 answers 500 again a millisecond later,
140
+ // and three retries only delay the chip's raw value by three round
141
+ // trips — the same discipline the pair's other reads keep.
142
+ retry: false,
143
+ })),
144
+ });
145
+
146
+ const captions = new Map<string, Readonly<Record<string, string>>>();
147
+ requests.forEach((request, index) => {
148
+ const data = answers[index]?.data;
149
+ if (isCaptionMap(data)) captions.set(request.slug, data);
150
+ });
151
+ if (captions.size === 0) return groups;
152
+
153
+ return mapLoad(groups, (list) =>
154
+ list.map((group) => {
155
+ const named = captions.get(group.slug);
156
+ if (named === undefined) return group;
157
+ return {
158
+ ...group,
159
+ options: group.options.map((option) => {
160
+ // Precedence again, enforced rather than assumed: an option whose
161
+ // label already differs from its value was named by the server or
162
+ // the schema, and the host does not get to overwrite either.
163
+ if (option.label !== option.value) return option;
164
+ const caption = named[option.value];
165
+ return caption === undefined || caption.length === 0
166
+ ? option
167
+ : { ...option, label: caption };
168
+ }),
169
+ };
170
+ })
171
+ );
172
+ }
@@ -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
@@ -46,6 +46,9 @@ export const searchI18nBundleEs: I18nDictionary = {
46
46
  "search.box.submit": "Buscar",
47
47
  "search.box.clear": "Borrar la búsqueda",
48
48
  "search.box.suggestions": "Sugerencias",
49
+ "search.box.categories": "Secciones",
50
+ "search.box.category_count.one": "{count} anuncio",
51
+ "search.box.category_count.other": "{count} anuncios",
49
52
 
50
53
  "search.sort.label": "Orden",
51
54
  "search.sort.relevance": "Más relevantes",
@@ -93,6 +96,7 @@ export const searchI18nBundleEs: I18nDictionary = {
93
96
  "search.filters.dismiss": "Cerrar los filtros",
94
97
  "search.filters.chips_label": "Filtros",
95
98
  "search.filters.all": "Todos los filtros",
99
+ "search.filters.short": "Filtros",
96
100
  "search.filters.chip_more": ", +{count}",
97
101
 
98
102
  "search.category.title": "Categoría",
@@ -150,6 +154,10 @@ export const searchI18nBundleEs: I18nDictionary = {
150
154
  "search.ranking.inactive": "No aplicado: {reason}",
151
155
  "search.ranking.notes": "Notas",
152
156
  "search.ranking.link": "Cómo se ordenan estos resultados",
157
+
158
+ "search.nav.results": "Buscar",
159
+ "search.nav.ranking": "Orden de resultados",
160
+ "search.nav.ranking.short": "Orden",
153
161
  };
154
162
 
155
163
  /** Register the es bundle. Call AFTER `registerSearchI18n`. */
package/src/i18n/keys.ts CHANGED
@@ -64,6 +64,22 @@ export const SEARCH_I18N_KEYS = {
64
64
  boxClear: "search.box.clear",
65
65
  /** Accessible name of the suggestion list under the box. */
66
66
  boxSuggestions: "search.box.suggestions",
67
+ /**
68
+ * The heading over the CATEGORY half of the type-ahead (stapel-search
69
+ * 0.7.0). The rows under it are DESTINATIONS — a section of the catalogue —
70
+ * and not, like everything else in the menu, another set of words to search
71
+ * for; a group with no heading would read as more of the same.
72
+ */
73
+ boxCategories: "search.box.categories",
74
+ /**
75
+ * "1 240 listings" beside a category row — how many LIVE listings a buyer
76
+ * would see there, the same number the SERP reports for it.
77
+ *
78
+ * A PLURAL FAMILY: render with `tPlural`, never `t`. It counts a noun in
79
+ * words, so Russian needs its four endings and a single string would be
80
+ * right only for 5-20 — the exact defect the results count already fixed.
81
+ */
82
+ boxCategoryCount: "search.box.category_count",
67
83
 
68
84
  // ── sort ─────────────────────────────────────────────────────────────────
69
85
  sortLabel: "search.sort.label",
@@ -129,6 +145,9 @@ export const SEARCH_I18N_KEYS = {
129
145
  /** The leading, icon-only chip: the whole panel. Icon-only means the name
130
146
  * exists ONLY here, so this key is the control's entire accessibility. */
131
147
  filtersAll: "search.filters.all",
148
+ /** The same door, named for a 390px row: the location line's trailing link
149
+ * sits beside a place name and cannot spend six characters on "All". */
150
+ filtersShort: "search.filters.short",
132
151
  /** A chip filtering on more than one value: "Bosch, +2". Not a plural
133
152
  * family — nothing is being counted in words. */
134
153
  filtersChipMore: "search.filters.chip_more",
@@ -207,6 +226,34 @@ export const SEARCH_I18N_KEYS = {
207
226
  rankingInactive: "search.ranking.inactive",
208
227
  rankingNotes: "search.ranking.notes",
209
228
  rankingLink: "search.ranking.link",
229
+
230
+ // ── nav (the destination's own name, not the page's heading) ─────────────
231
+ /**
232
+ * What the MENU calls the results screen.
233
+ *
234
+ * Separate from `search.results.title` on purpose. That key captions the
235
+ * list of matches — a heading over rows, which is why it reads "Results" —
236
+ * and a menu entry pointing at `/s` labelled "Results" tells a visitor
237
+ * nothing about where they would be going: results of what? The destination
238
+ * is the search itself. One key, one job; a translator asked to make
239
+ * "Results" work as both a page heading and a menu label has to pick which
240
+ * one to get wrong.
241
+ */
242
+ navResults: "search.nav.results",
243
+ /** The menu name of the P2B disclosure PAGE — see {@link navResults} for
244
+ * why it is not `search.ranking.title`, which is the page's own heading and
245
+ * a whole sentence. */
246
+ navRanking: "search.nav.ranking",
247
+ /**
248
+ * The compact form for a phone dock — see `NavEntry.shortLabelKey`. A
249
+ * five-cell dock at 390px gives a destination roughly ten characters, and
250
+ * "Ranking disclosure" ellipsizes to a fragment there.
251
+ *
252
+ * `search.nav.results` needs no short form: it is already one word in every
253
+ * locale this pair ships, and declaring a short key identical to the long
254
+ * one only gives a translator two strings to keep in sync.
255
+ */
256
+ navRankingShort: "search.nav.ranking.short",
210
257
  } as const;
211
258
 
212
259
  export type SearchI18nKey =
@@ -233,6 +280,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
233
280
  SEARCH_I18N_KEYS.resultsCountExact,
234
281
  SEARCH_I18N_KEYS.filtersShowCount,
235
282
  SEARCH_I18N_KEYS.filtersShowCountAtLeast,
283
+ SEARCH_I18N_KEYS.boxCategoryCount,
236
284
  ];
237
285
 
238
286
  /**
@@ -276,6 +324,9 @@ export const searchI18nBundleEn: Record<string, string> = {
276
324
  "search.box.submit": "Search",
277
325
  "search.box.clear": "Clear the search",
278
326
  "search.box.suggestions": "Suggestions",
327
+ "search.box.categories": "Sections",
328
+ "search.box.category_count.one": "{count} listing",
329
+ "search.box.category_count.other": "{count} listings",
279
330
 
280
331
  "search.sort.label": "Sort",
281
332
  "search.sort.relevance": "Most relevant",
@@ -323,6 +374,7 @@ export const searchI18nBundleEn: Record<string, string> = {
323
374
  "search.filters.dismiss": "Close the filters",
324
375
  "search.filters.chips_label": "Filters",
325
376
  "search.filters.all": "All filters",
377
+ "search.filters.short": "Filters",
326
378
  "search.filters.chip_more": ", +{count}",
327
379
 
328
380
  "search.category.title": "Category",
@@ -379,6 +431,10 @@ export const searchI18nBundleEn: Record<string, string> = {
379
431
  "search.ranking.inactive": "Not applied: {reason}",
380
432
  "search.ranking.notes": "Notes",
381
433
  "search.ranking.link": "How these results are ordered",
434
+
435
+ "search.nav.results": "Search",
436
+ "search.nav.ranking": "Ranking disclosure",
437
+ "search.nav.ranking.short": "Ranking",
382
438
  };
383
439
 
384
440
  /**
package/src/i18n/ru.ts CHANGED
@@ -69,6 +69,11 @@ export const searchI18nBundleRu: I18nDictionary = {
69
69
  "search.box.submit": "Найти",
70
70
  "search.box.clear": "Очистить запрос",
71
71
  "search.box.suggestions": "Подсказки",
72
+ "search.box.categories": "Разделы",
73
+ "search.box.category_count.one": "{count} объявление",
74
+ "search.box.category_count.few": "{count} объявления",
75
+ "search.box.category_count.many": "{count} объявлений",
76
+ "search.box.category_count.other": "{count} объявления",
72
77
 
73
78
  "search.sort.label": "Сортировка",
74
79
  "search.sort.relevance": "По релевантности",
@@ -120,6 +125,7 @@ export const searchI18nBundleRu: I18nDictionary = {
120
125
  "search.filters.dismiss": "Закрыть фильтры",
121
126
  "search.filters.chips_label": "Фильтры",
122
127
  "search.filters.all": "Все фильтры",
128
+ "search.filters.short": "Фильтры",
123
129
  "search.filters.chip_more": ", +{count}",
124
130
 
125
131
  "search.category.title": "Категория",
@@ -177,6 +183,10 @@ export const searchI18nBundleRu: I18nDictionary = {
177
183
  "search.ranking.inactive": "Не применяется: {reason}",
178
184
  "search.ranking.notes": "Примечания",
179
185
  "search.ranking.link": "Как упорядочены результаты",
186
+
187
+ "search.nav.results": "Поиск",
188
+ "search.nav.ranking": "Порядок результатов",
189
+ "search.nav.ranking.short": "Порядок",
180
190
  };
181
191
 
182
192
  /** 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,
@@ -157,8 +170,13 @@ export { SearchResults } from "./headless/SearchResults.js";
157
170
  export type { SearchPageInfo, SearchResultsBag } from "./headless/SearchResults.js";
158
171
  export { FacetPanel, useFacetPanel } from "./headless/FacetPanel.js";
159
172
  export type { FacetPanelBag } from "./headless/FacetPanel.js";
173
+ export { useHostFacetLabels } from "./headless/useFacetLabels.js";
174
+ export type {
175
+ FacetLabelRequest,
176
+ FacetLabelResolver,
177
+ } from "./headless/useFacetLabels.js";
160
178
  export { useAppliedSort } from "./headless/useAppliedSort.js";
161
- export { useSearchBox } from "./headless/useSearchBox.js";
179
+ export { offerableCategories, useSearchBox } from "./headless/useSearchBox.js";
162
180
  export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
163
181
  export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
164
182
  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
  };