@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
|
@@ -31,6 +31,22 @@ export interface FacetPanelBag {
|
|
|
31
31
|
readonly skipped: readonly string[];
|
|
32
32
|
/** Slugs that WERE counted. */
|
|
33
33
|
readonly counted: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Range slugs the server declared as CORE document columns
|
|
36
|
+
* (`facet_meta.core_ranges`, stapel-search 0.4.0+) — `price` on a
|
|
37
|
+
* classified board. They are not part of the category plan and are not
|
|
38
|
+
* counted; they are axes the panel may offer unconditionally, and they
|
|
39
|
+
* come from the answer so that a panel never draws a control over a
|
|
40
|
+
* filter the deployed server would answer zero for.
|
|
41
|
+
*/
|
|
42
|
+
readonly coreRanges: readonly string[];
|
|
43
|
+
/**
|
|
44
|
+
* ISO 4217 code of the corpus, read off the first card of the answer, so
|
|
45
|
+
* a money range reads as money without the host wiring anything. The
|
|
46
|
+
* cards already carry it — `SearchResultCard` formats prices from the
|
|
47
|
+
* same field.
|
|
48
|
+
*/
|
|
49
|
+
readonly currency: string | undefined;
|
|
34
50
|
/** Size of the largest counted set — the number `approximate` is about. */
|
|
35
51
|
readonly candidates: number;
|
|
36
52
|
/** Facet values + ranges + geo currently applied. */
|
|
@@ -47,6 +63,7 @@ const EMPTY_META: FacetMeta = {
|
|
|
47
63
|
candidates: 0,
|
|
48
64
|
counted: [],
|
|
49
65
|
skipped: [],
|
|
66
|
+
core_ranges: [],
|
|
50
67
|
};
|
|
51
68
|
|
|
52
69
|
/**
|
|
@@ -115,6 +132,7 @@ export function useFacetPanel(props: {
|
|
|
115
132
|
buildFacetGroups({
|
|
116
133
|
facets: data.facets,
|
|
117
134
|
meta: data.facet_meta,
|
|
135
|
+
facetLabels: data.facet_labels,
|
|
118
136
|
state: searchState,
|
|
119
137
|
...(props.categoryFeatures !== undefined
|
|
120
138
|
? { categoryFeatures: props.categoryFeatures }
|
|
@@ -129,6 +147,12 @@ export function useFacetPanel(props: {
|
|
|
129
147
|
approximate: meta.approximate,
|
|
130
148
|
skipped: meta.skipped,
|
|
131
149
|
counted: meta.counted,
|
|
150
|
+
coreRanges: meta.core_ranges ?? [],
|
|
151
|
+
currency:
|
|
152
|
+
envelope.status === "ready"
|
|
153
|
+
? envelope.data.items.find((item) => typeof item.card?.["currency"] === "string")
|
|
154
|
+
?.card?.["currency"] as string | undefined
|
|
155
|
+
: undefined,
|
|
132
156
|
candidates: meta.candidates,
|
|
133
157
|
activeFilters,
|
|
134
158
|
toggle: toggleFilter,
|
package/src/i18n/es.ts
CHANGED
|
@@ -72,6 +72,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
72
72
|
"search.facets.not_counted": "sin contar",
|
|
73
73
|
"search.facets.drill_down_hint":
|
|
74
74
|
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
|
75
|
+
"search.range.price": "Precio",
|
|
75
76
|
"search.facets.range_from": "Desde",
|
|
76
77
|
"search.facets.range_to": "Hasta",
|
|
77
78
|
"search.facets.range_apply": "Aplicar",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -103,6 +103,9 @@ export const SEARCH_I18N_KEYS = {
|
|
|
103
103
|
facetsRangeToAria: "search.facets.range_to_aria",
|
|
104
104
|
/** The reason "Apply" is off: the range reads backwards. */
|
|
105
105
|
facetsRangeInvalid: "search.facets.range_invalid",
|
|
106
|
+
/** A CORE range axis has no FeatureDef and so no `name` to translate.
|
|
107
|
+
* `search.range.<slug>`; `price` is the one stapel-search ships. */
|
|
108
|
+
rangePrice: "search.range.price",
|
|
106
109
|
/** The fold on a long facet group: "Show all (46)". The number is in
|
|
107
110
|
* brackets and not a counted noun, so this is one message and not a plural
|
|
108
111
|
* family — "(1)" never renders, because a group is only folded when the
|
|
@@ -299,6 +302,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
299
302
|
"search.facets.not_counted": "not counted",
|
|
300
303
|
"search.facets.drill_down_hint":
|
|
301
304
|
"Each count is what you would get by choosing that value instead of the one you have.",
|
|
305
|
+
"search.range.price": "Price",
|
|
302
306
|
"search.facets.range_from": "From",
|
|
303
307
|
"search.facets.range_to": "To",
|
|
304
308
|
"search.facets.range_apply": "Apply",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -95,6 +95,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
95
95
|
"search.facets.not_counted": "не посчитано",
|
|
96
96
|
"search.facets.drill_down_hint":
|
|
97
97
|
"Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
|
|
98
|
+
"search.range.price": "Цена",
|
|
98
99
|
"search.facets.range_from": "От",
|
|
99
100
|
"search.facets.range_to": "До",
|
|
100
101
|
"search.facets.range_apply": "Применить",
|
package/src/index.ts
CHANGED
|
@@ -89,11 +89,16 @@ export type {
|
|
|
89
89
|
export {
|
|
90
90
|
countIsEstimate,
|
|
91
91
|
countKind,
|
|
92
|
+
degradationAudience,
|
|
92
93
|
degradationMessageKey,
|
|
93
94
|
isCountNuanceOnly,
|
|
94
95
|
parseDegradations,
|
|
96
|
+
readerFacing,
|
|
97
|
+
} from "./state/degradations.js";
|
|
98
|
+
export type {
|
|
99
|
+
SearchCountKind,
|
|
100
|
+
SearchDegradationAudience,
|
|
95
101
|
} from "./state/degradations.js";
|
|
96
|
-
export type { SearchCountKind } from "./state/degradations.js";
|
|
97
102
|
|
|
98
103
|
export { buildFacetGroups, facetOptionLabel } from "./state/facets.js";
|
|
99
104
|
export type {
|
|
@@ -105,6 +110,7 @@ export type {
|
|
|
105
110
|
export {
|
|
106
111
|
RANGE_FEATURE_TYPES,
|
|
107
112
|
buildRangeGroups,
|
|
113
|
+
coreRangeLabelKey,
|
|
108
114
|
isRangeFeature,
|
|
109
115
|
isRangeUsable,
|
|
110
116
|
} from "./state/ranges.js";
|
|
@@ -133,3 +133,59 @@ export function isCountNuanceOnly(
|
|
|
133
133
|
degradations.every((degradation) => degradation.kind === "exact_total")
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Who a degradation is addressed to.
|
|
139
|
+
*
|
|
140
|
+
* - `"reader"` — it changes what THIS PAGE MEANS. "Counts are approximate",
|
|
141
|
+
* "subcategories may be missing", "a ranking parameter is inactive", and
|
|
142
|
+
* anything this build has no wording for. A buyer can act on these: read
|
|
143
|
+
* the numbers as estimates, widen the category, distrust the order.
|
|
144
|
+
* - `"operator"` — it describes the ENGINE THIS DEPLOYMENT CHOSE. Nothing a
|
|
145
|
+
* buyer does changes it, and it is the same sentence on every query
|
|
146
|
+
* forever, which is precisely what makes it invisible by the time it
|
|
147
|
+
* matters.
|
|
148
|
+
*/
|
|
149
|
+
export type SearchDegradationAudience = "reader" | "operator";
|
|
150
|
+
|
|
151
|
+
const OPERATOR_KINDS: ReadonlySet<string> = new Set<SearchDegradationKind>([
|
|
152
|
+
// Both of these say, in the shipped copy, "the search engine in use
|
|
153
|
+
// cannot do this" — a sentence about a procurement decision, printed at a
|
|
154
|
+
// person trying to buy a phone.
|
|
155
|
+
"typo_tolerance",
|
|
156
|
+
"phrase_synonyms",
|
|
157
|
+
// A count nuance the count itself already speaks, as "N+". This is the
|
|
158
|
+
// rule `isCountNuanceOnly` encoded for one kind, generalized.
|
|
159
|
+
"exact_total",
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
/** {@link SearchDegradationAudience} for one kind. */
|
|
163
|
+
export function degradationAudience(
|
|
164
|
+
kind: SearchDegradationKind
|
|
165
|
+
): SearchDegradationAudience {
|
|
166
|
+
return OPERATOR_KINDS.has(kind) ? "operator" : "reader";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The degradations a buyer should be told about.
|
|
171
|
+
*
|
|
172
|
+
* The live defect this exists for: a classified board raised a full-screen
|
|
173
|
+
* yellow "What this search could not do: synonyms were not substituted —
|
|
174
|
+
* the search engine in use cannot do this" between the sort control and
|
|
175
|
+
* the first card, on every query, for every buyer. It was not a lie about a
|
|
176
|
+
* broken thing — stapel-search really did report `phrase_synonyms` on every
|
|
177
|
+
* query with text — which is exactly why deleting the STRING would have been
|
|
178
|
+
* the wrong fix: the next engine-capability literal would have grown its own
|
|
179
|
+
* copy of it. What is wrong is the AUDIENCE, so the audience is the thing
|
|
180
|
+
* that got a name.
|
|
181
|
+
*
|
|
182
|
+
* `variant="debug"` on `<DegradationNotice>` shows the operator's half; a
|
|
183
|
+
* host that wants it on a status page renders that.
|
|
184
|
+
*/
|
|
185
|
+
export function readerFacing(
|
|
186
|
+
degradations: readonly SearchDegradation[]
|
|
187
|
+
): readonly SearchDegradation[] {
|
|
188
|
+
return degradations.filter(
|
|
189
|
+
(degradation) => degradationAudience(degradation.kind) === "reader"
|
|
190
|
+
);
|
|
191
|
+
}
|
package/src/state/facets.ts
CHANGED
|
@@ -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. **
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/state/ranges.ts
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
|
|
@@ -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
|
|
93
|
-
*
|
|
94
|
-
* the schema
|
|
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
|
|
157
|
+
const core = new Set(input.coreRanges ?? []);
|
|
158
|
+
const slugs: string[] = [...core];
|
|
103
159
|
for (const feature of input.categoryFeatures ?? []) {
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|