@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.
- package/CHANGELOG.md +74 -0
- package/dist/api/generated/schema.d.ts +16 -0
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/DegradationNotice.d.ts +22 -13
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +7 -8
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -16
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +13 -23
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +24 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -0
- package/dist/default/LocationSummaryLine.js +83 -0
- package/dist/default/LocationSummaryLine.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +49 -5
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +19 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +6 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/geoSheet.d.ts +61 -0
- package/dist/default/geoSheet.d.ts.map +1 -0
- package/dist/default/geoSheet.js +63 -0
- package/dist/default/geoSheet.js.map +1 -0
- package/dist/default/index.d.ts +2 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +16 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +7 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useSearchCount.d.ts +44 -0
- package/dist/headless/useSearchCount.d.ts.map +1 -0
- package/dist/headless/useSearchCount.js +121 -0
- package/dist/headless/useSearchCount.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +2 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +13 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +15 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +2 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +32 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +33 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +23 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +59 -8
- package/dist/state/facets.js.map +1 -1
- package/dist/state/limits.d.ts +21 -0
- package/dist/state/limits.d.ts.map +1 -1
- package/dist/state/limits.js +21 -0
- package/dist/state/limits.js.map +1 -1
- package/dist/state/ranges.d.ts +45 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +55 -14
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +36 -3
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +16 -0
- package/src/default/DegradationNotice.tsx +30 -20
- package/src/default/FacetPanelPane.tsx +39 -23
- package/src/default/FilterChips.tsx +22 -56
- package/src/default/LocationSummaryLine.tsx +187 -0
- package/src/default/RangeFilterRow.tsx +61 -3
- package/src/default/SearchPage.tsx +33 -3
- package/src/default/geoSheet.tsx +161 -0
- package/src/default/index.ts +3 -0
- package/src/headless/FacetPanel.tsx +24 -0
- package/src/headless/useSearchCount.ts +189 -0
- package/src/i18n/es.ts +2 -0
- package/src/i18n/keys.ts +15 -0
- package/src/i18n/ru.ts +2 -0
- package/src/index.ts +15 -1
- package/src/state/degradations.ts +56 -0
- package/src/state/facets.ts +96 -8
- package/src/state/limits.ts +23 -0
- 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
|
|
16
|
-
* None of them is optional behaviour in disguise — every one
|
|
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
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -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
|
+
}
|