@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/api/generated/schema.d.ts +96 -4
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +47 -6
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/types.js.map +1 -1
  7. package/dist/default/FacetPanelPane.d.ts +0 -19
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +45 -62
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/FilterChips.d.ts +1 -24
  12. package/dist/default/FilterChips.d.ts.map +1 -1
  13. package/dist/default/FilterChips.js +4 -18
  14. package/dist/default/FilterChips.js.map +1 -1
  15. package/dist/default/LocationSummaryLine.d.ts +12 -0
  16. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  17. package/dist/default/LocationSummaryLine.js +80 -6
  18. package/dist/default/LocationSummaryLine.js.map +1 -1
  19. package/dist/default/SearchPage.d.ts +6 -5
  20. package/dist/default/SearchPage.d.ts.map +1 -1
  21. package/dist/default/SearchPage.js +31 -21
  22. package/dist/default/SearchPage.js.map +1 -1
  23. package/dist/default/geoSheet.d.ts +12 -6
  24. package/dist/default/geoSheet.d.ts.map +1 -1
  25. package/dist/default/geoSheet.js +14 -3
  26. package/dist/default/geoSheet.js.map +1 -1
  27. package/dist/default/index.d.ts +4 -3
  28. package/dist/default/index.d.ts.map +1 -1
  29. package/dist/default/index.js +4 -3
  30. package/dist/default/index.js.map +1 -1
  31. package/dist/headless/FacetPanel.d.ts +29 -1
  32. package/dist/headless/FacetPanel.d.ts.map +1 -1
  33. package/dist/headless/FacetPanel.js +14 -0
  34. package/dist/headless/FacetPanel.js.map +1 -1
  35. package/dist/headless/SearchStateProvider.d.ts +57 -30
  36. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  37. package/dist/headless/SearchStateProvider.js +51 -36
  38. package/dist/headless/SearchStateProvider.js.map +1 -1
  39. package/dist/i18n/es.d.ts.map +1 -1
  40. package/dist/i18n/es.js +7 -0
  41. package/dist/i18n/es.js.map +1 -1
  42. package/dist/i18n/keys.d.ts +43 -0
  43. package/dist/i18n/keys.d.ts.map +1 -1
  44. package/dist/i18n/keys.js +51 -0
  45. package/dist/i18n/keys.js.map +1 -1
  46. package/dist/i18n/ru.d.ts.map +1 -1
  47. package/dist/i18n/ru.js +9 -0
  48. package/dist/i18n/ru.js.map +1 -1
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/state/degradations.d.ts +9 -0
  54. package/dist/state/degradations.d.ts.map +1 -1
  55. package/dist/state/degradations.js +13 -0
  56. package/dist/state/degradations.js.map +1 -1
  57. package/dist/state/facets.d.ts +3 -0
  58. package/dist/state/facets.d.ts.map +1 -1
  59. package/dist/state/facets.js +48 -1
  60. package/dist/state/facets.js.map +1 -1
  61. package/dist/state/urlState.d.ts +46 -3
  62. package/dist/state/urlState.d.ts.map +1 -1
  63. package/dist/state/urlState.js +69 -22
  64. package/dist/state/urlState.js.map +1 -1
  65. package/llms.txt +2 -2
  66. package/manifest.json +13 -2
  67. package/nav-manifest.json +1 -1
  68. package/package.json +5 -5
  69. package/src/analytics/generated/events.json +1 -1
  70. package/src/api/generated/schema.ts +96 -4
  71. package/src/api/types.ts +51 -5
  72. package/src/default/FacetPanelPane.tsx +52 -126
  73. package/src/default/FilterChips.tsx +5 -70
  74. package/src/default/LocationSummaryLine.tsx +157 -32
  75. package/src/default/SearchPage.tsx +57 -31
  76. package/src/default/geoSheet.tsx +46 -7
  77. package/src/default/index.ts +4 -3
  78. package/src/headless/FacetPanel.tsx +49 -1
  79. package/src/headless/SearchStateProvider.tsx +113 -66
  80. package/src/i18n/es.ts +11 -0
  81. package/src/i18n/keys.ts +55 -0
  82. package/src/i18n/ru.ts +15 -0
  83. package/src/index.ts +3 -0
  84. package/src/state/degradations.ts +14 -0
  85. package/src/state/facets.ts +47 -1
  86. 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 Stored row fields, so a result page costs one query. */
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 Radius around the centre. */
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
- export type SearchResponse = Schemas["SearchResponse"];
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
- /** The honesty block beside the counts: `approximate`, `candidates`,
25
- * `counted`, `skipped`. Rendered, never swallowed (spec §4.2). */
26
- export type FacetMeta = Schemas["FacetMeta"];
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
- * ── Two slots, and why they are slots ─────────────────────────────────────
25
+ * ── One slot, and why it is a slot ────────────────────────────────────────
26
26
  *
27
- * `renderCategoryFilter` and `renderGeoFilter` are filled by OTHER pairs:
28
- * choosing a category means walking the catalogue tree (`categories-react`),
29
- * and turning an address into a coordinate needs a geocoder and a map
30
- * (`geo-react`). Neither belongs in a search package, and importing either
31
- * would tie a storefront's search to a catalogue it might not have.
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 or a point must never leave a person with no way to widen it again.
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, isDevBuild, useT, useTPlural } from "@stapel/core";
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={<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-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 { geoSummaryFallback } from "./FacetPanelPane.js";
142
- import type {
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 || showGeoChip || ordered.length > 0;
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