@stapel/search-react 0.9.1 → 0.11.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 (94) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/api/generated/schema.d.ts +16 -0
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/DegradationNotice.d.ts +22 -13
  5. package/dist/default/DegradationNotice.d.ts.map +1 -1
  6. package/dist/default/DegradationNotice.js +7 -8
  7. package/dist/default/DegradationNotice.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +27 -16
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/FilterChips.d.ts.map +1 -1
  12. package/dist/default/FilterChips.js +13 -23
  13. package/dist/default/FilterChips.js.map +1 -1
  14. package/dist/default/LocationSummaryLine.d.ts +24 -0
  15. package/dist/default/LocationSummaryLine.d.ts.map +1 -0
  16. package/dist/default/LocationSummaryLine.js +83 -0
  17. package/dist/default/LocationSummaryLine.js.map +1 -0
  18. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  19. package/dist/default/RangeFilterRow.js +49 -5
  20. package/dist/default/RangeFilterRow.js.map +1 -1
  21. package/dist/default/SearchPage.d.ts +19 -0
  22. package/dist/default/SearchPage.d.ts.map +1 -1
  23. package/dist/default/SearchPage.js +6 -6
  24. package/dist/default/SearchPage.js.map +1 -1
  25. package/dist/default/geoSheet.d.ts +61 -0
  26. package/dist/default/geoSheet.d.ts.map +1 -0
  27. package/dist/default/geoSheet.js +63 -0
  28. package/dist/default/geoSheet.js.map +1 -0
  29. package/dist/default/index.d.ts +2 -0
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +1 -0
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/headless/FacetPanel.d.ts +16 -0
  34. package/dist/headless/FacetPanel.d.ts.map +1 -1
  35. package/dist/headless/FacetPanel.js +7 -0
  36. package/dist/headless/FacetPanel.js.map +1 -1
  37. package/dist/headless/useSearchCount.d.ts +44 -0
  38. package/dist/headless/useSearchCount.d.ts.map +1 -0
  39. package/dist/headless/useSearchCount.js +121 -0
  40. package/dist/headless/useSearchCount.js.map +1 -0
  41. package/dist/i18n/es.d.ts.map +1 -1
  42. package/dist/i18n/es.js +2 -0
  43. package/dist/i18n/es.js.map +1 -1
  44. package/dist/i18n/keys.d.ts +13 -0
  45. package/dist/i18n/keys.d.ts.map +1 -1
  46. package/dist/i18n/keys.js +15 -0
  47. package/dist/i18n/keys.js.map +1 -1
  48. package/dist/i18n/ru.d.ts.map +1 -1
  49. package/dist/i18n/ru.js +2 -0
  50. package/dist/i18n/ru.js.map +1 -1
  51. package/dist/index.d.ts +6 -4
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +4 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/state/degradations.d.ts +32 -0
  56. package/dist/state/degradations.d.ts.map +1 -1
  57. package/dist/state/degradations.js +33 -0
  58. package/dist/state/degradations.js.map +1 -1
  59. package/dist/state/facets.d.ts +23 -0
  60. package/dist/state/facets.d.ts.map +1 -1
  61. package/dist/state/facets.js +59 -8
  62. package/dist/state/facets.js.map +1 -1
  63. package/dist/state/limits.d.ts +21 -0
  64. package/dist/state/limits.d.ts.map +1 -1
  65. package/dist/state/limits.js +21 -0
  66. package/dist/state/limits.js.map +1 -1
  67. package/dist/state/ranges.d.ts +45 -3
  68. package/dist/state/ranges.d.ts.map +1 -1
  69. package/dist/state/ranges.js +55 -14
  70. package/dist/state/ranges.js.map +1 -1
  71. package/llms.txt +3 -2
  72. package/manifest.json +36 -3
  73. package/nav-manifest.json +1 -1
  74. package/package.json +6 -6
  75. package/src/analytics/generated/events.json +1 -1
  76. package/src/api/generated/schema.ts +16 -0
  77. package/src/default/DegradationNotice.tsx +30 -20
  78. package/src/default/FacetPanelPane.tsx +39 -23
  79. package/src/default/FilterChips.tsx +22 -56
  80. package/src/default/LocationSummaryLine.tsx +187 -0
  81. package/src/default/RangeFilterRow.tsx +61 -3
  82. package/src/default/SearchPage.tsx +33 -3
  83. package/src/default/geoSheet.tsx +161 -0
  84. package/src/default/index.ts +3 -0
  85. package/src/headless/FacetPanel.tsx +24 -0
  86. package/src/headless/useSearchCount.ts +189 -0
  87. package/src/i18n/es.ts +2 -0
  88. package/src/i18n/keys.ts +15 -0
  89. package/src/i18n/ru.ts +2 -0
  90. package/src/index.ts +15 -1
  91. package/src/state/degradations.ts +56 -0
  92. package/src/state/facets.ts +96 -8
  93. package/src/state/limits.ts +23 -0
  94. package/src/state/ranges.ts +85 -15
@@ -8,12 +8,20 @@
8
8
  * they would have if you swapped to them. A panel that greys the siblings
9
9
  * out has silently converted a drill-down facet into a naive one, and the
10
10
  * e2e leg in the spec (§7.2) exists to catch exactly that.
11
- * 2. **The server does not send option LABELS** (`facets.py` returns
12
- * `{value: count}` and nothing else). The labels are in the category's
13
- * feature schema, which is why {@link buildFacetGroups} takes
14
- * `categoryFeatures` the second slot-seam of the pair, filled by the
15
- * container from `categories-react` (spec §6.2 item 2). Without it the
16
- * panel still works and shows raw values; it does not invent labels.
11
+ * 2. **Option LABELS come from two places, in that order.** The category's
12
+ * feature schema `categoryFeatures`, the second slot-seam of the pair,
13
+ * filled by the container from `categories-react` (spec §6.2 item 2) —
14
+ * and, under it, the answer's own `facet_labels` (stapel-search 0.4.0+).
15
+ *
16
+ * Until 0.4.0 the server sent `{value: count}` and nothing else, on the
17
+ * reasoning that the container has the schema anyway. That reasoning had
18
+ * a hole the size of a marketplace: the slot is OPTIONAL, a live
19
+ * classified board never filled it, and its buyers read "Condition:
20
+ * **b-u**", "Listing kind: **prodayu-svoe**", "Screen condition:
21
+ * **bez-defektov**" on the SERP and in the filter chips. The schema still
22
+ * wins where it resolves — the client fetched it with its own
23
+ * `Accept-Language` — and the answer's captions are the floor beneath it.
24
+ * Neither invents a label: a value nobody names renders as itself.
17
25
  *
18
26
  * A slug the server SKIPPED (`facet_meta.skipped`, dropped at
19
27
  * `MAX_FACET_FIELDS`) is not counted at all. Its options carry `count: null`,
@@ -59,6 +67,31 @@ export interface BuildFacetGroupsInput {
59
67
  readonly state: SearchQueryState;
60
68
  /** The category's feature schema, for labels and option order. */
61
69
  readonly categoryFeatures?: readonly FeatureDef[];
70
+ /**
71
+ * The envelope's `facet_labels` (stapel-search 0.4.0+):
72
+ * `{slug: {translatable, values: {value: caption}}}`.
73
+ *
74
+ * The FLOOR under `categoryFeatures`, not a replacement for it. Both read
75
+ * the same category config, but they read it differently: the client
76
+ * fetches `GET /categories/{id}/features/` with its own `Accept-Language`,
77
+ * so a host that threaded the schema through has the better-localized
78
+ * copy and keeps it. What the answer's captions fix is the case where
79
+ * there is no other copy at all — `categoryFeatures` is an OPTIONAL slot,
80
+ * a live classified board never filled it, and its buyers read
81
+ * "Condition: **b-u**" and "Listing kind: **prodayu-svoe**" on the SERP and in the
82
+ * filter chips. A caption that arrives with the counts cannot be
83
+ * forgotten by a host.
84
+ *
85
+ * A slug neither side captions (a vocabulary-backed one, whose level lives
86
+ * outside the category schema) falls through to the raw value. No labels
87
+ * are invented at any step.
88
+ */
89
+ readonly facetLabels?: Readonly<
90
+ Record<
91
+ string,
92
+ { readonly translatable: boolean; readonly values: Readonly<Record<string, string>> }
93
+ >
94
+ >;
62
95
  /** Translator for label keys. */
63
96
  readonly t?: (key: string) => string;
64
97
  /** BCP-47 tag, forwarded to `formatFeatureValue` for `date` options. */
@@ -71,6 +104,26 @@ function translate(t: ((key: string) => string) | undefined, key: string): strin
71
104
  return resolved.length > 0 ? resolved : key;
72
105
  }
73
106
 
107
+ /**
108
+ * The caption the ANSWER carries for one option, or `undefined`.
109
+ *
110
+ * `translatable` is the server saying whether `values` holds translation
111
+ * KEYS or literal captions, and it has to be said rather than sniffed:
112
+ * `b.apple` and a rendered caption are both strings, and guessing wrong prints either a
113
+ * dotted key or an untranslated word at a buyer.
114
+ */
115
+ function serverLabel(
116
+ labels: BuildFacetGroupsInput["facetLabels"],
117
+ slug: string,
118
+ value: string,
119
+ t: ((key: string) => string) | undefined
120
+ ): string | undefined {
121
+ const entry = labels?.[slug];
122
+ const caption = entry?.values[value];
123
+ if (caption === undefined || caption.length === 0) return undefined;
124
+ return entry?.translatable === true ? translate(t, caption) : caption;
125
+ }
126
+
74
127
  /**
75
128
  * The declared option ORDER for a closed set, if the schema declares one.
76
129
  *
@@ -95,6 +148,28 @@ function declaredOptionValues(feature: FeatureDef | undefined): readonly string[
95
148
  return out;
96
149
  }
97
150
 
151
+ /**
152
+ * One option's caption: schema, then answer, then the raw value.
153
+ *
154
+ * Order matters and is deliberate. Both sources are the same category
155
+ * config, but the client fetched its copy with its own `Accept-Language`, so
156
+ * where the host threaded a schema through and it names the value, that is
157
+ * the better-localized answer. Where it does not — no schema, a slug the
158
+ * schema omits, a value added since — the server's caption is what stops
159
+ * `b-u` reaching a buyer.
160
+ */
161
+ function resolveLabel(
162
+ input: BuildFacetGroupsInput,
163
+ feature: FeatureDef | undefined,
164
+ slug: string,
165
+ value: string,
166
+ labelOptions: { t?: (key: string) => string; locale?: string }
167
+ ): string {
168
+ const viaSchema = facetOptionLabel(feature, value, labelOptions);
169
+ if (feature !== undefined && viaSchema !== value) return viaSchema;
170
+ return serverLabel(input.facetLabels, slug, value, input.t) ?? viaSchema;
171
+ }
172
+
98
173
  /**
99
174
  * A facet value as a person reads it, through
100
175
  * `@stapel/attributes-react`'s `formatFeatureValue` — the same formatter the
@@ -180,7 +255,15 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
180
255
  const counted = !skipped.has(slug) && slug in input.facets;
181
256
  const selected = input.state.filters[slug] ?? [];
182
257
 
183
- const declared = declaredOptionValues(feature);
258
+ // The authored option order, from whichever copy of the category config
259
+ // this page has. An authored list reshuffled by count is a size chart
260
+ // that moves on every click, and until 0.11 a host that passed no schema
261
+ // got exactly that.
262
+ const fromSchema = declaredOptionValues(feature);
263
+ const declared =
264
+ fromSchema.length > 0
265
+ ? fromSchema
266
+ : Object.keys(input.facetLabels?.[slug]?.values ?? {});
184
267
  const values: string[] = [];
185
268
  const push = (value: string): void => {
186
269
  if (!values.includes(value)) values.push(value);
@@ -211,7 +294,12 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
211
294
  options: values.map((value) => ({
212
295
  value,
213
296
  count: counted ? (counts[value] ?? 0) : null,
214
- label: facetOptionLabel(feature, value, labelOptions),
297
+ // The host's schema first when it actually resolves the value, the
298
+ // answer's caption when it does not (or when there is no schema at
299
+ // all), the raw value when neither knows. `facetOptionLabel` returns
300
+ // the value unchanged for an option it cannot name, which is what
301
+ // makes "did it resolve?" answerable without a second lookup.
302
+ label: resolveLabel(input, feature, slug, value, labelOptions),
215
303
  selected: selected.includes(value),
216
304
  })),
217
305
  };
@@ -27,6 +27,29 @@ export const SEARCH_MAX_PAGE_SIZE = 100;
27
27
  /** `services.suggest` clamps `limit` into `1..25`. */
28
28
  export const SUGGEST_MAX_LIMIT = 25;
29
29
 
30
+ /**
31
+ * The page a COUNT read asks for.
32
+ *
33
+ * There is no count-only endpoint (`SearchApi` is query/suggest/ranking), so
34
+ * `useSearchCount` rides the ordinary query and takes the total out of the
35
+ * envelope. One row is the smallest page the endpoint documents — `limit=0` is
36
+ * not a page size — and it is the difference between serializing one card
37
+ * nobody renders and serializing the default twenty-four.
38
+ */
39
+ export const SEARCH_COUNT_PAGE_SIZE = 1;
40
+
41
+ /**
42
+ * How long a composing panel has to stop changing before its count is asked
43
+ * for.
44
+ *
45
+ * Shorter than the search commit (350ms): a count follows discrete choices — a
46
+ * select, a chip, a range end — rather than keystrokes, and a button whose
47
+ * number arrives a third of a second after the choice reads as broken. Longer
48
+ * than zero: the read is a full query (see `SEARCH_COUNT_PAGE_SIZE`), so a
49
+ * person walking a select's options must not spend one search per option.
50
+ */
51
+ export const SEARCH_COUNT_DEBOUNCE_MS = 250;
52
+
30
53
  /**
31
54
  * The shortest prefix worth asking the index about.
32
55
  *
@@ -6,13 +6,23 @@
6
6
  * the panel only knew how to draw checkboxes. This module is the missing half:
7
7
  * which slugs a range row exists for, and what the row is called.
8
8
  *
9
- * ── Where a range row comes from, and why it is not the response ───────────
9
+ * ── Where a range row comes from ──────────────────────────────────────────
10
10
  *
11
- * A facet answer (`facets: {slug: {value: count}}`) enumerates DISCRETE values;
12
- * a range is not enumerable and the server never sends one. So the rows come
13
- * from the CATEGORY SCHEMA the same `categoryFeatures` slot that gives the
14
- * checkboxes their labels — filtered to the numeric value types, plus every
15
- * slug the URL already carries a range for.
11
+ * A facet answer (`facets: {slug: {value: count}}`) enumerates DISCRETE
12
+ * values; a range is not enumerable and no bucket is ever sent for one. So
13
+ * the rows come from two places:
14
+ *
15
+ * - the CATEGORY SCHEMA — the same `categoryFeatures` slot that gives the
16
+ * checkboxes their labels — filtered to the numeric value types;
17
+ * - the ANSWER's `facet_meta.core_ranges` (stapel-search 0.4.0+), which
18
+ * names the range slugs addressing a COLUMN of the document rather than
19
+ * an attribute. `price` is the shipped one, and it is why this module
20
+ * exists at all: a live classified board offered seven numeric ranges,
21
+ * every one of them a shipping or wholesale input, and no price — because
22
+ * price is not a category feature anywhere, and a row was only ever drawn
23
+ * for a feature.
24
+ *
25
+ * Plus every slug the URL already carries a range for.
16
26
  *
17
27
  * That last clause is the same rule `buildFacetGroups` follows for a filtered
18
28
  * slug that fell out of the plan: a constraint that is ACTIVE must always have
@@ -59,11 +69,50 @@ export interface RangeGroup {
59
69
  readonly step: number | undefined;
60
70
  /** Whether the URL currently constrains this slug. */
61
71
  readonly active: boolean;
72
+ /**
73
+ * `true` when the axis is a CORE document column the server declared in
74
+ * `facet_meta.core_ranges` rather than a category feature. Price is the
75
+ * shipped one. A core axis is drawn first and is never absent because a
76
+ * category forgot to declare an attribute for it.
77
+ */
78
+ readonly core: boolean;
79
+ /**
80
+ * ISO 4217 code when the row is money, so the control can read as money
81
+ * instead of as a bare integer. Only ever set on a core axis: an attribute
82
+ * carries a `postfix`, not a currency.
83
+ */
84
+ readonly currency: string | undefined;
85
+ }
86
+
87
+ /**
88
+ * i18n key for a core axis's own name.
89
+ *
90
+ * A core axis has no FeatureDef and therefore no `name` to translate. The
91
+ * label is this package's, because the axis is this package's.
92
+ */
93
+ export function coreRangeLabelKey(slug: string): string {
94
+ return `search.range.${slug}`;
62
95
  }
63
96
 
64
97
  export interface BuildRangeGroupsInput {
65
98
  readonly state: SearchQueryState;
66
99
  readonly categoryFeatures?: readonly FeatureDef[];
100
+ /**
101
+ * `facet_meta.core_ranges` — range slugs that address a column of the
102
+ * document rather than an attribute (stapel-search 0.4.0+).
103
+ *
104
+ * It comes from the ANSWER on purpose. A live classified board offered
105
+ * seven numeric ranges — parcel weight, length, height, width, packing
106
+ * quantity, minimum-order quantity, battery condition — and no price,
107
+ * because a row was only ever drawn for a category feature and price is a
108
+ * column of the listing. Hardcoding `"price"` here would have fixed that
109
+ * board and broken the next one, where `r.price` still answers zero
110
+ * because the server predates the axis. So the server says which axes it
111
+ * can actually serve, and this list is empty against an older one.
112
+ */
113
+ readonly coreRanges?: readonly string[];
114
+ /** ISO 4217 code for the money axes, when the surface knows one. */
115
+ readonly currency?: string;
67
116
  /** Translator for label keys (the schema's `name` is often one). */
68
117
  readonly t?: (key: string) => string;
69
118
  }
@@ -89,9 +138,15 @@ export function isRangeFeature(feature: FeatureDef): boolean {
89
138
  }
90
139
 
91
140
  /**
92
- * The range rows for the current search: every numeric feature of the
93
- * category, in the schema's own order, plus any slug the URL constrains that
94
- * the schema does not explain.
141
+ * The range rows for the current search, in the order they should be read:
142
+ * the CORE axes the answer declares, then every numeric feature of the
143
+ * category in the schema's own order, then any slug the URL constrains that
144
+ * neither explains.
145
+ *
146
+ * Core first is not cosmetic. On the board this was measured against, the
147
+ * seven numeric attributes a phone category happens to declare are all
148
+ * shipping and wholesale inputs; the one number a phone buyer narrows by is
149
+ * the price, and it belongs above them.
95
150
  */
96
151
  export function buildRangeGroups(
97
152
  input: BuildRangeGroupsInput
@@ -99,30 +154,45 @@ export function buildRangeGroups(
99
154
  const bySlug = new Map<string, FeatureDef>();
100
155
  for (const feature of input.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
101
156
 
102
- const slugs: string[] = [];
157
+ const core = new Set(input.coreRanges ?? []);
158
+ const slugs: string[] = [...core];
103
159
  for (const feature of input.categoryFeatures ?? []) {
104
- if (isRangeFeature(feature)) slugs.push(feature.slug);
160
+ // A core slug shadows a same-named attribute — which is exactly what the
161
+ // server does with it (`index_schema.CORE_RANGE_FIELDS` reserves the
162
+ // slug), so drawing both would put two controls over one filter.
163
+ if (isRangeFeature(feature) && !core.has(feature.slug)) slugs.push(feature.slug);
105
164
  }
106
165
  for (const slug of Object.keys(input.state.ranges)) {
107
166
  if (!slugs.includes(slug)) slugs.push(slug);
108
167
  }
109
168
 
110
169
  return slugs.map((slug) => {
111
- const feature = bySlug.get(slug);
170
+ const isCore = core.has(slug);
171
+ const feature = isCore ? undefined : bySlug.get(slug);
112
172
  const config = feature === undefined ? {} : featureConfig(feature);
113
173
  const applied: SearchRange | undefined = input.state.ranges[slug];
114
174
  return {
115
175
  slug,
116
- label:
117
- feature === undefined ? slug : translate(input.t, featureName(feature)),
176
+ label: isCore
177
+ ? translate(input.t, coreRangeLabelKey(slug))
178
+ : feature === undefined
179
+ ? slug
180
+ : translate(input.t, featureName(feature)),
118
181
  feature,
119
182
  from: applied?.from,
120
183
  to: applied?.to,
121
184
  min: num(config["min"]),
122
185
  max: num(config["max"]),
123
- unit: str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"]),
186
+ // A core money axis carries a CURRENCY, not a unit suffix: "" is
187
+ // formatted from the code for the reader's locale, a unit suffix is a literal
188
+ // the category author typed.
189
+ unit: isCore
190
+ ? undefined
191
+ : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
124
192
  step: feature !== undefined && featureType(feature) === "int" ? 1 : undefined,
125
193
  active: applied !== undefined,
194
+ core: isCore,
195
+ currency: isCore ? str(input.currency) : undefined,
126
196
  };
127
197
  });
128
198
  }