@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.
- package/CHANGELOG.md +40 -0
- package/dist/api/generated/schema.d.ts +16 -0
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/DegradationNotice.d.ts +22 -13
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +7 -8
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -16
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +5 -0
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +49 -5
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +16 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +7 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +3 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +4 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +1 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +32 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +33 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +23 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +59 -8
- package/dist/state/facets.js.map +1 -1
- package/dist/state/ranges.d.ts +45 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +55 -14
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +7 -2
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +16 -0
- package/src/default/DegradationNotice.tsx +30 -20
- package/src/default/FacetPanelPane.tsx +39 -23
- package/src/default/FilterChips.tsx +5 -0
- package/src/default/RangeFilterRow.tsx +61 -3
- package/src/headless/FacetPanel.tsx +24 -0
- package/src/i18n/es.ts +1 -0
- package/src/i18n/keys.ts +4 -0
- package/src/i18n/ru.ts +1 -0
- package/src/index.ts +7 -1
- package/src/state/degradations.ts +56 -0
- package/src/state/facets.ts +96 -8
- package/src/state/ranges.ts +85 -15
package/dist/state/ranges.js
CHANGED
|
@@ -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
|
|
9
|
+
* ── Where a range row comes from ──────────────────────────────────────────
|
|
10
10
|
*
|
|
11
|
-
* A facet answer (`facets: {slug: {value: count}}`) enumerates DISCRETE
|
|
12
|
-
* a range is not enumerable and
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
57
|
-
*
|
|
58
|
-
* the schema
|
|
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
|
|
89
|
+
const core = new Set(input.coreRanges ?? []);
|
|
90
|
+
const slugs = [...core];
|
|
65
91
|
for (const feature of input.categoryFeatures ?? []) {
|
|
66
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/dist/state/ranges.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ranges.js","sourceRoot":"","sources":["../../src/state/ranges.ts"],"names":[],"mappings":"AAAA
|
|
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.
|
|
1
|
+
# @stapel/search-react 0.11.0
|
|
2
2
|
|
|
3
|
-
Headless React flow pair for stapel-search (contract >=0.
|
|
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.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"backend": {
|
|
6
6
|
"module": "stapel-search",
|
|
7
|
-
"contract": ">=0.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/search-react",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
122
|
+
"@stapel/tokens-antd": "^0.10.0",
|
|
123
|
+
"@stapel/showcase": "^0.3.0"
|
|
124
124
|
},
|
|
125
125
|
"engines": {
|
|
126
126
|
"node": ">=22"
|
|
@@ -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.
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
19
|
-
* is
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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"`. `"
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 {
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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 } : {})}
|