@stapel/search-react 0.19.0 → 0.21.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 +125 -0
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +47 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +0 -19
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +45 -62
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +1 -24
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +4 -18
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +12 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +80 -6
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts +6 -5
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +31 -21
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/geoSheet.d.ts +12 -6
- package/dist/default/geoSheet.d.ts.map +1 -1
- package/dist/default/geoSheet.js +14 -3
- package/dist/default/geoSheet.js.map +1 -1
- package/dist/default/index.d.ts +4 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +57 -30
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +51 -36
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +7 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +43 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +51 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +3 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +48 -1
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +46 -3
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +69 -22
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +13 -2
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +51 -5
- package/src/default/FacetPanelPane.tsx +52 -126
- package/src/default/FilterChips.tsx +5 -70
- package/src/default/LocationSummaryLine.tsx +157 -32
- package/src/default/SearchPage.tsx +57 -31
- package/src/default/geoSheet.tsx +46 -7
- package/src/default/index.ts +4 -3
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +113 -66
- package/src/i18n/es.ts +11 -0
- package/src/i18n/keys.ts +55 -0
- package/src/i18n/ru.ts +15 -0
- package/src/index.ts +3 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +47 -1
- package/src/state/urlState.ts +98 -17
|
@@ -135,6 +135,19 @@ export interface paths {
|
|
|
135
135
|
export type webhooks = Record<string, never>;
|
|
136
136
|
export interface components {
|
|
137
137
|
schemas: {
|
|
138
|
+
/** @description One partition of the answer — a heading, never a filter over it. */
|
|
139
|
+
BandSummary: {
|
|
140
|
+
id: components["schemas"]["IdEnum"];
|
|
141
|
+
/** @description How many rows are in this band. `null` when the engine cannot say. The two counts add up to the top-level `count`, which is the machine-checkable form of 'nothing is hidden by distance'. */
|
|
142
|
+
count: number | null;
|
|
143
|
+
/** @description True when `count` is a floor (a capped count). Render 'N+'. */
|
|
144
|
+
count_is_lower_bound: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Format: double
|
|
147
|
+
* @description Present on `nearby` only: the edge, in km, the band was cut at. `all` has no edge — it is everything else, which is the promise.
|
|
148
|
+
*/
|
|
149
|
+
radius_km?: number;
|
|
150
|
+
};
|
|
138
151
|
/** @description One destination in the dropdown, ready to render and ready to follow. */
|
|
139
152
|
CategorySuggestion: {
|
|
140
153
|
/** @description Category id. A `listings`-graded row derives it from the path's leaf segment. */
|
|
@@ -149,6 +162,17 @@ export interface components {
|
|
|
149
162
|
category: string;
|
|
150
163
|
/** @description Live listings a buyer would see under this category, descendants included — the same number the SERP reports for it. On a `listings`-graded row: how many of them match the typed query, which is the count a `?q=…&category=…` tap will show. */
|
|
151
164
|
count: number;
|
|
165
|
+
/**
|
|
166
|
+
* @description What `count` counted. `category`: everything live under this category, the typed text ignored — the row is a PLACE. `query_in_category`: only the documents matching the typed text, which is what a `listings`-graded row is about. The two are different numbers about different pages, which is why the row says which one it is instead of leaving a storefront to guess.
|
|
167
|
+
*
|
|
168
|
+
* * `category` - category
|
|
169
|
+
* * `query_in_category` - query_in_category
|
|
170
|
+
*/
|
|
171
|
+
count_scope: components["schemas"]["CountScopeEnum"];
|
|
172
|
+
/** @description The /query parameters this row's `count` was computed for — send them VERBATIM (plus your own `type`/`lang`/paging) when the buyer follows the row. Always carries `category`; carries `q` only when `count_scope` is `query_in_category`. Assembling these yourself re-opens the defect this field exists to close: a place row followed with the typed text opens an empty page while its count promised stock. */
|
|
173
|
+
query: {
|
|
174
|
+
[key: string]: string;
|
|
175
|
+
};
|
|
152
176
|
/** @description Number of segments in `path`. */
|
|
153
177
|
depth: number;
|
|
154
178
|
/**
|
|
@@ -163,6 +187,28 @@ export interface components {
|
|
|
163
187
|
*/
|
|
164
188
|
match: components["schemas"]["MatchEnum"];
|
|
165
189
|
};
|
|
190
|
+
/**
|
|
191
|
+
* @description * `category` - category
|
|
192
|
+
* * `query_in_category` - query_in_category
|
|
193
|
+
* @enum {string}
|
|
194
|
+
*/
|
|
195
|
+
CountScopeEnum: "category" | "query_in_category";
|
|
196
|
+
/** @description One filter the query's own words produced, ready to be replayed. */
|
|
197
|
+
ExtractedFilter: {
|
|
198
|
+
slug: string;
|
|
199
|
+
value: string;
|
|
200
|
+
label: string;
|
|
201
|
+
value_label: string;
|
|
202
|
+
method: components["schemas"]["MethodEnum"];
|
|
203
|
+
/** Format: double */
|
|
204
|
+
confidence: number;
|
|
205
|
+
/** @description `[start, end)` into the RAW query, so a UI can underline the words that became this chip. */
|
|
206
|
+
span: number[];
|
|
207
|
+
/** @description The literal query parameter this filter IS — e.g. `f.color=krasnyy`. Re-send it verbatim to KEEP the chip, omit it to remove it, and send `qu=off` alongside so the server does not extract it again. Nothing about extraction is remembered server-side, which is why the parameter has to be complete. */
|
|
208
|
+
param: string;
|
|
209
|
+
/** @description True when this filter actually narrowed the answer. A false one still contributed to each row's `match_count`. */
|
|
210
|
+
applied: boolean;
|
|
211
|
+
};
|
|
166
212
|
/** @description Captions for one slug's option codes. */
|
|
167
213
|
FacetLabels: {
|
|
168
214
|
/** @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. */
|
|
@@ -183,6 +229,16 @@ export interface components {
|
|
|
183
229
|
dropped_filters: string[];
|
|
184
230
|
/** @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. */
|
|
185
231
|
core_ranges: string[];
|
|
232
|
+
/** @description Where the plan came from. `category` — the queried category's own authored schema. `evidence` — the categories the CANDIDATE SET actually contains, used when that schema did not fill MAX_FACET_FIELDS, which is every branch category and every text query (`categories.features` resolves own + ANCESTOR-inherited features, so a branch owns no axes; its leaves do). */
|
|
233
|
+
plan: string;
|
|
234
|
+
/** @description `{slug, coverage, candidates}` for groups that were counted and then withheld because they describe too little of the result set (FACET_MIN_COVERAGE). Present so a panel can say «3 filters apply to too few of these» instead of «no filters» — the second is false whenever this list is not empty. Only slugs the evidence plan borrowed from another category are ever here, and never one the reader has already filtered on. */
|
|
235
|
+
withheld: {
|
|
236
|
+
[key: string]: unknown;
|
|
237
|
+
}[];
|
|
238
|
+
/** @description `{category, count}` — the categories this answer's candidate set is made of, busiest first, `category` being the same slash-joined id path the `category` filter takes. The evidence the plan was drawn from, and what a panel needs to offer the CATEGORY itself as the first filter on a text search. Empty when the plan is the queried category's own. */
|
|
239
|
+
categories: {
|
|
240
|
+
[key: string]: unknown;
|
|
241
|
+
}[];
|
|
186
242
|
};
|
|
187
243
|
HealthResponse: {
|
|
188
244
|
backend: string;
|
|
@@ -196,6 +252,12 @@ export interface components {
|
|
|
196
252
|
lag_seconds?: number | null;
|
|
197
253
|
stale_reason?: string;
|
|
198
254
|
};
|
|
255
|
+
/**
|
|
256
|
+
* @description * `nearby` - nearby
|
|
257
|
+
* * `all` - all
|
|
258
|
+
* @enum {string}
|
|
259
|
+
*/
|
|
260
|
+
IdEnum: "nearby" | "all";
|
|
199
261
|
/**
|
|
200
262
|
* @description * `exact` - exact
|
|
201
263
|
* * `prefix` - prefix
|
|
@@ -206,6 +268,24 @@ export interface components {
|
|
|
206
268
|
* @enum {string}
|
|
207
269
|
*/
|
|
208
270
|
MatchEnum: "exact" | "prefix" | "word" | "substring" | "listings" | "vector";
|
|
271
|
+
/**
|
|
272
|
+
* @description * `exact` - exact
|
|
273
|
+
* * `translit` - translit
|
|
274
|
+
* * `alias` - alias
|
|
275
|
+
* * `vector` - vector
|
|
276
|
+
* @enum {string}
|
|
277
|
+
*/
|
|
278
|
+
MethodEnum: "exact" | "translit" | "alias" | "vector";
|
|
279
|
+
/** @description What a free-text query turned out to be ABOUT, beside its words. */
|
|
280
|
+
QueryUnderstanding: {
|
|
281
|
+
filters: components["schemas"]["ExtractedFilter"][];
|
|
282
|
+
category_path: string[];
|
|
283
|
+
/** Format: double */
|
|
284
|
+
category_confidence: number;
|
|
285
|
+
/** @description The query with every extracted span removed — the text the engine still had to match. Empty is normal and correct: «красный» is entirely a filter, and the answer is then the filters' set. */
|
|
286
|
+
residual: string;
|
|
287
|
+
degraded: string[];
|
|
288
|
+
};
|
|
209
289
|
/** @description The P2B Art. 5 disclosure, generated from the scorer registry. */
|
|
210
290
|
RankingResponse: {
|
|
211
291
|
doc_type: string;
|
|
@@ -252,10 +332,14 @@ export interface components {
|
|
|
252
332
|
promoted: boolean;
|
|
253
333
|
/**
|
|
254
334
|
* Format: double
|
|
255
|
-
* @description Great-circle distance from the searched centre.
|
|
335
|
+
* @description Great-circle distance from the searched centre, in km. For an ANONYMOUS reader it is measured from the same ~1.1km grid point the card publishes and floored to that grid's quantum (the cell's diagonal, ~1.574km): a distance finer than the position it came from is the position, three requests away. The listing's own owner, staff and the service transport get the exact number. Coarse is enough for what it drives — a card saying «12 км» does not need metres — and it never overstates proximity, being floored rather than rounded.
|
|
256
336
|
*/
|
|
257
337
|
distance_km: number | null;
|
|
258
|
-
/** @description
|
|
338
|
+
/** @description `nearby` | `all` | `""`. Which partition this row sits in, present only under `geo_mode=rank`. A LABEL, never a filter: `all` carries every remaining row, including rows with no coordinates at all, so a query never returns fewer results because of distance. `""` means the request ranked by proximity but gave no centre, which is not an error. */
|
|
339
|
+
band?: string;
|
|
340
|
+
/** @description How many of the filters the QUERY's own words produced this row satisfies — including the soft ones that were not applied as filters. Orders rows within a band, strongest first. Present only when the query produced signals. */
|
|
341
|
+
match_count?: number;
|
|
342
|
+
/** @description Stored row fields, so a result page costs one query. For an ANONYMOUS reader the card never carries full-precision coordinates, on every path and whatever the flags: a key naming half a pair (`lat`, `latitude`, `lon`, `lng`, `*_lat`…) is rewritten onto the ~1.1km public grid from the row's own columns, and a key carrying a position this module cannot turn into an area (`geohash`, `location`, `coordinates`…) is removed rather than truncated — two differently-aligned areas around one point intersect down to a sliver. `geo_precision_km` says how wide the area is: draw a CIRCLE, never a marker. Under `geo_mode=rank` the pair is ADDED even to a card that carried no position at all. */
|
|
259
343
|
card: {
|
|
260
344
|
[key: string]: unknown;
|
|
261
345
|
};
|
|
@@ -263,6 +347,10 @@ export interface components {
|
|
|
263
347
|
/** @description The query envelope: AnchorPagination's keys, plus what search owes. */
|
|
264
348
|
SearchResponse: {
|
|
265
349
|
items: components["schemas"]["SearchItem"][];
|
|
350
|
+
/** @description Per-band counts in render order — `nearby` («Объявления поблизости») then `all` («Все объявления»). Present only under `geo_mode=rank`; empty when no centre was given. The rows themselves carry `band`; this is the summary a heading needs. The top-level `count` remains the WHOLE matching total, never the nearby one. */
|
|
351
|
+
bands?: components["schemas"]["BandSummary"][];
|
|
352
|
+
/** @description What the query's words became. Absent entirely while QUERY_UNDERSTANDING is off or `qu=off` was sent. */
|
|
353
|
+
query_understanding?: components["schemas"]["QueryUnderstanding"];
|
|
266
354
|
/** @description {slug: {value: count}}, counted with the slug's own filter removed. */
|
|
267
355
|
facets: {
|
|
268
356
|
[key: string]: {
|
|
@@ -338,7 +426,7 @@ export interface operations {
|
|
|
338
426
|
query: {
|
|
339
427
|
/** @description Opaque keyset cursor from a previous answer. */
|
|
340
428
|
anchor?: string;
|
|
341
|
-
/** @description minLat,minLon,maxLat,maxLon. minLon > maxLon means the box crosses +/-180. */
|
|
429
|
+
/** @description minLat,minLon,maxLat,maxLon. minLon > maxLon means the box crosses +/-180. For an anonymous caller the rectangle is grown OUTWARD to whole ~1.1km cells of the public geo grid, so it can only ever ask about the area a public card publishes: a box EXCLUDES rows, and halving one around a listing would otherwise converge on the seller's pin in a few dozen requests. */
|
|
342
430
|
bbox?: string;
|
|
343
431
|
/** @description root/leaf path; a prefix filter, so a parent finds its descendants. */
|
|
344
432
|
category?: string;
|
|
@@ -348,6 +436,8 @@ export interface operations {
|
|
|
348
436
|
"f.<slug>"?: string;
|
|
349
437
|
/** @description on | off | comma-separated slugs. Default is the category's plan. */
|
|
350
438
|
facets?: string;
|
|
439
|
+
/** @description rank | filter. What `radius_km` means. `rank` PARTITIONS: the answer comes back as `nearby` (inside the radius) then `all` (every remaining row), nothing is withheld, and `count` stays the whole matching total — a query can never come back empty because of distance. `filter` is the historical hard cut, for a caller that genuinely wants only what is within N km. `rank` is the default inside the feature; while STAPEL_SEARCH['GEO_BANDS'] is off this parameter is inert and `radius_km` filters as it always has. */
|
|
440
|
+
geo_mode?: string;
|
|
351
441
|
/** @description Language of the query: selects the analyzer AND narrows the corpus. Omit it and only the analyzer is chosen (from Accept-Language) — a header must not hide a catalogue. */
|
|
352
442
|
lang?: string;
|
|
353
443
|
/** @description Centre latitude (with lon). */
|
|
@@ -359,9 +449,11 @@ export interface operations {
|
|
|
359
449
|
owner?: string;
|
|
360
450
|
/** @description Free text. Dictionary-normalized here; morphology belongs to the engine. */
|
|
361
451
|
q?: string;
|
|
452
|
+
/** @description auto | off. Whether the query's own words may become filters. `auto` (the default while QUERY_UNDERSTANDING is on) extracts and reports what it extracted under `query_understanding`; each filter carries the literal `param` to replay. Send `qu=off` with those replayed params afterwards — extraction is stateless, so without it a removed chip comes straight back. */
|
|
453
|
+
qu?: string;
|
|
362
454
|
/** @description Range filter, `from..to`; either end may be omitted. */
|
|
363
455
|
"r.<slug>"?: string;
|
|
364
|
-
/** @description
|
|
456
|
+
/** @description Distance around the centre, in km. Its MEANING is `geo_mode`'s: under `rank` it is the edge of the `nearby` band and excludes nothing; under `filter` it is a hard cut. Defaults to NEAR_BAND_RADIUS_KM under `rank`. */
|
|
365
457
|
radius_km?: number;
|
|
366
458
|
/** @description relevance | newest | price_asc | price_desc | distance. An explicit sort never receives a promotional boost. */
|
|
367
459
|
sort?: string;
|
package/src/api/types.ts
CHANGED
|
@@ -13,17 +13,59 @@ import type { components } from "./generated/schema.js";
|
|
|
13
13
|
/** The generated schema table — the one source of truth for wire shapes. */
|
|
14
14
|
export type Schemas = components["schemas"];
|
|
15
15
|
|
|
16
|
-
/** `GET /query` 200 — the whole envelope.
|
|
17
|
-
|
|
16
|
+
/** `GET /query` 200 — the whole envelope. Corrected in one place: see
|
|
17
|
+
* {@link FacetMeta}. */
|
|
18
|
+
export type SearchResponse = Omit<Schemas["SearchResponse"], "facet_meta"> & {
|
|
19
|
+
readonly facet_meta: FacetMeta;
|
|
20
|
+
};
|
|
18
21
|
|
|
19
22
|
/** One result row. `promoted` is present on EVERY item under EVERY sort — a
|
|
20
23
|
* mandatory marking (DSA Art. 26), not an optional field, which is why the
|
|
21
24
|
* card slot's contract carries it and why the default skin renders it. */
|
|
22
25
|
export type SearchItem = Schemas["SearchItem"];
|
|
23
26
|
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* One group the counter COUNTED and then held back, because its buckets
|
|
29
|
+
* describe too little of the result set (`FACET_MIN_COVERAGE`).
|
|
30
|
+
*
|
|
31
|
+
* The existence of this list is what makes "this search offers no filters"
|
|
32
|
+
* a false sentence whenever it is not empty (D175).
|
|
33
|
+
*/
|
|
34
|
+
export interface FacetWithheldGroup {
|
|
35
|
+
readonly slug: string;
|
|
36
|
+
/** Sum of that group's bucket counts — how much of the set it describes. */
|
|
37
|
+
readonly coverage: number;
|
|
38
|
+
/** Size of the candidate set `coverage` is a fraction of. */
|
|
39
|
+
readonly candidates: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One category the candidate set is made of. */
|
|
43
|
+
export interface FacetCategoryCount {
|
|
44
|
+
/** The slash-joined id path (`"32/149/163"`) — the SAME string the
|
|
45
|
+
* `category` filter and `SearchQueryState.category` already take, so a
|
|
46
|
+
* panel can offer it as a filter without translating anything. */
|
|
47
|
+
readonly category: string;
|
|
48
|
+
readonly count: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The honesty block beside the counts: `approximate`, `candidates`,
|
|
53
|
+
* `counted`, `skipped`, and (stapel-search 0.12.0+) where the facet plan came
|
|
54
|
+
* from. Rendered, never swallowed (spec §4.2).
|
|
55
|
+
*
|
|
56
|
+
* WHAT THE GENERATOR LOST: drf-spectacular describes `withheld` and
|
|
57
|
+
* `categories` as bare `object` arrays, so the generated members are
|
|
58
|
+
* `{[key: string]: unknown}[]` — the two fields a panel has to read
|
|
59
|
+
* field-by-field are the two it cannot. Both are corrected here to the
|
|
60
|
+
* documented row shapes; nothing else about `FacetMeta` is hand-written.
|
|
61
|
+
*/
|
|
62
|
+
export type FacetMeta = Omit<
|
|
63
|
+
Schemas["FacetMeta"],
|
|
64
|
+
"withheld" | "categories"
|
|
65
|
+
> & {
|
|
66
|
+
readonly withheld: readonly FacetWithheldGroup[];
|
|
67
|
+
readonly categories: readonly FacetCategoryCount[];
|
|
68
|
+
};
|
|
27
69
|
|
|
28
70
|
/** `GET /suggest` 200, as the CURRENT generated schema describes it. */
|
|
29
71
|
export type SuggestResponse = Schemas["SuggestResponse"];
|
|
@@ -259,6 +301,10 @@ export type SearchDegradationKind =
|
|
|
259
301
|
| "exact_total"
|
|
260
302
|
| "exact_facet_counts"
|
|
261
303
|
| "category_rollup"
|
|
304
|
+
/** The engine has no `category_counts` verb, so the categories the result
|
|
305
|
+
* set is made of are unknown and no evidence facet plan could be drawn.
|
|
306
|
+
* An empty filter panel then means "we do not know", not "there are none". */
|
|
307
|
+
| "facet_plan_evidence"
|
|
262
308
|
| "scorer"
|
|
263
309
|
| "unknown";
|
|
264
310
|
|
|
@@ -22,19 +22,27 @@
|
|
|
22
22
|
* filters on a slug it never counted; the engine's own list of skipped
|
|
23
23
|
* slugs is a developer's note and lives behind `skippedNotice`.
|
|
24
24
|
*
|
|
25
|
-
* ──
|
|
25
|
+
* ── One slot, and why it is a slot ────────────────────────────────────────
|
|
26
26
|
*
|
|
27
|
-
* `renderCategoryFilter`
|
|
28
|
-
*
|
|
29
|
-
* and
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* `renderCategoryFilter` is filled by ANOTHER pair: choosing a category means
|
|
28
|
+
* walking the catalogue tree (`categories-react`), which does not belong in a
|
|
29
|
+
* search package, and importing it would tie a storefront's search to a
|
|
30
|
+
* catalogue it might not have.
|
|
31
|
+
*
|
|
32
|
+
* There used to be a second one, `renderGeoFilter`, and a "Location" group
|
|
33
|
+
* around it with the radius under it. Both are gone from this panel. A place
|
|
34
|
+
* is not a filter — a coordinate pair is the machine form of somewhere, and
|
|
35
|
+
* putting it in the filter list is what let a landing announce "clear all
|
|
36
|
+
* filters (2)" over an empty page with two constraints that had no name.
|
|
37
|
+
* The place and its radius are ONE control of their own
|
|
38
|
+
* (`<LocationSummaryLine>`), the way a search box is, and `<SearchPage>`
|
|
39
|
+
* still takes `renderGeoFilter` — it hands it there.
|
|
32
40
|
*
|
|
33
41
|
* What this panel does NOT do is pretend the slot is optional. An unfilled
|
|
34
42
|
* slot renders `SlotPlaceholder` in development — a named, visible hole rather
|
|
35
43
|
* than a silent absence — and, in every build, any constraint the URL already
|
|
36
44
|
* carries gets a control that REMOVES it. A shared link that narrows to a
|
|
37
|
-
* category
|
|
45
|
+
* category must never leave a person with no way to widen it again.
|
|
38
46
|
*
|
|
39
47
|
* ── The panel opens what the answer argues for, and closes the rest ────────
|
|
40
48
|
*
|
|
@@ -68,11 +76,10 @@ import {
|
|
|
68
76
|
Divider,
|
|
69
77
|
Flex,
|
|
70
78
|
Input,
|
|
71
|
-
InputNumber,
|
|
72
79
|
Typography,
|
|
73
80
|
theme,
|
|
74
81
|
} from "antd";
|
|
75
|
-
import { SlotPlaceholder,
|
|
82
|
+
import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
|
|
76
83
|
import {
|
|
77
84
|
EmptyState,
|
|
78
85
|
ErrorAlert,
|
|
@@ -84,6 +91,7 @@ import { featureName } from "@stapel/attributes-react";
|
|
|
84
91
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
85
92
|
import type { SearchGeo } from "../api/types.js";
|
|
86
93
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
94
|
+
import type { FacetPanelBag } from "../headless/FacetPanel.js";
|
|
87
95
|
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
88
96
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
89
97
|
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
@@ -202,25 +210,6 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
202
210
|
/** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
|
|
203
211
|
* to a path). Unfilled, an active category still gets a "clear" control. */
|
|
204
212
|
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
205
|
-
/** The location control (`geo-react`). Unfilled, a location that arrived in
|
|
206
|
-
* the URL still gets its radius and a "clear" control. */
|
|
207
|
-
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
208
|
-
/**
|
|
209
|
-
* What the current location constraint is CALLED, in words — the address or
|
|
210
|
-
* the city the host resolved.
|
|
211
|
-
*
|
|
212
|
-
* The panel owns `lat`/`lon`/`radius_km` as URL state and must never render
|
|
213
|
-
* them: a coordinate is what gets STORED, and storage is not a display
|
|
214
|
-
* concern. `55.756, 37.617` is unreadable to the one person who could have
|
|
215
|
-
* caught the mistake, so a wrong point looks as authoritative as a right one
|
|
216
|
-
* and a right one looks like machinery.
|
|
217
|
-
*
|
|
218
|
-
* Whoever turned an address into that point still has the address — the
|
|
219
|
-
* geocoder's own answer, the city an IP guess named, the label on the map
|
|
220
|
-
* pin — and hands it back here. Absent, the panel says a location is applied
|
|
221
|
-
* without pretending to name it (`search.geo.chosen_place`).
|
|
222
|
-
*/
|
|
223
|
-
readonly geoLabel?: ReactNode;
|
|
224
213
|
/** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
|
|
225
214
|
readonly languages?: readonly string[];
|
|
226
215
|
/**
|
|
@@ -407,97 +396,6 @@ export function geoSummaryFallback(
|
|
|
407
396
|
: t(SEARCH_I18N_KEYS.geoChosenPlace);
|
|
408
397
|
}
|
|
409
398
|
|
|
410
|
-
/**
|
|
411
|
-
* The location constraint.
|
|
412
|
-
*
|
|
413
|
-
* SETTING a centre needs a geocoder, which is the deployment's and
|
|
414
|
-
* `geo-react`'s. ADJUSTING one that already exists does not: the radius is a
|
|
415
|
-
* number in the URL, and a link shared with `lat/lon/radius_km` is a link this
|
|
416
|
-
* panel can widen, tighten and clear without knowing what a map is. That is the
|
|
417
|
-
* difference between a slot and a hole.
|
|
418
|
-
*
|
|
419
|
-
* NAMING the centre is a third thing again, and it is the host's: this panel
|
|
420
|
-
* has two numbers and no way to turn them into a place. So it either says the
|
|
421
|
-
* name it was handed (`label`) or says that a place is chosen — never the
|
|
422
|
-
* numbers themselves. See {@link FacetPanelPaneProps.geoLabel}.
|
|
423
|
-
*/
|
|
424
|
-
function GeoFilter(props: {
|
|
425
|
-
render?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
426
|
-
label?: ReactNode;
|
|
427
|
-
}): ReactElement | null {
|
|
428
|
-
const t = useT();
|
|
429
|
-
const { state, setGeo } = useSearchState();
|
|
430
|
-
const geo = state.geo;
|
|
431
|
-
|
|
432
|
-
const slot =
|
|
433
|
-
props.render !== undefined
|
|
434
|
-
? props.render({
|
|
435
|
-
value: geo,
|
|
436
|
-
onChange: (next) => {
|
|
437
|
-
setGeo(next);
|
|
438
|
-
},
|
|
439
|
-
})
|
|
440
|
-
: geo === undefined
|
|
441
|
-
? <SlotPlaceholder name="renderGeoFilter" data-testid="search-geo-slot" />
|
|
442
|
-
: null;
|
|
443
|
-
|
|
444
|
-
if (geo === undefined) {
|
|
445
|
-
// An unfilled slot is a NAMED hole in development and nothing at all in a
|
|
446
|
-
// production build — so the heading has to follow the placeholder rather
|
|
447
|
-
// than outlive it. It did not, and the live desktop panel printed
|
|
448
|
-
// "Location" over empty space with no location control under it
|
|
449
|
-
// (class NC-ORPHANFIELD): a label is a promise that a control follows.
|
|
450
|
-
if (props.render === undefined && !isDevBuild()) return null;
|
|
451
|
-
return slot === null ? null : (
|
|
452
|
-
<Flex vertical gap={spacing[1]} data-testid="search-geo">
|
|
453
|
-
<Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
|
|
454
|
-
{slot}
|
|
455
|
-
</Flex>
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return (
|
|
460
|
-
<Flex vertical gap={spacing[1]} data-testid="search-geo">
|
|
461
|
-
<Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
|
|
462
|
-
{slot}
|
|
463
|
-
<Typography.Text type="secondary" data-testid="search-geo-summary">
|
|
464
|
-
{props.label ?? geoSummaryFallback(geo, t)}
|
|
465
|
-
</Typography.Text>
|
|
466
|
-
{geo.kind === "center" && (
|
|
467
|
-
<Flex gap={spacing[2]} align="center" wrap>
|
|
468
|
-
<Typography.Text type="secondary" aria-hidden="true">
|
|
469
|
-
{t(SEARCH_I18N_KEYS.geoRadiusLabel)}
|
|
470
|
-
</Typography.Text>
|
|
471
|
-
<InputNumber
|
|
472
|
-
min={1}
|
|
473
|
-
value={geo.radiusKm ?? null}
|
|
474
|
-
aria-label={t(SEARCH_I18N_KEYS.geoRadiusLabel)}
|
|
475
|
-
data-testid="search-geo-radius"
|
|
476
|
-
onChange={(value) => {
|
|
477
|
-
setGeo({
|
|
478
|
-
kind: "center",
|
|
479
|
-
lat: geo.lat,
|
|
480
|
-
lon: geo.lon,
|
|
481
|
-
...(typeof value === "number" ? { radiusKm: value } : {}),
|
|
482
|
-
});
|
|
483
|
-
}}
|
|
484
|
-
/>
|
|
485
|
-
</Flex>
|
|
486
|
-
)}
|
|
487
|
-
<Button
|
|
488
|
-
style={{ alignSelf: "flex-start" }}
|
|
489
|
-
data-testid="search-geo-clear"
|
|
490
|
-
data-analytics="none"
|
|
491
|
-
data-analytics-reason="a filter is a read, not a flow step"
|
|
492
|
-
onClick={() => {
|
|
493
|
-
setGeo(null);
|
|
494
|
-
}}
|
|
495
|
-
>
|
|
496
|
-
{t(SEARCH_I18N_KEYS.geoClear)}
|
|
497
|
-
</Button>
|
|
498
|
-
</Flex>
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
399
|
|
|
502
400
|
/**
|
|
503
401
|
* The slugs the server skipped, named the way the panel names everything else.
|
|
@@ -524,6 +422,40 @@ function skippedNames(
|
|
|
524
422
|
.join(", ");
|
|
525
423
|
}
|
|
526
424
|
|
|
425
|
+
/**
|
|
426
|
+
* The empty arm of the panel — the ONE place "this search offers no filters"
|
|
427
|
+
* may be said, and the two answers that forbid it (D175).
|
|
428
|
+
*
|
|
429
|
+
* A group list of zero is not the same claim. `withheld` names groups the
|
|
430
|
+
* counter counted and then held back for describing too little of the result
|
|
431
|
+
* set: they exist, so the honest line is how many. `planUnavailable` means
|
|
432
|
+
* the server could not work a plan out at all — the reader hears that from
|
|
433
|
+
* `<DegradationNotice>`, and this arm's only job is to not contradict it.
|
|
434
|
+
*
|
|
435
|
+
* A COMPONENT rather than a ternary inline in `empty=`, because `LoadList`
|
|
436
|
+
* reads a nullish `empty` as "no arm given" and draws its own default, which
|
|
437
|
+
* is the sentence again. An element that renders `null` says nothing; a
|
|
438
|
+
* `null` prop says it louder.
|
|
439
|
+
*/
|
|
440
|
+
function FacetsEmptyArm(props: { readonly bag: FacetPanelBag }): ReactElement | null {
|
|
441
|
+
const t = useT();
|
|
442
|
+
const tPlural = useTPlural();
|
|
443
|
+
const withheld = props.bag.withheld.length;
|
|
444
|
+
if (withheld > 0) {
|
|
445
|
+
return (
|
|
446
|
+
<EmptyState
|
|
447
|
+
compact
|
|
448
|
+
title={tPlural(SEARCH_I18N_KEYS.facetsWithheld, { count: withheld })}
|
|
449
|
+
testId="facets-withheld"
|
|
450
|
+
/>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (props.bag.planUnavailable) return null;
|
|
454
|
+
return (
|
|
455
|
+
<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
527
459
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
528
460
|
const t = useT();
|
|
529
461
|
const { state } = useSearchState();
|
|
@@ -616,12 +548,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
616
548
|
<LanguageSelect
|
|
617
549
|
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
618
550
|
/>
|
|
619
|
-
<GeoFilter
|
|
620
|
-
{...(props.renderGeoFilter !== undefined
|
|
621
|
-
? { render: props.renderGeoFilter }
|
|
622
|
-
: {})}
|
|
623
|
-
{...(props.geoLabel !== undefined ? { label: props.geoLabel } : {})}
|
|
624
|
-
/>
|
|
625
551
|
|
|
626
552
|
{/* Price, and only the CORE axes — the ones the server declares
|
|
627
553
|
for every document in every category.
|
|
@@ -675,7 +601,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
675
601
|
state={bag.state}
|
|
676
602
|
testId="facets"
|
|
677
603
|
skeletonRows={4}
|
|
678
|
-
empty={<
|
|
604
|
+
empty={<FacetsEmptyArm bag={bag} />}
|
|
679
605
|
failed={(error) => (
|
|
680
606
|
<ErrorAlert
|
|
681
607
|
testId="facets-failed"
|
|
@@ -138,12 +138,8 @@ import type { FacetGroup } from "../state/facets.js";
|
|
|
138
138
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
139
139
|
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
140
140
|
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
141
|
-
import {
|
|
142
|
-
import
|
|
143
|
-
CategoryFilterSlotProps,
|
|
144
|
-
GeoFilterSlotProps,
|
|
145
|
-
} from "./FacetPanelPane.js";
|
|
146
|
-
import { CHIP_GEO_TEST_IDS, GeoSheet, useApplyLabel } from "./geoSheet.js";
|
|
141
|
+
import type { CategoryFilterSlotProps } from "./FacetPanelPane.js";
|
|
142
|
+
import { useApplyLabel } from "./geoSheet.js";
|
|
147
143
|
|
|
148
144
|
/** The class the scroller carries, for {@link chipRowCss}. */
|
|
149
145
|
export const CHIP_ROW_CLASS = "stapel-filter-chips";
|
|
@@ -179,6 +175,7 @@ const ROW: CSSProperties = {
|
|
|
179
175
|
|
|
180
176
|
const CHIP: CSSProperties = { flex: "0 0 auto", borderRadius: radii.full };
|
|
181
177
|
|
|
178
|
+
|
|
182
179
|
/** Which picker is open, if any. `null` closes everything. */
|
|
183
180
|
type OpenChip = string | null;
|
|
184
181
|
|
|
@@ -323,19 +320,6 @@ export interface FilterChipsProps {
|
|
|
323
320
|
* does not, and it is never invented.
|
|
324
321
|
*/
|
|
325
322
|
readonly categoryLabel?: ReactNode;
|
|
326
|
-
/** The location control (`geo-react`), same slot the panel takes. Without
|
|
327
|
-
* it the location chip appears only when the URL already carries a point,
|
|
328
|
-
* so a shared link can still be widened. */
|
|
329
|
-
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
330
|
-
/**
|
|
331
|
-
* What the current location constraint is CALLED — see
|
|
332
|
-
* {@link FacetPanelPaneProps.geoLabel}. The chip carries it as its own text,
|
|
333
|
-
* which is the whole point of a chip row: "Berlin Mitte" states the filter
|
|
334
|
-
* on the results page, where `55.756, 37.617` stated only that a machine was
|
|
335
|
-
* involved. Absent, the chip reads `search.geo.chosen_place`; never a
|
|
336
|
-
* coordinate, on either surface.
|
|
337
|
-
*/
|
|
338
|
-
readonly geoLabel?: ReactNode;
|
|
339
323
|
/** Open the whole panel — the leading chip's action. The page owns that
|
|
340
324
|
* sheet, because the page is the surface it covers. */
|
|
341
325
|
readonly onOpenAll: () => void;
|
|
@@ -346,16 +330,6 @@ export interface FilterChipsProps {
|
|
|
346
330
|
* they are always drawn.
|
|
347
331
|
*/
|
|
348
332
|
readonly maxRowChips?: number | null;
|
|
349
|
-
/**
|
|
350
|
-
* Draw the location chip. Default `true`.
|
|
351
|
-
*
|
|
352
|
-
* `false` for a surface that already states the location ABOVE this row —
|
|
353
|
-
* `<LocationSummaryLine>` is that surface, and the phone SERP mounts both.
|
|
354
|
-
* Together they printed two location controls one line apart — the summary
|
|
355
|
-
* sentence over the chip's own prompt — opening two different sheets over
|
|
356
|
-
* one filter, which is the same constraint asked about twice.
|
|
357
|
-
*/
|
|
358
|
-
readonly geoChip?: boolean;
|
|
359
333
|
}
|
|
360
334
|
|
|
361
335
|
/**
|
|
@@ -494,18 +468,6 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
494
468
|
props.maxRowChips === undefined ? CHIP_ROW_CAP : props.maxRowChips
|
|
495
469
|
);
|
|
496
470
|
|
|
497
|
-
const geo = state.geo;
|
|
498
|
-
const showGeoChip =
|
|
499
|
-
props.geoChip !== false &&
|
|
500
|
-
(props.renderGeoFilter !== undefined || geo !== undefined);
|
|
501
|
-
// Nothing applied: the chip is the FILTER's name ("Location"), because there
|
|
502
|
-
// is no constraint to describe yet. Applied: the host's name for the place,
|
|
503
|
-
// and failing that the sentence that admits the pair does not know it.
|
|
504
|
-
const geoChipLabel: ReactNode =
|
|
505
|
-
geo === undefined
|
|
506
|
-
? t(SEARCH_I18N_KEYS.geoTitle)
|
|
507
|
-
: (props.geoLabel ?? geoSummaryFallback(geo, t));
|
|
508
|
-
|
|
509
471
|
/*
|
|
510
472
|
* A row of one button is not a chip row.
|
|
511
473
|
*
|
|
@@ -518,7 +480,7 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
518
480
|
* there is nothing to state, the row states nothing and the surface above
|
|
519
481
|
* keeps its own door.
|
|
520
482
|
*/
|
|
521
|
-
const hasChips = showCategoryChip ||
|
|
483
|
+
const hasChips = showCategoryChip || ordered.length > 0;
|
|
522
484
|
if (!hasChips) return null;
|
|
523
485
|
|
|
524
486
|
return (
|
|
@@ -570,21 +532,6 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
570
532
|
</Button>
|
|
571
533
|
)}
|
|
572
534
|
|
|
573
|
-
{showGeoChip && (
|
|
574
|
-
<Button
|
|
575
|
-
style={CHIP}
|
|
576
|
-
shape="round"
|
|
577
|
-
type={geo !== undefined ? "primary" : "default"}
|
|
578
|
-
data-testid="search-chip-geo"
|
|
579
|
-
data-analytics="none"
|
|
580
|
-
data-analytics-reason="a filter is a read, not a flow step"
|
|
581
|
-
onClick={() => {
|
|
582
|
-
setOpen("geo");
|
|
583
|
-
}}
|
|
584
|
-
>
|
|
585
|
-
{geoChipLabel}
|
|
586
|
-
</Button>
|
|
587
|
-
)}
|
|
588
535
|
|
|
589
536
|
{/* One list, in the row's stated order — see this module's ordering
|
|
590
537
|
note. Rendering ranges and facets as two separate `.map`s is what
|
|
@@ -690,19 +637,6 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
690
637
|
<RangeFilterRow group={group} onApply={bag.setRange} />
|
|
691
638
|
);
|
|
692
639
|
})()}
|
|
693
|
-
|
|
694
|
-
{/* The LOCATION sheet is shared with `<LocationSummaryLine>` — the ref
|
|
695
|
-
puts a location control on both rows, and they must land in the same
|
|
696
|
-
place. See `geoSheet.tsx`. */}
|
|
697
|
-
<GeoSheet
|
|
698
|
-
open={open === "geo"}
|
|
699
|
-
onClose={close}
|
|
700
|
-
testIds={CHIP_GEO_TEST_IDS}
|
|
701
|
-
{...(props.renderGeoFilter !== undefined
|
|
702
|
-
? { renderGeoFilter: props.renderGeoFilter }
|
|
703
|
-
: {})}
|
|
704
|
-
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
705
|
-
/>
|
|
706
640
|
</>
|
|
707
641
|
);
|
|
708
642
|
}
|
|
@@ -710,6 +644,7 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
710
644
|
/** The mark on the "all filters" chip: something is applied. Not a count —
|
|
711
645
|
* the counts are on the chips beside it, and a number inside a 32px circle is
|
|
712
646
|
* a number nobody reads. */
|
|
647
|
+
|
|
713
648
|
function ActiveDot(): ReactElement {
|
|
714
649
|
return (
|
|
715
650
|
<span
|