@stapel/search-react 0.10.0 → 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 (64) hide show
  1. package/CHANGELOG.md +40 -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 +5 -0
  13. package/dist/default/FilterChips.js.map +1 -1
  14. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  15. package/dist/default/RangeFilterRow.js +49 -5
  16. package/dist/default/RangeFilterRow.js.map +1 -1
  17. package/dist/headless/FacetPanel.d.ts +16 -0
  18. package/dist/headless/FacetPanel.d.ts.map +1 -1
  19. package/dist/headless/FacetPanel.js +7 -0
  20. package/dist/headless/FacetPanel.js.map +1 -1
  21. package/dist/i18n/es.d.ts.map +1 -1
  22. package/dist/i18n/es.js +1 -0
  23. package/dist/i18n/es.js.map +1 -1
  24. package/dist/i18n/keys.d.ts +3 -0
  25. package/dist/i18n/keys.d.ts.map +1 -1
  26. package/dist/i18n/keys.js +4 -0
  27. package/dist/i18n/keys.js.map +1 -1
  28. package/dist/i18n/ru.d.ts.map +1 -1
  29. package/dist/i18n/ru.js +1 -0
  30. package/dist/i18n/ru.js.map +1 -1
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/state/degradations.d.ts +32 -0
  36. package/dist/state/degradations.d.ts.map +1 -1
  37. package/dist/state/degradations.js +33 -0
  38. package/dist/state/degradations.js.map +1 -1
  39. package/dist/state/facets.d.ts +23 -0
  40. package/dist/state/facets.d.ts.map +1 -1
  41. package/dist/state/facets.js +59 -8
  42. package/dist/state/facets.js.map +1 -1
  43. package/dist/state/ranges.d.ts +45 -3
  44. package/dist/state/ranges.d.ts.map +1 -1
  45. package/dist/state/ranges.js +55 -14
  46. package/dist/state/ranges.js.map +1 -1
  47. package/llms.txt +2 -2
  48. package/manifest.json +7 -2
  49. package/nav-manifest.json +1 -1
  50. package/package.json +4 -4
  51. package/src/analytics/generated/events.json +1 -1
  52. package/src/api/generated/schema.ts +16 -0
  53. package/src/default/DegradationNotice.tsx +30 -20
  54. package/src/default/FacetPanelPane.tsx +39 -23
  55. package/src/default/FilterChips.tsx +5 -0
  56. package/src/default/RangeFilterRow.tsx +61 -3
  57. package/src/headless/FacetPanel.tsx +24 -0
  58. package/src/i18n/es.ts +1 -0
  59. package/src/i18n/keys.ts +4 -0
  60. package/src/i18n/ru.ts +1 -0
  61. package/src/index.ts +7 -1
  62. package/src/state/degradations.ts +56 -0
  63. package/src/state/facets.ts +96 -8
  64. package/src/state/ranges.ts +85 -15
@@ -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
@@ -35,6 +45,15 @@ export const RANGE_FEATURE_TYPES = [
35
45
  "float",
36
46
  "convertible_unit",
37
47
  ];
48
+ /**
49
+ * i18n key for a core axis's own name.
50
+ *
51
+ * A core axis has no FeatureDef and therefore no `name` to translate. The
52
+ * label is this package's, because the axis is this package's.
53
+ */
54
+ export function coreRangeLabelKey(slug) {
55
+ return `search.range.${slug}`;
56
+ }
38
57
  function num(value) {
39
58
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
40
59
  }
@@ -53,17 +72,27 @@ export function isRangeFeature(feature) {
53
72
  return type !== undefined && RANGE_FEATURE_TYPES.includes(type);
54
73
  }
55
74
  /**
56
- * The range rows for the current search: every numeric feature of the
57
- * category, in the schema's own order, plus any slug the URL constrains that
58
- * the schema does not explain.
75
+ * The range rows for the current search, in the order they should be read:
76
+ * the CORE axes the answer declares, then every numeric feature of the
77
+ * category in the schema's own order, then any slug the URL constrains that
78
+ * neither explains.
79
+ *
80
+ * Core first is not cosmetic. On the board this was measured against, the
81
+ * seven numeric attributes a phone category happens to declare are all
82
+ * shipping and wholesale inputs; the one number a phone buyer narrows by is
83
+ * the price, and it belongs above them.
59
84
  */
60
85
  export function buildRangeGroups(input) {
61
86
  const bySlug = new Map();
62
87
  for (const feature of input.categoryFeatures ?? [])
63
88
  bySlug.set(feature.slug, feature);
64
- const slugs = [];
89
+ const core = new Set(input.coreRanges ?? []);
90
+ const slugs = [...core];
65
91
  for (const feature of input.categoryFeatures ?? []) {
66
- if (isRangeFeature(feature))
92
+ // A core slug shadows a same-named attribute — which is exactly what the
93
+ // server does with it (`index_schema.CORE_RANGE_FIELDS` reserves the
94
+ // slug), so drawing both would put two controls over one filter.
95
+ if (isRangeFeature(feature) && !core.has(feature.slug))
67
96
  slugs.push(feature.slug);
68
97
  }
69
98
  for (const slug of Object.keys(input.state.ranges)) {
@@ -71,20 +100,32 @@ export function buildRangeGroups(input) {
71
100
  slugs.push(slug);
72
101
  }
73
102
  return slugs.map((slug) => {
74
- const feature = bySlug.get(slug);
103
+ const isCore = core.has(slug);
104
+ const feature = isCore ? undefined : bySlug.get(slug);
75
105
  const config = feature === undefined ? {} : featureConfig(feature);
76
106
  const applied = input.state.ranges[slug];
77
107
  return {
78
108
  slug,
79
- label: feature === undefined ? slug : translate(input.t, featureName(feature)),
109
+ label: isCore
110
+ ? translate(input.t, coreRangeLabelKey(slug))
111
+ : feature === undefined
112
+ ? slug
113
+ : translate(input.t, featureName(feature)),
80
114
  feature,
81
115
  from: applied?.from,
82
116
  to: applied?.to,
83
117
  min: num(config["min"]),
84
118
  max: num(config["max"]),
85
- unit: str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"]),
119
+ // A core money axis carries a CURRENCY, not a unit suffix: "" is
120
+ // formatted from the code for the reader's locale, a unit suffix is a literal
121
+ // the category author typed.
122
+ unit: isCore
123
+ ? undefined
124
+ : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
86
125
  step: feature !== undefined && featureType(feature) === "int" ? 1 : undefined,
87
126
  active: applied !== undefined,
127
+ core: isCore,
128
+ currency: isCore ? str(input.currency) : undefined,
88
129
  };
89
130
  });
90
131
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ranges.js","sourceRoot":"","sources":["../../src/state/ranges.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ranges.js","sourceRoot":"","sources":["../../src/state/ranges.ts"],"names":[],"mappings":"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"}
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
- # @stapel/search-react 0.10.0
1
+ # @stapel/search-react 0.11.0
2
2
 
3
- Headless React flow pair for stapel-search (contract >=0.3 <0.4) — business + state, zero visual opinion.
3
+ Headless React flow pair for stapel-search (contract >=0.4 <0.5) — business + state, zero visual opinion.
4
4
  Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
5
5
  verification-403 interception, i18n engine, analytics facade, TanStack Query layer.
6
6
 
package/manifest.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "backend": {
6
6
  "module": "stapel-search",
7
- "contract": ">=0.3 <0.4"
7
+ "contract": ">=0.4 <0.5"
8
8
  },
9
9
  "layers": [
10
10
  "api",
@@ -763,6 +763,7 @@
763
763
  "search.limit.from_link",
764
764
  "search.limit.label",
765
765
  "search.limit.option",
766
+ "search.range.price",
766
767
  "search.ranking.applies_to",
767
768
  "search.ranking.empty",
768
769
  "search.ranking.inactive",
@@ -848,11 +849,13 @@
848
849
  "buildFacetGroups",
849
850
  "buildRangeGroups",
850
851
  "clearFilters",
852
+ "coreRangeLabelKey",
851
853
  "countIsEstimate",
852
854
  "countKind",
853
855
  "countQueryState",
854
856
  "createSearchApi",
855
857
  "createSearchRuntime",
858
+ "degradationAudience",
856
859
  "degradationMessageKey",
857
860
  "explainSearchError",
858
861
  "facetOptionLabel",
@@ -864,6 +867,7 @@
864
867
  "parseDegradations",
865
868
  "parseSearchState",
866
869
  "patchSearchState",
870
+ "readerFacing",
867
871
  "registerSearchI18n",
868
872
  "searchErrorBundleEn",
869
873
  "searchI18nBundleEn",
@@ -908,6 +912,7 @@
908
912
  "SearchCountKind",
909
913
  "SearchCountState",
910
914
  "SearchDegradation",
915
+ "SearchDegradationAudience",
911
916
  "SearchDegradationKind",
912
917
  "SearchErrorCode",
913
918
  "SearchErrorSpec",
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/search-react",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "entries": [
5
5
  {
6
6
  "id": "search.results",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/search-react",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Headless React pair for stapel-search: a typed query client, TanStack Query hooks, and a URL-first state codec that makes a search shareable by construction (filters, ranges, geo, sort and the keyset cursor all live in the query string). Drill-down facets rendered with their remaining counts and with the server's own honesty flags — approximate, skipped, degraded — never swallowed; keyset pagination with the window refusal named; DSA Art. 26 `promoted` marking carried into every card slot and the P2B Art. 5 ranking disclosure exposed as data. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin, and /router binds the codec to react-router's useSearchParams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -116,11 +116,11 @@
116
116
  "typescript": "^5.8.3",
117
117
  "vitest": "^3.2.4",
118
118
  "@stapel/attributes-react": "^0.5.0",
119
- "@stapel/core": "^0.20.0",
119
+ "@stapel/core": "^0.21.0",
120
120
  "@stapel/image": "^0.4.2",
121
- "@stapel/showcase": "^0.3.0",
122
121
  "@stapel/tokens": "^0.6.0",
123
- "@stapel/tokens-antd": "^0.9.0"
122
+ "@stapel/tokens-antd": "^0.10.0",
123
+ "@stapel/showcase": "^0.3.0"
124
124
  },
125
125
  "engines": {
126
126
  "node": ">=22"
@@ -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.11.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -124,6 +124,14 @@ export interface paths {
124
124
  export type webhooks = Record<string, never>;
125
125
  export interface components {
126
126
  schemas: {
127
+ /** @description Captions for one slug's option codes. */
128
+ FacetLabels: {
129
+ /** @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. */
130
+ translatable: boolean;
131
+ values: {
132
+ [key: string]: string;
133
+ };
134
+ };
127
135
  FacetMeta: {
128
136
  /** @description True when counts came from a sample because the candidate set exceeded FACET_CANDIDATE_CAP. */
129
137
  approximate: boolean;
@@ -132,6 +140,8 @@ export interface components {
132
140
  counted: string[];
133
141
  /** @description Plan slugs dropped at MAX_FACET_FIELDS — reported, not vanished. */
134
142
  skipped: string[];
143
+ /** @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. */
144
+ core_ranges: string[];
135
145
  };
136
146
  HealthResponse: {
137
147
  backend: string;
@@ -208,6 +218,10 @@ export interface components {
208
218
  [key: string]: number;
209
219
  };
210
220
  };
221
+ /** @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. */
222
+ facet_labels: {
223
+ [key: string]: components["schemas"]["FacetLabels"];
224
+ };
211
225
  facet_meta: components["schemas"]["FacetMeta"];
212
226
  next_anchor: string | null;
213
227
  prev_anchor: string | null;
@@ -222,6 +236,8 @@ export interface components {
222
236
  /** @description What the configured engine could not do for this query. */
223
237
  degraded: string[];
224
238
  backend: string;
239
+ /** @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. */
240
+ language: string;
225
241
  sort: string;
226
242
  took_ms: number;
227
243
  };
@@ -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>
@@ -306,13 +306,6 @@ function skippedNames(
306
306
  export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
307
307
  const t = useT();
308
308
  const { state } = useSearchState();
309
- const ranges = buildRangeGroups({
310
- state,
311
- ...(props.categoryFeatures !== undefined
312
- ? { categoryFeatures: props.categoryFeatures }
313
- : {}),
314
- t,
315
- });
316
309
 
317
310
  return (
318
311
  <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
@@ -323,7 +316,24 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
323
316
  {...(props.locale !== undefined ? { locale: props.locale } : {})}
324
317
  {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
325
318
  >
326
- {(bag) => (
319
+ {(bag) => {
320
+ // Built INSIDE the bag, because which axes exist is a property of
321
+ // the ANSWER now: `facet_meta.core_ranges` names the core columns
322
+ // this server can actually filter on (`r.price`), and the corpus
323
+ // currency is read off the cards it just returned. Computed
324
+ // outside, the panel would have had to keep its own list of core
325
+ // slugs — which is how a board ends up offering a price filter
326
+ // against a server that answers zero for one.
327
+ const ranges = buildRangeGroups({
328
+ state,
329
+ ...(props.categoryFeatures !== undefined
330
+ ? { categoryFeatures: props.categoryFeatures }
331
+ : {}),
332
+ coreRanges: bag.coreRanges,
333
+ ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
334
+ t,
335
+ });
336
+ return (
327
337
  <Flex vertical gap={spacing[3]} data-testid="search-facets">
328
338
  <Flex justify="space-between" align="center" gap={spacing[2]}>
329
339
  {props.heading === null ? (
@@ -360,6 +370,25 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
360
370
  {...(props.geoLabel !== undefined ? { label: props.geoLabel } : {})}
361
371
  />
362
372
 
373
+ {/* Price first. Every numeric row below it is an ATTRIBUTE the
374
+ category happens to declare — on the phone board this was
375
+ measured against, all seven of them were parcel dimensions
376
+ and wholesale packing. The one number a buyer narrows by is
377
+ the price, and `buildRangeGroups` puts the core axes first. */}
378
+ {ranges.length > 0 && (
379
+ <Flex vertical gap={spacing[3]} data-testid="search-ranges">
380
+ {ranges.map((group) => (
381
+ <RangeFilterRow
382
+ key={group.slug}
383
+ group={group}
384
+ onApply={bag.setRange}
385
+ />
386
+ ))}
387
+ </Flex>
388
+ )}
389
+
390
+ {ranges.length > 0 && <Divider style={{ margin: 0 }} />}
391
+
363
392
  {/* Honesty flags, not failures: the counts ARE approximate and
364
393
  those slugs WERE skipped, and a red box would teach a person
365
394
  the page is broken. `ErrorAlert` is for a read that did not
@@ -383,20 +412,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
383
412
  />
384
413
  )}
385
414
 
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
415
  <LoadList
401
416
  state={bag.state}
402
417
  testId="facets"
@@ -440,7 +455,8 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
440
455
  )}
441
456
  </LoadList>
442
457
  </Flex>
443
- )}
458
+ );
459
+ }}
444
460
  </FacetPanel>
445
461
  </SkinTheme>
446
462
  );
@@ -153,11 +153,16 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
153
153
  const surface = useDialogSurface();
154
154
  const [open, setOpen] = useState<OpenChip>(null);
155
155
 
156
+ // Same source as the panel's rows, so the phone chip row and the desktop
157
+ // panel cannot disagree about which axes exist: the core columns come from
158
+ // the ANSWER (`facet_meta.core_ranges`) and the currency off its cards.
156
159
  const ranges = buildRangeGroups({
157
160
  state,
158
161
  ...(props.categoryFeatures !== undefined
159
162
  ? { categoryFeatures: props.categoryFeatures }
160
163
  : {}),
164
+ coreRanges: bag.coreRanges,
165
+ ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
161
166
  t,
162
167
  });
163
168
  const groups =
@@ -18,7 +18,7 @@
18
18
  import { useRef, useState } from "react";
19
19
  import type { ReactElement } from "react";
20
20
  import { Button, Flex, InputNumber, Typography } from "antd";
21
- import { actionAvailable, actionBlocked, useT } from "@stapel/core";
21
+ import { actionAvailable, actionBlocked, useFormat, useT } from "@stapel/core";
22
22
  import type { ActionAvailability } from "@stapel/core";
23
23
  import { GatedButton } from "@stapel/tokens-antd/skin";
24
24
  import { spacing } from "@stapel/tokens";
@@ -40,8 +40,39 @@ function toDraft(value: string | undefined): string {
40
40
  return value ?? "";
41
41
  }
42
42
 
43
+ /**
44
+ * The suffix a bound field carries: a currency SYMBOL for a money axis, the
45
+ * category's own unit string otherwise.
46
+ *
47
+ * `group.currency` is an ISO 4217 code and `RUB` beside an input is not what
48
+ * money looks like anywhere; `Intl` knows the symbol per locale, so the code
49
+ * goes in and «₽» comes out. An unsupported code falls back to the code
50
+ * itself — still better than nothing, and it never throws inside a render.
51
+ */
52
+ function boundSuffix(
53
+ format: ReturnType<typeof useFormat>,
54
+ group: RangeGroup
55
+ ): string | undefined {
56
+ if (group.currency === undefined) return group.unit;
57
+ if (!/^[A-Za-z]{3}$/.test(group.currency)) return group.currency;
58
+ try {
59
+ const sample = format.number(0, {
60
+ style: "currency",
61
+ currency: group.currency.toUpperCase(),
62
+ currencyDisplay: "narrowSymbol",
63
+ maximumFractionDigits: 0,
64
+ });
65
+ if (sample === null) return group.currency;
66
+ const symbol = sample.replace(/[\s\u00a0\u202f0-9.,]/g, "");
67
+ return symbol.length > 0 ? symbol : group.currency;
68
+ } catch {
69
+ return group.currency;
70
+ }
71
+ }
72
+
43
73
  export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
44
74
  const t = useT();
75
+ const format = useFormat();
45
76
  const { group } = props;
46
77
  const [from, setFrom] = useState(toDraft(group.from));
47
78
  const [to, setTo] = useState(toDraft(group.to));
@@ -71,7 +102,23 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
71
102
  props.onApply(group.slug, empty ? null : draft);
72
103
  };
73
104
 
74
- const unit = group.unit === undefined ? "" : ` ${group.unit}`;
105
+ const suffix = boundSuffix(format, group);
106
+ const unit = suffix === undefined ? "" : ` ${suffix}`;
107
+ // Thousands grouping inside the field, because a price is read in
108
+ // thousands: `119000` is a wall of digits and `119 000` is a number. The
109
+ // parser is digits-only, so whatever the locale's separator turns out to
110
+ // be, the value that leaves the control is still the one the URL carries.
111
+ const grouping =
112
+ group.core
113
+ ? {
114
+ formatter: (value: string | number | undefined): string =>
115
+ value === undefined || value === ""
116
+ ? ""
117
+ : (format.number(Number(value)) ?? String(value)),
118
+ parser: (value: string | undefined): string =>
119
+ (value ?? "").replace(/[^\d.-]/g, ""),
120
+ }
121
+ : {};
75
122
 
76
123
  return (
77
124
  <Flex
@@ -79,8 +126,17 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
79
126
  gap={spacing[1]}
80
127
  data-testid={`facet-range-${group.slug}`}
81
128
  data-active={group.active ? "true" : "false"}
129
+ data-core={group.core ? "true" : "false"}
82
130
  >
83
- <Typography.Text strong>{group.label}</Typography.Text>
131
+ {/* The unit lives in the HEADING, not in the fields. It used to live
132
+ only in an aria-label, so a sighted reader of a money row saw two
133
+ bare integers and had to infer the currency from the results. antd
134
+ deprecated `addonAfter` on InputNumber in favour of `Space.Compact`,
135
+ and two addons plus an Apply button do not survive a 390px row —
136
+ "Price, RUB" states it once and costs no width. */}
137
+ <Typography.Text strong data-testid={`facet-range-${group.slug}-label`}>
138
+ {suffix === undefined ? group.label : `${group.label}, ${suffix}`}
139
+ </Typography.Text>
84
140
  <Flex gap={spacing[2]} align="center" wrap>
85
141
  <InputNumber
86
142
  value={from === "" ? null : Number(from)}
@@ -90,6 +146,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
90
146
  })}${unit}`}
91
147
  data-testid={`facet-range-${group.slug}-from`}
92
148
  style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
149
+ {...grouping}
93
150
  {...(group.min !== undefined ? { min: group.min } : {})}
94
151
  {...(group.max !== undefined ? { max: group.max } : {})}
95
152
  {...(group.step !== undefined ? { step: group.step } : {})}
@@ -106,6 +163,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
106
163
  })}${unit}`}
107
164
  data-testid={`facet-range-${group.slug}-to`}
108
165
  style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
166
+ {...grouping}
109
167
  {...(group.min !== undefined ? { min: group.min } : {})}
110
168
  {...(group.max !== undefined ? { max: group.max } : {})}
111
169
  {...(group.step !== undefined ? { step: group.step } : {})}