@stapel/search-react 0.10.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 (130) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/MODULE.md +111 -0
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +76 -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/DegradationNotice.d.ts +22 -13
  14. package/dist/default/DegradationNotice.d.ts.map +1 -1
  15. package/dist/default/DegradationNotice.js +7 -8
  16. package/dist/default/DegradationNotice.js.map +1 -1
  17. package/dist/default/FacetPanelPane.d.ts +13 -2
  18. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  19. package/dist/default/FacetPanelPane.js +29 -16
  20. package/dist/default/FacetPanelPane.js.map +1 -1
  21. package/dist/default/FilterChips.d.ts +88 -3
  22. package/dist/default/FilterChips.d.ts.map +1 -1
  23. package/dist/default/FilterChips.js +166 -13
  24. package/dist/default/FilterChips.js.map +1 -1
  25. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  26. package/dist/default/LocationSummaryLine.js +1 -1
  27. package/dist/default/LocationSummaryLine.js.map +1 -1
  28. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  29. package/dist/default/RangeFilterRow.js +49 -5
  30. package/dist/default/RangeFilterRow.js.map +1 -1
  31. package/dist/default/SearchBox.d.ts.map +1 -1
  32. package/dist/default/SearchBox.js +74 -4
  33. package/dist/default/SearchBox.js.map +1 -1
  34. package/dist/default/SearchPage.d.ts +29 -1
  35. package/dist/default/SearchPage.d.ts.map +1 -1
  36. package/dist/default/SearchPage.js +25 -10
  37. package/dist/default/SearchPage.js.map +1 -1
  38. package/dist/default/SearchResultsPane.d.ts +22 -0
  39. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  40. package/dist/default/SearchResultsPane.js +2 -2
  41. package/dist/default/SearchResultsPane.js.map +1 -1
  42. package/dist/default/SortSelect.d.ts +23 -0
  43. package/dist/default/SortSelect.d.ts.map +1 -1
  44. package/dist/default/SortSelect.js +29 -9
  45. package/dist/default/SortSelect.js.map +1 -1
  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 +26 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +16 -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 +8 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +48 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +58 -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 +10 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +9 -7
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +6 -5
  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/nav/manifest.d.ts +21 -0
  85. package/dist/nav/manifest.d.ts.map +1 -1
  86. package/dist/nav/manifest.js +3 -2
  87. package/dist/nav/manifest.js.map +1 -1
  88. package/dist/state/degradations.d.ts +32 -0
  89. package/dist/state/degradations.d.ts.map +1 -1
  90. package/dist/state/degradations.js +33 -0
  91. package/dist/state/degradations.js.map +1 -1
  92. package/dist/state/facets.d.ts +64 -0
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +134 -9
  95. package/dist/state/facets.js.map +1 -1
  96. package/dist/state/ranges.d.ts +45 -3
  97. package/dist/state/ranges.d.ts.map +1 -1
  98. package/dist/state/ranges.js +55 -14
  99. package/dist/state/ranges.js.map +1 -1
  100. package/llms.txt +2 -2
  101. package/manifest.json +27 -2
  102. package/nav-manifest.json +4 -3
  103. package/package.json +10 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/api/generated/schema.ts +76 -6
  106. package/src/api/searchApi.ts +13 -6
  107. package/src/api/types.ts +115 -1
  108. package/src/default/DegradationNotice.tsx +30 -20
  109. package/src/default/FacetPanelPane.tsx +55 -25
  110. package/src/default/FilterChips.tsx +290 -38
  111. package/src/default/LocationSummaryLine.tsx +6 -1
  112. package/src/default/RangeFilterRow.tsx +61 -3
  113. package/src/default/SearchBox.tsx +134 -6
  114. package/src/default/SearchPage.tsx +79 -5
  115. package/src/default/SearchResultsPane.tsx +48 -11
  116. package/src/default/SortSelect.tsx +70 -9
  117. package/src/default/index.ts +10 -2
  118. package/src/headless/FacetPanel.tsx +44 -1
  119. package/src/headless/useFacetLabels.ts +172 -0
  120. package/src/headless/useSearchBox.ts +132 -5
  121. package/src/i18n/es.ts +9 -0
  122. package/src/i18n/keys.ts +60 -0
  123. package/src/i18n/ru.ts +11 -0
  124. package/src/index.ts +28 -4
  125. package/src/model/queries.ts +6 -5
  126. package/src/model/queryKeys.ts +23 -0
  127. package/src/nav/manifest.ts +24 -2
  128. package/src/state/degradations.ts +56 -0
  129. package/src/state/facets.ts +181 -9
  130. package/src/state/ranges.ts +85 -15
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -102,13 +102,24 @@ export interface paths {
102
102
  cookie?: never;
103
103
  };
104
104
  /**
105
- * Title prefixes from the index
106
- * @description ``GET /search/api/v1/suggest`` — title prefixes out of the index.
105
+ * Type-ahead: category paths with live counts, plus title prefixes
106
+ * @description ``GET /search/api/v1/suggest`` — what to offer under the search box.
107
107
  *
108
- * Not out of a query log: no query log is kept, which is a privacy
108
+ * ``categories`` is the primary half: each row is a destination with its
109
+ * full ancestor path and the number of listings a buyer would actually
110
+ * see there, ranked by that number. ``terms`` is the title-prefix half.
111
+ *
112
+ * Neither comes from a query log: no query log is kept, which is a privacy
109
113
  * decision before it is a product one, and on day one there would be
110
114
  * nothing in it anyway.
111
115
  *
116
+ * The answer is public, identical for every reader and requested on every
117
+ * keystroke, so it carries ``Cache-Control: public`` and an ``ETag``. This
118
+ * is the module's first conditional read — ``query`` has none, because a
119
+ * SERP answer embeds ``took_ms`` and a cursor and would revalidate to a
120
+ * miss every time. Here the payload is deliberately free of anything that
121
+ * varies with the clock.
122
+ *
112
123
  * **Permissions:** `AllowAny`
113
124
  */
114
125
  get: operations["search_api_v1_suggest_retrieve"];
@@ -124,6 +135,38 @@ export interface paths {
124
135
  export type webhooks = Record<string, never>;
125
136
  export interface components {
126
137
  schemas: {
138
+ /** @description One destination in the dropdown, ready to render and ready to follow. */
139
+ CategorySuggestion: {
140
+ /** @description Category id. */
141
+ id: number;
142
+ /** @description Category slug. */
143
+ slug: string;
144
+ /** @description The category's own display name. */
145
+ name: string;
146
+ /** @description Display names root->leaf, e.g. ['Мужская одежда', 'Шорты']. This is what distinguishes three categories that share a name. */
147
+ path: string[];
148
+ /** @description The ancestry as ids joined with '/'. Pass it verbatim as the `category` parameter of /query — do not re-join path segments yourself. */
149
+ category: string;
150
+ /** @description Live listings a buyer would see under this category, descendants included — the same number the SERP reports for it. */
151
+ count: number;
152
+ /** @description Number of segments in `path`. */
153
+ depth: number;
154
+ /**
155
+ * @description How the name matched. Informational; ranking is by `count`.
156
+ *
157
+ * * `prefix` - prefix
158
+ * * `substring` - substring
159
+ */
160
+ match: components["schemas"]["MatchEnum"];
161
+ };
162
+ /** @description Captions for one slug's option codes. */
163
+ FacetLabels: {
164
+ /** @description True when `values` holds translation KEYS to run through the catalogue; false when it holds literal captions. The reader cannot tell by looking — `b.apple` and `Б/у` are both strings. */
165
+ translatable: boolean;
166
+ values: {
167
+ [key: string]: string;
168
+ };
169
+ };
127
170
  FacetMeta: {
128
171
  /** @description True when counts came from a sample because the candidate set exceeded FACET_CANDIDATE_CAP. */
129
172
  approximate: boolean;
@@ -132,6 +175,8 @@ export interface components {
132
175
  counted: string[];
133
176
  /** @description Plan slugs dropped at MAX_FACET_FIELDS — reported, not vanished. */
134
177
  skipped: string[];
178
+ /** @description Range slugs that address a core document column rather than an attribute (`r.price`). Offer them as filters unconditionally: they exist for every document in every category, which is why they are not in the category's own plan. */
179
+ core_ranges: string[];
135
180
  };
136
181
  HealthResponse: {
137
182
  backend: string;
@@ -145,6 +190,12 @@ export interface components {
145
190
  lag_seconds?: number | null;
146
191
  stale_reason?: string;
147
192
  };
193
+ /**
194
+ * @description * `prefix` - prefix
195
+ * * `substring` - substring
196
+ * @enum {string}
197
+ */
198
+ MatchEnum: "prefix" | "substring";
148
199
  /** @description The P2B Art. 5 disclosure, generated from the scorer registry. */
149
200
  RankingResponse: {
150
201
  doc_type: string;
@@ -208,6 +259,10 @@ export interface components {
208
259
  [key: string]: number;
209
260
  };
210
261
  };
262
+ /** @description {slug: {translatable, values: {value: caption}}} for slugs whose options are inline in the category schema. Absent for a vocabulary-backed slug: its level lives outside the schema and the plan will not invent a caption it has not read. */
263
+ facet_labels: {
264
+ [key: string]: components["schemas"]["FacetLabels"];
265
+ };
211
266
  facet_meta: components["schemas"]["FacetMeta"];
212
267
  next_anchor: string | null;
213
268
  prev_anchor: string | null;
@@ -222,11 +277,22 @@ export interface components {
222
277
  /** @description What the configured engine could not do for this query. */
223
278
  degraded: string[];
224
279
  backend: string;
280
+ /** @description The language whose dictionary and analyzer configuration answered — `lang`, else Accept-Language, else DEFAULT_LANGUAGE. When the fallback is wrong the synonym layer silently does not apply, and this field is the only place the answer says so. */
281
+ language: string;
225
282
  sort: string;
226
283
  took_ms: number;
227
284
  };
228
285
  SuggestResponse: {
286
+ /** @description Destinations, ranked by live listing count desc, then depth, then name. */
287
+ categories: components["schemas"]["CategorySuggestion"][];
288
+ /** @description Title prefixes from the index. */
289
+ terms: string[];
290
+ /** @description Deprecated alias of `terms`, kept for one minor. */
229
291
  items: string[];
292
+ /** @description Which dictionary answered — the same resolution /query reports. */
293
+ language: string;
294
+ /** @description What this answer could not do: `category_suggestions` (no provider for category names), `category_rollup` (no ancestry, so counts would read 0). */
295
+ degraded: string[];
230
296
  backend: string;
231
297
  };
232
298
  };
@@ -356,11 +422,15 @@ export interface operations {
356
422
  };
357
423
  search_api_v1_suggest_retrieve: {
358
424
  parameters: {
359
- query: {
425
+ query?: {
426
+ /** @description Language of the query: picks the dictionary, so «shorty» reaches «шорты». Falls back to Accept-Language, then DEFAULT_LANGUAGE. */
427
+ lang?: string;
428
+ /** @description Rows per half. Capped by MAX_SUGGEST_LIMIT. */
360
429
  limit?: number;
361
- /** @description Title prefix. */
430
+ /** @description What the buyer has typed so far. */
362
431
  q?: string;
363
- type: string;
432
+ /** @description Registered doc_type. Optional when exactly one type is registered — a type-ahead should not have to name the only corpus there is. */
433
+ type?: string;
364
434
  };
365
435
  header?: never;
366
436
  path?: never;
@@ -3,8 +3,8 @@ import type {
3
3
  RankingResponse,
4
4
  SearchQueryState,
5
5
  SearchResponse,
6
+ SuggestAnswer,
6
7
  SuggestParams,
7
- SuggestResponse,
8
8
  } from "./types.js";
9
9
 
10
10
  /**
@@ -53,15 +53,22 @@ export interface SearchApi {
53
53
  ): Promise<SearchResponse>;
54
54
 
55
55
  /**
56
- * Title-prefix suggestions. Typed and callable, but this pair ships NO hook
57
- * and no autocomplete widget over it (spec §4.2 defers the debounce +
58
- * keyboard work). A host that wants one today calls this directly rather
59
- * than waiting for a version of the pair that has it.
56
+ * What to offer under the search box: CATEGORIES first, then title-prefix
57
+ * terms (stapel-search 0.7.0).
58
+ *
59
+ * Neither half comes from a query log the module keeps none, which is a
60
+ * privacy decision before it is a product one — so every suggestion is a
61
+ * destination or a search that has results.
62
+ *
63
+ * Typed as {@link SuggestAnswer} rather than as the generated
64
+ * `SuggestResponse`: a build regenerated against a pre-0.7.0 schema would
65
+ * otherwise hide the categories half from the compiler. See that type for
66
+ * why every member but `backend` is optional.
60
67
  */
61
68
  suggest(
62
69
  params: SuggestParams,
63
70
  options?: { readonly signal?: AbortSignal }
64
- ): Promise<SuggestResponse>;
71
+ ): Promise<SuggestAnswer>;
65
72
 
66
73
  /**
67
74
  * The P2B Art. 5 ranking disclosure for a doc type: which parameters rank
package/src/api/types.ts CHANGED
@@ -25,9 +25,123 @@ export type SearchItem = Schemas["SearchItem"];
25
25
  * `counted`, `skipped`. Rendered, never swallowed (spec §4.2). */
26
26
  export type FacetMeta = Schemas["FacetMeta"];
27
27
 
28
- /** `GET /suggest` 200. */
28
+ /** `GET /suggest` 200, as the CURRENT generated schema describes it. */
29
29
  export type SuggestResponse = Schemas["SuggestResponse"];
30
30
 
31
+ /**
32
+ * How a category's name matched the typed prefix. Informational — the server
33
+ * ranks by `count`, never by this.
34
+ */
35
+ export type SuggestCategoryMatch = "prefix" | "substring";
36
+
37
+ /**
38
+ * One CATEGORY the type-ahead offers: a destination, not a search term.
39
+ *
40
+ * A classified's search box is a navigation control before it is a text
41
+ * filter. "Shorts" is not one destination but three — men's, women's,
42
+ * children's — and the only things that let a buyer pick between them are the
43
+ * ancestor path and how many live listings sit behind each. Both are here,
44
+ * and neither can be computed on the client: the count is one aggregate over
45
+ * the index, and a client-side matcher over a fetched tree would have the
46
+ * names and no numbers.
47
+ */
48
+ export interface SuggestCategory {
49
+ readonly id: number;
50
+ readonly slug: string;
51
+ /** The category's own display name. */
52
+ readonly name: string;
53
+ /**
54
+ * Display names root→leaf, e.g. `["Menswear", "Shorts"]` — this is what
55
+ * distinguishes three categories sharing a name, and it is what a row has
56
+ * to print.
57
+ */
58
+ readonly path: readonly string[];
59
+ /**
60
+ * The ancestry as ids joined with `/`, ready to pass VERBATIM as the
61
+ * `category` parameter of `/query`.
62
+ *
63
+ * The server serves the joined string rather than only the segments
64
+ * precisely so that a client cannot invent a different join and silently
65
+ * miss — so nothing in this pair rebuilds it from {@link path} or
66
+ * {@link slug}.
67
+ */
68
+ readonly category: string;
69
+ /**
70
+ * Live listings a buyer would see under this category, descendants
71
+ * included — the same number the SERP reports for it.
72
+ */
73
+ readonly count: number;
74
+ /** Number of segments in {@link path}. */
75
+ readonly depth: number;
76
+ readonly match: SuggestCategoryMatch;
77
+ }
78
+
79
+ /**
80
+ * The `/suggest` answer this pair actually reads.
81
+ *
82
+ * GENERATOR NOTE, and the reason this is not `Schemas["SuggestResponse"]`:
83
+ * the generated type describes ONE server. stapel-search 0.7.0 made the answer
84
+ * three-part — `categories`, `terms`, and `items` as a deprecated alias of
85
+ * `terms` — plus `language` and `degraded`, and declares all five REQUIRED,
86
+ * which is true of a 0.7.0 answer and false of every answer sent by the
87
+ * servers a storefront is also deployed against.
88
+ *
89
+ * Every member but `backend` is therefore OPTIONAL here, which is not
90
+ * sloppiness but the deployment story: a storefront ships against whichever
91
+ * server is actually running, an older one sends no `categories` key at all,
92
+ * and "the key is absent" has to read as "this server offers no destinations"
93
+ * rather than as a crash or as an empty group under a heading. A pair typed
94
+ * against the required-field version would compile while reading `undefined`
95
+ * from a field the compiler swore was there.
96
+ */
97
+ export interface SuggestAnswer {
98
+ readonly backend: string;
99
+ /** Destinations, ranked by live listing count desc, then depth, then name. */
100
+ readonly categories?: readonly SuggestCategory[];
101
+ /** Title prefixes from the index. */
102
+ readonly terms?: readonly string[];
103
+ /** Deprecated alias of {@link terms}, and the only half a pre-0.7.0 server
104
+ * sends. Read through {@link suggestTerms}, never directly. */
105
+ readonly items?: readonly string[];
106
+ /** Which dictionary answered — the same resolution `/query` reports. */
107
+ readonly language?: string;
108
+ /** What this answer could not do — see {@link SUGGEST_DEGRADED_CATEGORIES}
109
+ * and {@link SUGGEST_DEGRADED_ROLLUP}. */
110
+ readonly degraded?: readonly string[];
111
+ }
112
+
113
+ /**
114
+ * The suggest answer had NO category provider, so the categories half is
115
+ * empty for a reason that is not "nothing matched".
116
+ *
117
+ * A dropdown has no room for a sentence about a provider being down, and the
118
+ * person reading it is mid-word. So the group is ABSENT rather than empty —
119
+ * an empty group under a heading is the box claiming the catalogue has no
120
+ * section by that name, which is a different and untrue statement.
121
+ */
122
+ export const SUGGEST_DEGRADED_CATEGORIES = "category_suggestions";
123
+
124
+ /**
125
+ * Ancestry never arrived, so every stored path is one segment long and every
126
+ * count would read `0`.
127
+ *
128
+ * The rows are still destinations and still worth offering; their COUNTS are
129
+ * the part that is not an answer, so a surface drops the number rather than
130
+ * printing a catalogue of zeros.
131
+ */
132
+ export const SUGGEST_DEGRADED_ROLLUP = "category_rollup";
133
+
134
+ /**
135
+ * The term half of a suggest answer, from whichever key this server sends.
136
+ *
137
+ * `terms` is 0.7.0's name and `items` is the deprecated alias kept for one
138
+ * minor; a client that read only one of them would go blank against half the
139
+ * servers in the fleet.
140
+ */
141
+ export function suggestTerms(answer: SuggestAnswer | undefined): readonly string[] {
142
+ return answer?.terms ?? answer?.items ?? [];
143
+ }
144
+
31
145
  /** `GET /ranking` 200 — the P2B Art. 5 disclosure. */
32
146
  export type RankingResponse = Schemas["RankingResponse"];
33
147
 
@@ -12,27 +12,34 @@
12
12
  *
13
13
  * ── Two deliberate ways NOT to shout ───────────────────────────────────────
14
14
  *
15
- * 1. **`exact_total` alone never raises a banner.** It is a count NUANCE, not
16
- * a failed search: the rows are right, and the single consequence — that
17
- * the total is a floor is already spoken by the count as "N+". A warning
18
- * box over a perfectly good result page teaches the reader that the page
19
- * is broken, and a banner that cries wolf on every landing page is a
20
- * banner nobody reads on the day `category_rollup` appears in it. Beside
21
- * any other degradation it renders normally, because the list is then
22
- * describing an answer that really is degraded. See
23
- * {@link isCountNuanceOnly}.
15
+ * 1. **A degradation addressed to the OPERATOR never reaches the reader.**
16
+ * `typo_tolerance` and `phrase_synonyms` both say, in the shipped ru copy,
17
+ * "the search engine in use cannot do this" a sentence about which
18
+ * engine somebody licensed, printed at a person trying to buy a phone. It
19
+ * is the same sentence on every query forever, which is what makes it
20
+ * invisible by the day `category_rollup` shows up in the same box.
21
+ * `exact_total` joins them: the count already says "N+".
22
+ *
23
+ * Measured live on a classified board: a full-screen yellow "synonyms
24
+ * were not substituted" between the sort control and the first card, on every
25
+ * query, for every buyer. Note what the fix is NOT — the string was not
26
+ * deleted and the kind was not special-cased. `readerFacing` names the
27
+ * *audience*, so the next engine-capability literal is filtered by the
28
+ * same rule instead of growing its own copy of this comment.
24
29
  * 2. **`variant`.** A catalogue page wants the banner; a landing page that
25
30
  * shows six cards under a hero has no room for a warning box and passes
26
- * `"inline"` (one quiet line) or `"off"`. `"off"` is a decision the
27
- * CONTAINER makes about ITS surface the notice stays the default, and
28
- * nothing here silences a degradation for everyone.
31
+ * `"inline"` (one quiet line) or `"off"`. `"debug"` is the operator's
32
+ * view: everything, unfiltered, for a status page or a support tool.
33
+ * `"off"` is a decision the CONTAINER makes about ITS surface — the
34
+ * notice stays the default, and nothing here silences a reader-facing
35
+ * degradation for everyone.
29
36
  */
30
37
  import type { ReactElement } from "react";
31
38
  import { Alert, Flex, Typography } from "antd";
32
39
  import { fontSize, spacing } from "@stapel/tokens";
33
40
  import { useT } from "@stapel/core";
34
41
  import type { SearchDegradation } from "../api/types.js";
35
- import { isCountNuanceOnly } from "../state/degradations.js";
42
+ import { readerFacing } from "../state/degradations.js";
36
43
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
37
44
 
38
45
  /**
@@ -40,10 +47,12 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
40
47
  *
41
48
  * - `"banner"` (default) — an antd warning `Alert` with one line per item.
42
49
  * - `"inline"` — the same sentences as quiet secondary text, no box.
50
+ * - `"debug"` — every degradation including the operator's, for a status page
51
+ * or a support tool. The one variant that does not filter by audience.
43
52
  * - `"off"` — nothing. For a surface where the notice does not belong at all;
44
53
  * the container that turns it off owns saying so somewhere else.
45
54
  */
46
- export type DegradationNoticeVariant = "banner" | "inline" | "off";
55
+ export type DegradationNoticeVariant = "banner" | "inline" | "debug" | "off";
47
56
 
48
57
  export interface DegradationNoticeProps {
49
58
  readonly degradations: readonly SearchDegradation[];
@@ -63,9 +72,10 @@ export function DegradationNotice(
63
72
  const t = useT();
64
73
  const variant: DegradationNoticeVariant = props.variant ?? "banner";
65
74
  if (variant === "off") return null;
66
- if (props.degradations.length === 0) return null;
67
- // A count nuance is not a degraded search — the count already says "N+".
68
- if (isCountNuanceOnly(props.degradations)) return null;
75
+ // Everything, or only what this page's reader can act on.
76
+ const degradations =
77
+ variant === "debug" ? props.degradations : readerFacing(props.degradations);
78
+ if (degradations.length === 0) return null;
69
79
 
70
80
  const say = (degradation: SearchDegradation): string => {
71
81
  const slug = degradation.scorer;
@@ -82,7 +92,7 @@ export function DegradationNotice(
82
92
  // theme's warning tint, and grey body text on it measured under 3:1 in the
83
93
  // visual pass. Inside a coloured box the readable colour is the box's own
84
94
  // text colour, which is what a plain `<Typography.Text>` inherits.
85
- const lines = props.degradations.map((degradation) => (
95
+ const lines = degradations.map((degradation) => (
86
96
  <li key={degradation.raw} data-degradation={degradation.raw}>
87
97
  <Typography.Text>{say(degradation)}</Typography.Text>
88
98
  </li>
@@ -95,7 +105,7 @@ export function DegradationNotice(
95
105
  {t(SEARCH_I18N_KEYS.degradedTitle)}
96
106
  </Typography.Text>
97
107
  <ul style={{ margin: 0, paddingInlineStart: spacing[5], fontSize: fontSize.xs.fontSize }}>
98
- {props.degradations.map((degradation) => (
108
+ {degradations.map((degradation) => (
99
109
  <li key={degradation.raw} data-degradation={degradation.raw}>
100
110
  <Typography.Text type="secondary">{say(degradation)}</Typography.Text>
101
111
  </li>
@@ -110,7 +120,7 @@ export function DegradationNotice(
110
120
  type="warning"
111
121
  showIcon
112
122
  data-testid="search-degraded"
113
- data-variant="banner"
123
+ data-variant={variant === "debug" ? "debug" : "banner"}
114
124
  title={t(SEARCH_I18N_KEYS.degradedTitle)}
115
125
  description={
116
126
  <ul style={{ margin: 0, paddingInlineStart: spacing[5] }}>{lines}</ul>
@@ -54,6 +54,7 @@ import { featureName } from "@stapel/attributes-react";
54
54
  import type { FeatureDef } from "@stapel/attributes-react";
55
55
  import type { SearchGeo } from "../api/types.js";
56
56
  import { FacetPanel } from "../headless/FacetPanel.js";
57
+ import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
57
58
  import { useSearchState } from "../headless/SearchStateProvider.js";
58
59
  import { FacetGroupControl } from "./FacetGroupControl.js";
59
60
  import { buildRangeGroups } from "../state/ranges.js";
@@ -78,11 +79,21 @@ export interface GeoFilterSlotProps {
78
79
  }
79
80
 
80
81
  export interface FacetPanelPaneProps extends ThemeModeProp {
81
- /** The category's feature schema — the source of option LABELS and of which
82
- * slugs get a numeric range row. */
82
+ /** The category's feature schema — the source of option LABELS, of which
83
+ * slugs get a numeric range row, and of which slugs are a filter at all
84
+ * (`isFacetableFeature`: an `imei` is counted and is not one). */
83
85
  readonly categoryFeatures?: readonly FeatureDef[];
84
86
  readonly locale?: string;
85
87
  readonly enabled?: boolean;
88
+ /**
89
+ * Name the values neither the answer nor the schema names — see
90
+ * {@link FacetLabelResolver}. A `ref_select` facet carries only a pointer to
91
+ * a vocabulary in its config, and the vocabulary is the host's to read.
92
+ *
93
+ * The same prop reaches `<FilterChips>` from `<SearchPage>`, so the panel
94
+ * and the chip row cannot print two different words for one value.
95
+ */
96
+ readonly resolveFacetLabels?: FacetLabelResolver;
86
97
  /** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
87
98
  * to a path). Unfilled, an active category still gets a "clear" control. */
88
99
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
@@ -306,13 +317,6 @@ function skippedNames(
306
317
  export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
307
318
  const t = useT();
308
319
  const { state } = useSearchState();
309
- const ranges = buildRangeGroups({
310
- state,
311
- ...(props.categoryFeatures !== undefined
312
- ? { categoryFeatures: props.categoryFeatures }
313
- : {}),
314
- t,
315
- });
316
320
 
317
321
  return (
318
322
  <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
@@ -322,8 +326,28 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
322
326
  : {})}
323
327
  {...(props.locale !== undefined ? { locale: props.locale } : {})}
324
328
  {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
329
+ {...(props.resolveFacetLabels !== undefined
330
+ ? { resolveFacetLabels: props.resolveFacetLabels }
331
+ : {})}
325
332
  >
326
- {(bag) => (
333
+ {(bag) => {
334
+ // Built INSIDE the bag, because which axes exist is a property of
335
+ // the ANSWER now: `facet_meta.core_ranges` names the core columns
336
+ // this server can actually filter on (`r.price`), and the corpus
337
+ // currency is read off the cards it just returned. Computed
338
+ // outside, the panel would have had to keep its own list of core
339
+ // slugs — which is how a board ends up offering a price filter
340
+ // against a server that answers zero for one.
341
+ const ranges = buildRangeGroups({
342
+ state,
343
+ ...(props.categoryFeatures !== undefined
344
+ ? { categoryFeatures: props.categoryFeatures }
345
+ : {}),
346
+ coreRanges: bag.coreRanges,
347
+ ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
348
+ t,
349
+ });
350
+ return (
327
351
  <Flex vertical gap={spacing[3]} data-testid="search-facets">
328
352
  <Flex justify="space-between" align="center" gap={spacing[2]}>
329
353
  {props.heading === null ? (
@@ -360,6 +384,25 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
360
384
  {...(props.geoLabel !== undefined ? { label: props.geoLabel } : {})}
361
385
  />
362
386
 
387
+ {/* Price first. Every numeric row below it is an ATTRIBUTE the
388
+ category happens to declare — on the phone board this was
389
+ measured against, all seven of them were parcel dimensions
390
+ and wholesale packing. The one number a buyer narrows by is
391
+ the price, and `buildRangeGroups` puts the core axes first. */}
392
+ {ranges.length > 0 && (
393
+ <Flex vertical gap={spacing[3]} data-testid="search-ranges">
394
+ {ranges.map((group) => (
395
+ <RangeFilterRow
396
+ key={group.slug}
397
+ group={group}
398
+ onApply={bag.setRange}
399
+ />
400
+ ))}
401
+ </Flex>
402
+ )}
403
+
404
+ {ranges.length > 0 && <Divider style={{ margin: 0 }} />}
405
+
363
406
  {/* Honesty flags, not failures: the counts ARE approximate and
364
407
  those slugs WERE skipped, and a red box would teach a person
365
408
  the page is broken. `ErrorAlert` is for a read that did not
@@ -383,20 +426,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
383
426
  />
384
427
  )}
385
428
 
386
- {ranges.length > 0 && (
387
- <Flex vertical gap={spacing[3]} data-testid="search-ranges">
388
- {ranges.map((group) => (
389
- <RangeFilterRow
390
- key={group.slug}
391
- group={group}
392
- onApply={bag.setRange}
393
- />
394
- ))}
395
- </Flex>
396
- )}
397
-
398
- {ranges.length > 0 && <Divider style={{ margin: 0 }} />}
399
-
400
429
  <LoadList
401
430
  state={bag.state}
402
431
  testId="facets"
@@ -440,7 +469,8 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
440
469
  )}
441
470
  </LoadList>
442
471
  </Flex>
443
- )}
472
+ );
473
+ }}
444
474
  </FacetPanel>
445
475
  </SkinTheme>
446
476
  );