@stapel/search-react 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/api/generated/schema.d.ts +16 -0
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/DegradationNotice.d.ts +22 -13
  5. package/dist/default/DegradationNotice.d.ts.map +1 -1
  6. package/dist/default/DegradationNotice.js +7 -8
  7. package/dist/default/DegradationNotice.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +27 -16
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/FilterChips.d.ts.map +1 -1
  12. package/dist/default/FilterChips.js +13 -23
  13. package/dist/default/FilterChips.js.map +1 -1
  14. package/dist/default/LocationSummaryLine.d.ts +24 -0
  15. package/dist/default/LocationSummaryLine.d.ts.map +1 -0
  16. package/dist/default/LocationSummaryLine.js +83 -0
  17. package/dist/default/LocationSummaryLine.js.map +1 -0
  18. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  19. package/dist/default/RangeFilterRow.js +49 -5
  20. package/dist/default/RangeFilterRow.js.map +1 -1
  21. package/dist/default/SearchPage.d.ts +19 -0
  22. package/dist/default/SearchPage.d.ts.map +1 -1
  23. package/dist/default/SearchPage.js +6 -6
  24. package/dist/default/SearchPage.js.map +1 -1
  25. package/dist/default/geoSheet.d.ts +61 -0
  26. package/dist/default/geoSheet.d.ts.map +1 -0
  27. package/dist/default/geoSheet.js +63 -0
  28. package/dist/default/geoSheet.js.map +1 -0
  29. package/dist/default/index.d.ts +2 -0
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +1 -0
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/headless/FacetPanel.d.ts +16 -0
  34. package/dist/headless/FacetPanel.d.ts.map +1 -1
  35. package/dist/headless/FacetPanel.js +7 -0
  36. package/dist/headless/FacetPanel.js.map +1 -1
  37. package/dist/headless/useSearchCount.d.ts +44 -0
  38. package/dist/headless/useSearchCount.d.ts.map +1 -0
  39. package/dist/headless/useSearchCount.js +121 -0
  40. package/dist/headless/useSearchCount.js.map +1 -0
  41. package/dist/i18n/es.d.ts.map +1 -1
  42. package/dist/i18n/es.js +2 -0
  43. package/dist/i18n/es.js.map +1 -1
  44. package/dist/i18n/keys.d.ts +13 -0
  45. package/dist/i18n/keys.d.ts.map +1 -1
  46. package/dist/i18n/keys.js +15 -0
  47. package/dist/i18n/keys.js.map +1 -1
  48. package/dist/i18n/ru.d.ts.map +1 -1
  49. package/dist/i18n/ru.js +2 -0
  50. package/dist/i18n/ru.js.map +1 -1
  51. package/dist/index.d.ts +6 -4
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +4 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/state/degradations.d.ts +32 -0
  56. package/dist/state/degradations.d.ts.map +1 -1
  57. package/dist/state/degradations.js +33 -0
  58. package/dist/state/degradations.js.map +1 -1
  59. package/dist/state/facets.d.ts +23 -0
  60. package/dist/state/facets.d.ts.map +1 -1
  61. package/dist/state/facets.js +59 -8
  62. package/dist/state/facets.js.map +1 -1
  63. package/dist/state/limits.d.ts +21 -0
  64. package/dist/state/limits.d.ts.map +1 -1
  65. package/dist/state/limits.js +21 -0
  66. package/dist/state/limits.js.map +1 -1
  67. package/dist/state/ranges.d.ts +45 -3
  68. package/dist/state/ranges.d.ts.map +1 -1
  69. package/dist/state/ranges.js +55 -14
  70. package/dist/state/ranges.js.map +1 -1
  71. package/llms.txt +3 -2
  72. package/manifest.json +36 -3
  73. package/nav-manifest.json +1 -1
  74. package/package.json +6 -6
  75. package/src/analytics/generated/events.json +1 -1
  76. package/src/api/generated/schema.ts +16 -0
  77. package/src/default/DegradationNotice.tsx +30 -20
  78. package/src/default/FacetPanelPane.tsx +39 -23
  79. package/src/default/FilterChips.tsx +22 -56
  80. package/src/default/LocationSummaryLine.tsx +187 -0
  81. package/src/default/RangeFilterRow.tsx +61 -3
  82. package/src/default/SearchPage.tsx +33 -3
  83. package/src/default/geoSheet.tsx +161 -0
  84. package/src/default/index.ts +3 -0
  85. package/src/headless/FacetPanel.tsx +24 -0
  86. package/src/headless/useSearchCount.ts +189 -0
  87. package/src/i18n/es.ts +2 -0
  88. package/src/i18n/keys.ts +15 -0
  89. package/src/i18n/ru.ts +2 -0
  90. package/src/index.ts +15 -1
  91. package/src/state/degradations.ts +56 -0
  92. package/src/state/facets.ts +96 -8
  93. package/src/state/limits.ts +23 -0
  94. package/src/state/ranges.ts +85 -15
@@ -12,9 +12,9 @@
12
12
  * The seams a storefront fills: `renderCard` (a `<ListingCard>`),
13
13
  * `categoryFeatures` (from categories-react, for facet labels and range rows),
14
14
  * `renderCategoryFilter` / `renderGeoFilter` (controls other pairs own),
15
- * `footer` (the container's own chrome), `filtersHeader` and `resultsHeading`.
16
- * None of them is optional behaviour in disguise — every one has a working
17
- * default or a visible placeholder.
15
+ * `footer` (the container's own chrome), `filtersHeader`, `resultsHeader` and
16
+ * `resultsHeading`. None of them is optional behaviour in disguise — every one
17
+ * has a working default or a visible placeholder.
18
18
  *
19
19
  * ── The screen could not start a search ───────────────────────────────────
20
20
  *
@@ -185,6 +185,25 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
185
185
  * sense that matters and not a decoration bolted on top.
186
186
  */
187
187
  readonly filtersHeader?: ReactNode;
188
+ /**
189
+ * The row ABOVE the chips and the results — where `<LocationSummaryLine>`
190
+ * goes on the phone SERP.
191
+ *
192
+ * A NEW slot rather than a reuse, and the existing four were each checked
193
+ * first: `filtersHeader` is inside the filter panel (it is a filter, and on
194
+ * a phone it is behind the sheet, which is precisely where a location
195
+ * summary must NOT be); `breadcrumb` renders in the right place but names a
196
+ * walk up the CATEGORY tree, and a host wanting a trail AND a location row
197
+ * would have to choose; `resultsHeading` and the pane's `toolbar` are inside
198
+ * the results pane, below the chips. Nothing sat between "the search box"
199
+ * and "the filters", and that gap is exactly the ref's location row.
200
+ *
201
+ * It is rendered in the page's vertical stack, so it spans the full width in
202
+ * BOTH layouts — above the chip row on a phone, above the two columns on a
203
+ * desktop. That is deliberate: whatever states where a search is looking
204
+ * describes the whole page, not the results column of it.
205
+ */
206
+ readonly resultsHeader?: ReactNode;
188
207
  /** What this surface calls its result list. See
189
208
  * {@link SearchResultsPaneProps.heading}. */
190
209
  readonly resultsHeading?: ReactNode;
@@ -261,6 +280,7 @@ interface SearchPageBodyProps {
261
280
  readonly geoLabel?: ReactNode;
262
281
  readonly footer?: ReactNode;
263
282
  readonly filtersHeader?: ReactNode;
283
+ readonly resultsHeader?: ReactNode;
264
284
  readonly resultsHeading?: ReactNode;
265
285
  readonly degradationNotice?: DegradationNoticeVariant;
266
286
  readonly filtersLayout?: SearchFiltersLayout;
@@ -414,6 +434,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
414
434
  )}
415
435
  <UrlIssueNotice />
416
436
 
437
+ {/* Above the chips in the sheet layout and above the columns in the
438
+ other one — see {@link SearchPageProps.resultsHeader}. `?? null` is
439
+ the written decision, not an oversight: a page with nothing to say
440
+ about location says nothing rather than reserving a blank row. */}
441
+ {props.resultsHeader !== undefined && (
442
+ <div data-testid="search-results-header">{props.resultsHeader}</div>
443
+ )}
444
+
417
445
  {showFilters && layout === "sheet" ? (
418
446
  <>
419
447
  {/* The phone's filter row. It REPLACES the full-width "Filters (3)"
@@ -492,6 +520,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
492
520
  defaultGeo,
493
521
  footer,
494
522
  filtersHeader,
523
+ resultsHeader,
495
524
  resultsHeading,
496
525
  degradationNotice,
497
526
  filtersLayout,
@@ -521,6 +550,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
521
550
  {...(geoLabel !== undefined ? { geoLabel } : {})}
522
551
  {...(footer !== undefined ? { footer } : {})}
523
552
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
553
+ {...(resultsHeader !== undefined ? { resultsHeader } : {})}
524
554
  {...(resultsHeading !== undefined ? { resultsHeading } : {})}
525
555
  {...(degradationNotice !== undefined ? { degradationNotice } : {})}
526
556
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The LOCATION sheet — one implementation, two doors.
3
+ *
4
+ * `<FilterChips>`'s geo chip opened it and `<LocationSummaryLine>` opens it
5
+ * too: on the ref's SERP both controls say where the search is centred, one
6
+ * on the summary row and one in the chip strip, and a person tapping either
7
+ * must land in the same place. Two copies of a bottom sheet is two places for
8
+ * "clear the location" to behave differently, so the sheet is a component and
9
+ * the two surfaces are two `open` flags on it.
10
+ *
11
+ * ── What the sheet can do without a geocoder, and what it cannot ───────────
12
+ *
13
+ * SETTING a centre needs a map and a place-name lookup, which belong to
14
+ * `geo-react` and to the deployment: that is `renderGeoFilter`, and when it is
15
+ * unfilled the gap is NAMED (`SlotPlaceholder`) rather than left as a blank
16
+ * area under the sheet's title. ADJUSTING or CLEARING a location the URL
17
+ * already carries needs neither — `lat`/`lon`/`radius_km` are numbers this
18
+ * pair owns — which is why the sheet still opens usefully on a shared link
19
+ * with no slot wired.
20
+ *
21
+ * NAMING the place is a third thing and it is the host's: this package holds
22
+ * two coordinates and has no way to turn them into "Berlin Mitte", so it
23
+ * prints the name it was handed (`geoLabel`) or says that a place is chosen —
24
+ * never the numbers. `test/geo.test.tsx` asserts no digit of the point reaches
25
+ * the DOM, on either surface.
26
+ */
27
+ import type { ReactElement, ReactNode } from "react";
28
+ import { Button, Flex, Typography } from "antd";
29
+ import { SkinDialog } from "@stapel/tokens-antd/skin";
30
+ import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
31
+ import { spacing } from "@stapel/tokens";
32
+ import { useAppliedCount } from "../headless/useAppliedCount.js";
33
+ import { useSearchState } from "../headless/SearchStateProvider.js";
34
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
35
+ import { geoSummaryFallback } from "./FacetPanelPane.js";
36
+ import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
37
+
38
+ /** The test ids a surface gives its own copy of the sheet — the chip row and
39
+ * the summary line each keep the names their tests already know. */
40
+ export interface GeoSheetTestIds {
41
+ readonly sheet: string;
42
+ readonly apply: string;
43
+ readonly slot: string;
44
+ readonly summary: string;
45
+ readonly clear: string;
46
+ }
47
+
48
+ /** The chip row's door. These names predate the split and are what
49
+ * `test/geo.test.tsx` already asks for, so they travel with the sheet rather
50
+ * than being renamed by a refactor nobody asked to observe. */
51
+ export const CHIP_GEO_TEST_IDS: GeoSheetTestIds = {
52
+ sheet: "filter-chip-sheet-geo",
53
+ apply: "filter-chip-apply-geo",
54
+ slot: "search-chip-geo-slot",
55
+ summary: "search-chip-geo-summary",
56
+ clear: "search-chip-geo-clear",
57
+ };
58
+
59
+ /** The summary line's door — a different set, so a page holding both rows
60
+ * never hands a test two elements under one name. */
61
+ export const SUMMARY_GEO_TEST_IDS: GeoSheetTestIds = {
62
+ sheet: "search-location-sheet",
63
+ apply: "search-location-apply",
64
+ slot: "search-location-slot",
65
+ summary: "search-location-sheet-summary",
66
+ clear: "search-location-clear",
67
+ };
68
+
69
+ export interface GeoSheetProps {
70
+ readonly open: boolean;
71
+ readonly onClose: () => void;
72
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
73
+ readonly geoLabel?: ReactNode;
74
+ readonly testIds: GeoSheetTestIds;
75
+ }
76
+
77
+ /**
78
+ * "Show 25 results", not "Show results".
79
+ *
80
+ * The results are BEHIND this sheet, so the button that closes it is the only
81
+ * place a person learns what their choice did. When the engine cannot say how
82
+ * many there are it says nothing rather than a number it made up.
83
+ */
84
+ export function useApplyLabel(): string {
85
+ const t = useT();
86
+ const tPlural = useTPlural();
87
+ const applied = useAppliedCount();
88
+ return applied.count === null || applied.kind === "unknown"
89
+ ? t(SEARCH_I18N_KEYS.filtersApply)
90
+ : tPlural(
91
+ applied.kind === "at_least"
92
+ ? SEARCH_I18N_KEYS.filtersShowCountAtLeast
93
+ : SEARCH_I18N_KEYS.filtersShowCount,
94
+ { count: applied.count }
95
+ );
96
+ }
97
+
98
+ export function GeoSheet(props: GeoSheetProps): ReactElement {
99
+ const t = useT();
100
+ const { state, setGeo } = useSearchState();
101
+ const applyLabel = useApplyLabel();
102
+ const geo = state.geo;
103
+ const summary: ReactNode =
104
+ geo === undefined
105
+ ? null
106
+ : (props.geoLabel ?? geoSummaryFallback(geo, t));
107
+
108
+ return (
109
+ <SkinDialog
110
+ open={props.open}
111
+ onClose={props.onClose}
112
+ title={t(SEARCH_I18N_KEYS.geoTitle)}
113
+ dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
114
+ data-testid={props.testIds.sheet}
115
+ footer={
116
+ <Button
117
+ block
118
+ type="primary"
119
+ data-testid={props.testIds.apply}
120
+ data-analytics="none"
121
+ data-analytics-reason="the filter is already applied; this closes the sheet"
122
+ onClick={props.onClose}
123
+ >
124
+ {applyLabel}
125
+ </Button>
126
+ }
127
+ >
128
+ <Flex vertical gap={spacing[3]}>
129
+ {props.renderGeoFilter?.({
130
+ value: geo,
131
+ onChange: (next) => {
132
+ setGeo(next);
133
+ },
134
+ }) ?? (
135
+ <SlotPlaceholder
136
+ name="renderGeoFilter"
137
+ data-testid={props.testIds.slot}
138
+ />
139
+ )}
140
+ {geo !== undefined && (
141
+ <>
142
+ <Typography.Text type="secondary" data-testid={props.testIds.summary}>
143
+ {summary}
144
+ </Typography.Text>
145
+ <Button
146
+ style={{ alignSelf: "flex-start" }}
147
+ data-testid={props.testIds.clear}
148
+ data-analytics="none"
149
+ data-analytics-reason="a filter is a read, not a flow step"
150
+ onClick={() => {
151
+ setGeo(null);
152
+ }}
153
+ >
154
+ {t(SEARCH_I18N_KEYS.geoClear)}
155
+ </Button>
156
+ </>
157
+ )}
158
+ </Flex>
159
+ </SkinDialog>
160
+ );
161
+ }
@@ -51,6 +51,9 @@ export type {
51
51
  export { FilterChips, CHIP_ROW_CLASS, CHIP_ROW_STYLE_HREF, chipRowCss } from "./FilterChips.js";
52
52
  export type { FilterChipsProps } from "./FilterChips.js";
53
53
 
54
+ export { LocationSummaryLine } from "./LocationSummaryLine.js";
55
+ export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
56
+
54
57
  export {
55
58
  FacetGroupControl,
56
59
  facetGroupShape,
@@ -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,
@@ -0,0 +1,189 @@
1
+ /**
2
+ * `useSearchCount(state)` — how many results a search state WOULD return,
3
+ * without showing any of them.
4
+ *
5
+ * A quick-search panel's button ("Show 128 listings") has to know the total
6
+ * for a state that is not on screen and not in the URL: the person is still
7
+ * composing it. `<SearchResults>` cannot answer that — it reads the committed
8
+ * URL state — and `useAppliedCount` deliberately reads the page already in
9
+ * cache. So this is a read of its own, over a state the caller hands in.
10
+ *
11
+ * ── THE GAP THIS HOOK RIDES OVER, STATED ──────────────────────────────────
12
+ *
13
+ * **stapel-search has no count-only endpoint.** `SearchApi` is `query`,
14
+ * `suggest` and `ranking`; nothing answers "how many" without also assembling
15
+ * a page. So this hook issues the ORDINARY query with the smallest page the
16
+ * endpoint will serve ({@link SEARCH_COUNT_PAGE_SIZE}) and facet counting
17
+ * switched off, and reads `count` out of the envelope it gets back. That is a
18
+ * deliberate choice with a cost: the engine still ranks the candidate set and
19
+ * the backend still serializes one row, so a count is roughly as expensive as
20
+ * a search. It is not a bug to be worked around client-side — a debounce is
21
+ * the mitigation, not a fix.
22
+ *
23
+ * FOLLOW-UP (stapel-search): a `GET /count` verb that answers the envelope's
24
+ * three count fields (`count`, `count_is_lower_bound`, `exact_total`) plus
25
+ * `degraded[]` and nothing else. When it lands, this hook's body changes and
26
+ * its signature does not.
27
+ *
28
+ * ── What it asks, and what it deliberately does not ───────────────────────
29
+ *
30
+ * The state is normalized before it goes out, and each removal is a rule:
31
+ *
32
+ * - `facets: "off"` — a count does not draw a facet panel, and counting
33
+ * facets is the expensive half of the request.
34
+ * - `limit: 1` — the smallest page. Zero is not a page size the endpoint
35
+ * documents, and asking for the default 24 would serialize 24 cards nobody
36
+ * renders.
37
+ * - `anchor` / `direction` dropped — a cursor asks about a PAGE, and the
38
+ * count is about the whole result set. Keeping one would also cache the
39
+ * same total once per page a person walked through.
40
+ * - `sort` dropped — the total does not depend on the order, and keeping it
41
+ * would miss the cache every time somebody changed the sort.
42
+ *
43
+ * Everything else — `q`, `category`, `owner`, filters, ranges, geo, `lang` —
44
+ * is exactly what a real search would carry, because those are what change the
45
+ * answer.
46
+ *
47
+ * ── The count is a LoadState, and the number is not always a number ────────
48
+ *
49
+ * The ready value carries `countKind`: `"exact"` is a total, `"at_least"` is
50
+ * a floor, `"unknown"` is the engine declining to say — and `count: null`
51
+ * under `"unknown"` is never `0`. A skin that renders the floor as a total is
52
+ * the defect `state/degradations.ts` exists to prevent, so the kind travels
53
+ * with the number rather than beside it.
54
+ */
55
+ import { useEffect, useRef, useState } from "react";
56
+ import { loadStateFromQuery, mapLoad } from "@stapel/core";
57
+ import type { LoadState } from "@stapel/core";
58
+ import { searchQueryParams } from "../api/searchApi.js";
59
+ import type { SearchQueryState } from "../api/types.js";
60
+ import { useSearchQuery } from "../model/queries.js";
61
+ import {
62
+ SEARCH_COUNT_DEBOUNCE_MS,
63
+ SEARCH_COUNT_PAGE_SIZE,
64
+ } from "../state/limits.js";
65
+ import { countKind, parseDegradations } from "../state/degradations.js";
66
+ import type { SearchCountKind } from "../state/degradations.js";
67
+
68
+ /** What a count read answers. */
69
+ export interface SearchCount {
70
+ /** `null` is "the engine cannot say" — a real state, and NOT zero. */
71
+ readonly count: number | null;
72
+ /** Whether that number may be spoken as a total, as a floor, or at all. */
73
+ readonly kind: SearchCountKind;
74
+ }
75
+
76
+ /**
77
+ * The state a count is asked about: a doc type plus as much or as little of a
78
+ * search as the caller has composed. Partial on purpose — a panel holding one
79
+ * chosen facet is a legitimate question, and making it build a full
80
+ * {@link SearchQueryState} would mean spelling `filters: {}` and `ranges: {}`
81
+ * at every call site.
82
+ */
83
+ export type SearchCountState = Pick<SearchQueryState, "type"> &
84
+ Partial<Omit<SearchQueryState, "type">>;
85
+
86
+ export interface UseSearchCountOptions {
87
+ /**
88
+ * Ask at all. Default `true`. `false` holds the hook at `loading` (the
89
+ * fleet's shape for "no answer yet"), so a panel that is still resolving its
90
+ * category does not ask about a state it has not finished building.
91
+ */
92
+ readonly enabled?: boolean;
93
+ /**
94
+ * Quiet time before a CHANGED state is asked about (default
95
+ * {@link SEARCH_COUNT_DEBOUNCE_MS}). The first state is asked about
96
+ * immediately — a panel that opens should not wait a quarter second to say
97
+ * its number — and every change after that is coalesced, because a count
98
+ * rides the full query (see this file's header) and a request per keystroke
99
+ * is how a throttled endpoint starts answering 429.
100
+ *
101
+ * `0` disables it, for a caller whose state only changes on a commit.
102
+ */
103
+ readonly debounceMs?: number;
104
+ }
105
+
106
+ /**
107
+ * The wire form of a count question — exported so a test (or a host building
108
+ * its own key) can see exactly what is asked, rather than inferring it.
109
+ */
110
+ export function countQueryState(state: SearchCountState): SearchQueryState {
111
+ const {
112
+ anchor: _anchor,
113
+ direction: _direction,
114
+ sort: _sort,
115
+ ...rest
116
+ } = state;
117
+ return {
118
+ q: "",
119
+ filters: {},
120
+ ranges: {},
121
+ ...rest,
122
+ facets: "off",
123
+ limit: SEARCH_COUNT_PAGE_SIZE,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * A stable string for "is this the same question". Built from the same wire
129
+ * object the query key is built from, with the keys sorted, so two states
130
+ * assembled in a different order are one question and not two.
131
+ */
132
+ function questionOf(state: SearchQueryState): string {
133
+ const params = searchQueryParams(state);
134
+ return JSON.stringify(
135
+ Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
136
+ );
137
+ }
138
+
139
+ export function useSearchCount(
140
+ state: SearchCountState,
141
+ options: UseSearchCountOptions = {}
142
+ ): LoadState<SearchCount> {
143
+ const target = countQueryState(state);
144
+ const question = questionOf(target);
145
+ const debounceMs = options.debounceMs ?? SEARCH_COUNT_DEBOUNCE_MS;
146
+
147
+ // The state the timer will settle ON when it fires — always the newest one,
148
+ // not the one that started the timer. Typing "hond" then "honda" must ask
149
+ // about "honda" once, never about "hond" late.
150
+ const latest = useRef(target);
151
+ latest.current = target;
152
+
153
+ const [settled, setSettled] = useState<{
154
+ readonly question: string;
155
+ readonly state: SearchQueryState;
156
+ }>(() => ({ question, state: target }));
157
+
158
+ useEffect(() => {
159
+ if (settled.question === question) return undefined;
160
+ if (debounceMs <= 0) {
161
+ setSettled({ question, state: latest.current });
162
+ return undefined;
163
+ }
164
+ const handle = setTimeout(() => {
165
+ setSettled({ question, state: latest.current });
166
+ }, debounceMs);
167
+ return () => {
168
+ clearTimeout(handle);
169
+ };
170
+ }, [question, settled.question, debounceMs]);
171
+
172
+ // `useSearchQuery` keeps the previous answer on screen while the next one is
173
+ // in flight (`keepPreviousData`), which is what a counted button wants: the
174
+ // number belongs to the last state we actually asked about, and it does not
175
+ // blank between two of them.
176
+ const query = useSearchQuery(settled.state, {
177
+ enabled: options.enabled ?? true,
178
+ });
179
+
180
+ return mapLoad(loadStateFromQuery(query), (data) => ({
181
+ count: data.count,
182
+ kind: countKind(
183
+ data.count,
184
+ data.count_is_lower_bound,
185
+ data.exact_total,
186
+ parseDegradations(data.degraded)
187
+ ),
188
+ }));
189
+ }
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",
@@ -109,6 +110,7 @@ export const searchI18nBundleEs: I18nDictionary = {
109
110
  "search.geo.radius_km": "A menos de {km} km",
110
111
  "search.geo.radius_label": "Radio, km",
111
112
  "search.geo.clear": "En cualquier lugar",
113
+ "search.geo.everywhere": "Buscando en todas partes",
112
114
  "search.geo.box": "Dentro del área mostrada",
113
115
  "search.geo.chosen_place": "Un lugar elegido en el mapa",
114
116
 
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
@@ -151,6 +154,16 @@ export const SEARCH_I18N_KEYS = {
151
154
  geoRadiusKm: "search.geo.radius_km",
152
155
  geoRadiusLabel: "search.geo.radius_label",
153
156
  geoClear: "search.geo.clear",
157
+ /**
158
+ * What `<LocationSummaryLine>` says when NO location is applied.
159
+ *
160
+ * Not `search.geo.clear` reused: that is the label on a BUTTON that widens
161
+ * the search ("Anywhere"), and this is a STATEMENT about where the search is
162
+ * currently looking. The two are the same word in English and diverge the
163
+ * moment a translator treats one as an imperative — which is exactly the
164
+ * class of bug a shared key produces and nobody sees in the source locale.
165
+ */
166
+ geoEverywhere: "search.geo.everywhere",
154
167
  geoBox: "search.geo.box",
155
168
  /**
156
169
  * What a location constraint is called when nobody has given it a NAME.
@@ -289,6 +302,7 @@ export const searchI18nBundleEn: Record<string, string> = {
289
302
  "search.facets.not_counted": "not counted",
290
303
  "search.facets.drill_down_hint":
291
304
  "Each count is what you would get by choosing that value instead of the one you have.",
305
+ "search.range.price": "Price",
292
306
  "search.facets.range_from": "From",
293
307
  "search.facets.range_to": "To",
294
308
  "search.facets.range_apply": "Apply",
@@ -326,6 +340,7 @@ export const searchI18nBundleEn: Record<string, string> = {
326
340
  "search.geo.radius_km": "Within {km} km",
327
341
  "search.geo.radius_label": "Radius, km",
328
342
  "search.geo.clear": "Anywhere",
343
+ "search.geo.everywhere": "Searching everywhere",
329
344
  "search.geo.box": "Inside the shown area",
330
345
  "search.geo.chosen_place": "A chosen place on the map",
331
346
 
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": "Применить",
@@ -136,6 +137,7 @@ export const searchI18nBundleRu: I18nDictionary = {
136
137
  "search.geo.radius_km": "В радиусе {km} км",
137
138
  "search.geo.radius_label": "Радиус, км",
138
139
  "search.geo.clear": "Везде",
140
+ "search.geo.everywhere": "Ищем везде",
139
141
  "search.geo.box": "В показанной области",
140
142
  "search.geo.chosen_place": "Выбранное место на карте",
141
143
 
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";
@@ -113,6 +119,8 @@ export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
113
119
  export {
114
120
  SEARCH_BOX_DEBOUNCE_MS,
115
121
  SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
122
+ SEARCH_COUNT_DEBOUNCE_MS,
123
+ SEARCH_COUNT_PAGE_SIZE,
116
124
  SEARCH_DEFAULT_PAGE_SIZE,
117
125
  SEARCH_MAX_PAGE_SIZE,
118
126
  SEARCH_QUERY_MAX_CHARS,
@@ -152,6 +160,12 @@ export type { FacetPanelBag } from "./headless/FacetPanel.js";
152
160
  export { useAppliedSort } from "./headless/useAppliedSort.js";
153
161
  export { useSearchBox } from "./headless/useSearchBox.js";
154
162
  export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
163
+ export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
164
+ export type {
165
+ SearchCount,
166
+ SearchCountState,
167
+ UseSearchCountOptions,
168
+ } from "./headless/useSearchCount.js";
155
169
  export { RankingDisclosure } from "./headless/RankingDisclosure.js";
156
170
  export type { RankingDisclosureBag } from "./headless/RankingDisclosure.js";
157
171
 
@@ -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
+ }