@stapel/search-react 0.14.2 → 0.15.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 +20 -0
- package/dist/default/EmptyExits.d.ts +75 -0
- package/dist/default/EmptyExits.d.ts.map +1 -0
- package/dist/default/EmptyExits.js +100 -0
- package/dist/default/EmptyExits.js.map +1 -0
- package/dist/default/FilterChips.d.ts +4 -2
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +45 -5
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/SearchPage.d.ts +8 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -1
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +15 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +7 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- 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/useSearchBox.d.ts +16 -9
- package/dist/headless/useSearchBox.d.ts.map +1 -1
- package/dist/headless/useSearchBox.js +17 -12
- package/dist/headless/useSearchBox.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +5 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +15 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +21 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +5 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +2 -1
- package/manifest.json +24 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/default/EmptyExits.tsx +203 -0
- package/src/default/FilterChips.tsx +50 -7
- package/src/default/SearchPage.tsx +16 -0
- package/src/default/SearchResultsPane.tsx +33 -4
- package/src/default/index.ts +2 -0
- package/src/headless/useSearchBox.ts +17 -11
- package/src/i18n/es.ts +5 -0
- package/src/i18n/keys.ts +22 -0
- package/src/i18n/ru.ts +5 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<EmptyExits>` — the way out of a search that found nothing.
|
|
3
|
+
*
|
|
4
|
+
* ## What it replaces
|
|
5
|
+
*
|
|
6
|
+
* One sentence. "Nothing matches this search." — and then the page stopped:
|
|
7
|
+
* fifteen filter chips above it, a location the person never chose, and no
|
|
8
|
+
* control that widens anything. Measured on a live board where 2924
|
|
9
|
+
* leaves out of 2924 were empty, that sentence was the terminal state of the
|
|
10
|
+
* whole catalogue — and the two constraints most likely to have caused it (a
|
|
11
|
+
* 25 km radius the page applied on its own, and the narrowest segment of the
|
|
12
|
+
* category path) were exactly the two the person had never typed.
|
|
13
|
+
*
|
|
14
|
+
* ## The exits are derived, never invented
|
|
15
|
+
*
|
|
16
|
+
* Every button here is built out of state this pair already owns, and each
|
|
17
|
+
* one removes exactly one constraint:
|
|
18
|
+
*
|
|
19
|
+
* - **up a level** — drop the last segment of `category=`. The pair holds
|
|
20
|
+
* the path, so this needs no tree; what it cannot do is NAME the parent,
|
|
21
|
+
* which is why the label is "the level above" and not a guess.
|
|
22
|
+
* - **widen the radius** — multiply `geo.radiusKm`. Offered only when a
|
|
23
|
+
* radius is actually applied, because widening a search that was never
|
|
24
|
+
* narrowed is a button that changes nothing.
|
|
25
|
+
* - **search everywhere** — drop the geo constraint entirely. The single
|
|
26
|
+
* most likely culprit on this board: the SERP applied a default radius
|
|
27
|
+
* the category page did not, so the same category read 2 from the API and
|
|
28
|
+
* "0 listings, nothing found" on screen.
|
|
29
|
+
* - **drop one filter** — one button per applied facet slug and per applied
|
|
30
|
+
* range, labelled with the filter's own name. "The narrowest filter" is
|
|
31
|
+
* not a thing this pair can rank, so it does not pretend to: it offers
|
|
32
|
+
* every constraint separately and lets the person pick.
|
|
33
|
+
* - **clear everything** — the existing `search.facets.clear_all`, last,
|
|
34
|
+
* for the person who wants the catalogue back.
|
|
35
|
+
*
|
|
36
|
+
* ## Siblings come from the host
|
|
37
|
+
*
|
|
38
|
+
* "Show me the neighbouring sections, with their counts" is the exit a buyer
|
|
39
|
+
* most wants and the one this package must not build: walking the tree is
|
|
40
|
+
* `categories-react`'s job and the counts are the server's. So it is a SLOT
|
|
41
|
+
* (`renderExtra`), rendered above the derived exits — the host that already
|
|
42
|
+
* draws the category picker has the tree in hand, and a host that fills
|
|
43
|
+
* nothing still gets every exit above.
|
|
44
|
+
*
|
|
45
|
+
* ## Nothing to offer is a valid answer
|
|
46
|
+
*
|
|
47
|
+
* A bare, unfiltered, uncentred search that finds nothing has no exit to
|
|
48
|
+
* offer and this renders the sentence alone. A row of disabled buttons would
|
|
49
|
+
* be worse than the sentence.
|
|
50
|
+
*/
|
|
51
|
+
import type { ReactElement, ReactNode } from "react";
|
|
52
|
+
import { Button, Flex, Typography } from "antd";
|
|
53
|
+
import { useT } from "@stapel/core";
|
|
54
|
+
import { spacing } from "@stapel/tokens";
|
|
55
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
56
|
+
import { featureName } from "@stapel/attributes-react";
|
|
57
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
58
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How much wider "widen the radius" makes it.
|
|
62
|
+
*
|
|
63
|
+
* Four, not two: doubling a 25 km radius covers four times the area and
|
|
64
|
+
* usually still nothing on a thin board, so the person taps the same button
|
|
65
|
+
* three times. Four times the radius is sixteen times the area — one tap
|
|
66
|
+
* that visibly changes the answer, which is what makes it an exit rather
|
|
67
|
+
* than a nudge.
|
|
68
|
+
*/
|
|
69
|
+
export const RADIUS_WIDEN_FACTOR = 4;
|
|
70
|
+
|
|
71
|
+
export interface EmptyExitsProps {
|
|
72
|
+
/** The category schema, for naming an applied filter the way its chip does. */
|
|
73
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
74
|
+
/**
|
|
75
|
+
* The host's own exits — sibling sections with their counts, most usefully.
|
|
76
|
+
* Rendered first, above the derived ones.
|
|
77
|
+
*/
|
|
78
|
+
readonly renderExtra?: () => ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One exit, resolved to its label and the state change it performs. */
|
|
82
|
+
interface Exit {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly label: string;
|
|
85
|
+
readonly apply: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The parent of a `root/leaf` path, or `undefined` at the root. */
|
|
89
|
+
export function parentCategory(category: string | undefined): string | undefined {
|
|
90
|
+
if (category === undefined) return undefined;
|
|
91
|
+
const parts = category.split("/").filter((part) => part.length > 0);
|
|
92
|
+
if (parts.length <= 1) return undefined;
|
|
93
|
+
return parts.slice(0, -1).join("/");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function EmptyExits(props: EmptyExitsProps): ReactElement | null {
|
|
97
|
+
const t = useT();
|
|
98
|
+
const { state, setCategory, setGeo, setFilter, setRange, clearAll } =
|
|
99
|
+
useSearchState();
|
|
100
|
+
|
|
101
|
+
const bySlug = new Map<string, FeatureDef>();
|
|
102
|
+
for (const feature of props.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
|
|
103
|
+
const nameOf = (slug: string): string => {
|
|
104
|
+
const feature = bySlug.get(slug);
|
|
105
|
+
return feature === undefined ? slug : t(featureName(feature));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const exits: Exit[] = [];
|
|
109
|
+
|
|
110
|
+
const parent = parentCategory(state.category);
|
|
111
|
+
if (parent !== undefined) {
|
|
112
|
+
exits.push({
|
|
113
|
+
id: "up",
|
|
114
|
+
label: t(SEARCH_I18N_KEYS.emptyUpALevel),
|
|
115
|
+
apply: () => {
|
|
116
|
+
setCategory(parent);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const geo = state.geo;
|
|
122
|
+
if (geo !== undefined && geo.kind === "center" && geo.radiusKm !== undefined) {
|
|
123
|
+
const wider = Math.round(geo.radiusKm * RADIUS_WIDEN_FACTOR);
|
|
124
|
+
exits.push({
|
|
125
|
+
id: "widen",
|
|
126
|
+
label: t(SEARCH_I18N_KEYS.emptyWidenRadius, { km: wider }),
|
|
127
|
+
apply: () => {
|
|
128
|
+
setGeo({ ...geo, radiusKm: wider });
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (geo !== undefined) {
|
|
133
|
+
exits.push({
|
|
134
|
+
id: "anywhere",
|
|
135
|
+
label: t(SEARCH_I18N_KEYS.emptyAnywhere),
|
|
136
|
+
apply: () => {
|
|
137
|
+
setGeo(null);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const slug of Object.keys(state.filters)) {
|
|
143
|
+
if ((state.filters[slug] ?? []).length === 0) continue;
|
|
144
|
+
exits.push({
|
|
145
|
+
id: `filter:${slug}`,
|
|
146
|
+
label: t(SEARCH_I18N_KEYS.emptyDropFilter, { name: nameOf(slug) }),
|
|
147
|
+
apply: () => {
|
|
148
|
+
setFilter(slug, []);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
for (const slug of Object.keys(state.ranges)) {
|
|
153
|
+
exits.push({
|
|
154
|
+
id: `range:${slug}`,
|
|
155
|
+
label: t(SEARCH_I18N_KEYS.emptyDropFilter, { name: nameOf(slug) }),
|
|
156
|
+
apply: () => {
|
|
157
|
+
setRange(slug, null);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (exits.length > 1) {
|
|
163
|
+
exits.push({
|
|
164
|
+
id: "clear",
|
|
165
|
+
label: t(SEARCH_I18N_KEYS.facetsClearAll, { count: exits.length }),
|
|
166
|
+
apply: clearAll,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const extra = props.renderExtra?.();
|
|
171
|
+
if (exits.length === 0 && extra === undefined) return null;
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Flex
|
|
175
|
+
vertical
|
|
176
|
+
gap={spacing[3]}
|
|
177
|
+
data-testid="search-empty-exits"
|
|
178
|
+
style={{ marginBlockStart: spacing[4] }}
|
|
179
|
+
>
|
|
180
|
+
{extra}
|
|
181
|
+
{exits.length > 0 && (
|
|
182
|
+
<Flex vertical gap={spacing[2]}>
|
|
183
|
+
<Typography.Text type="secondary">
|
|
184
|
+
{t(SEARCH_I18N_KEYS.emptyExitsTitle)}
|
|
185
|
+
</Typography.Text>
|
|
186
|
+
<Flex wrap gap={spacing[2]}>
|
|
187
|
+
{exits.map((exit) => (
|
|
188
|
+
<Button
|
|
189
|
+
key={exit.id}
|
|
190
|
+
data-testid={`search-empty-exit-${exit.id}`}
|
|
191
|
+
data-analytics="none"
|
|
192
|
+
data-analytics-reason="widening a search is a read, not a flow step"
|
|
193
|
+
onClick={exit.apply}
|
|
194
|
+
>
|
|
195
|
+
{exit.label}
|
|
196
|
+
</Button>
|
|
197
|
+
))}
|
|
198
|
+
</Flex>
|
|
199
|
+
</Flex>
|
|
200
|
+
)}
|
|
201
|
+
</Flex>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -87,6 +87,22 @@
|
|
|
87
87
|
* counter ran out of plan slots at `MAX_FACET_FIELDS`, not that a person
|
|
88
88
|
* cannot filter by it. `r.<slug>` still answers for a skipped slug.
|
|
89
89
|
*
|
|
90
|
+
* ## The one case where band 3 IS deleted: a barren result
|
|
91
|
+
*
|
|
92
|
+
* There is exactly one piece of evidence strong enough, and the server sends
|
|
93
|
+
* it: the plan was counted (`facet_meta.counted`) over a candidate set of
|
|
94
|
+
* ZERO (`facet_meta.candidates`). Then every counted group is empty and drops
|
|
95
|
+
* out on its own, and the only chips left standing are the ones that never
|
|
96
|
+
* needed a count — the category's numeric attributes, drawn from the schema
|
|
97
|
+
* alone. Measured live on a cars leaf inside a 25 km radius that held no
|
|
98
|
+
* cars, that row read "Price / Colour / Availability / Steering side / Year / VIN or body
|
|
99
|
+
* number / Dealer offer "…" x9": the make and the model gone
|
|
100
|
+
* because they had nothing to count, a body number and nine dealer
|
|
101
|
+
* promotions in their place. An unapplied numeric axis over an empty set
|
|
102
|
+
* narrows nothing that is not already nothing; it states nothing and takes
|
|
103
|
+
* the room the empty state's exits need. APPLIED ones stay, always — a
|
|
104
|
+
* constraint keeps the control that removes it.
|
|
105
|
+
*
|
|
90
106
|
* ## Why the CATEGORY leads the row
|
|
91
107
|
*
|
|
92
108
|
* The owner's navigation model puts levels 1-2 of the catalogue on tiles and
|
|
@@ -207,7 +223,8 @@ function specApplied(spec: ChipSpec): boolean {
|
|
|
207
223
|
*/
|
|
208
224
|
export function orderChipFilters(
|
|
209
225
|
ranges: readonly RangeGroup[],
|
|
210
|
-
facets: readonly FacetGroup[]
|
|
226
|
+
facets: readonly FacetGroup[],
|
|
227
|
+
options: { readonly barren?: boolean } = {}
|
|
211
228
|
): readonly ChipSpec[] {
|
|
212
229
|
const specs: ChipSpec[] = [
|
|
213
230
|
...ranges.map(
|
|
@@ -217,7 +234,11 @@ export function orderChipFilters(
|
|
|
217
234
|
: { band: "attribute_range", range }
|
|
218
235
|
),
|
|
219
236
|
...facets.map((facet): ChipSpec => ({ band: "facet", facet })),
|
|
220
|
-
]
|
|
237
|
+
].filter(
|
|
238
|
+
(spec) =>
|
|
239
|
+
!(options.barren === true && spec.band === "attribute_range") ||
|
|
240
|
+
specApplied(spec)
|
|
241
|
+
);
|
|
221
242
|
return [...specs].sort((a, b) => {
|
|
222
243
|
const applied = Number(specApplied(b)) - Number(specApplied(a));
|
|
223
244
|
if (applied !== 0) return applied;
|
|
@@ -311,9 +332,17 @@ function chipLabel(group: FacetGroup, t: (key: string, p?: Record<string, unknow
|
|
|
311
332
|
* URL genuinely carries, which is the line the geo chip draws too: never print
|
|
312
333
|
* a coordinate, always print the name you actually have.
|
|
313
334
|
*/
|
|
314
|
-
export function categoryLeaf(path: string): string {
|
|
335
|
+
export function categoryLeaf(path: string): string | undefined {
|
|
315
336
|
const parts = path.split("/").filter((part) => part.length > 0);
|
|
316
|
-
|
|
337
|
+
const leaf = parts[parts.length - 1];
|
|
338
|
+
if (leaf === undefined) return undefined;
|
|
339
|
+
// A path of database IDS has no readable last segment, and printing one
|
|
340
|
+
// puts a green pill reading «165» on the SERP — which is what the live
|
|
341
|
+
// board did, permanently, on every category page. An id is not a
|
|
342
|
+
// half-answer the way a slug is: it names nothing a person could have
|
|
343
|
+
// typed. When the leaf is a bare number the chip falls back to the
|
|
344
|
+
// FILTER's own name, the same thing every other unset chip shows.
|
|
345
|
+
return /^\d+$/.test(leaf) ? undefined : leaf;
|
|
317
346
|
}
|
|
318
347
|
|
|
319
348
|
export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
@@ -392,9 +421,23 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
|
|
|
392
421
|
const categoryChipLabel: ReactNode =
|
|
393
422
|
category === undefined
|
|
394
423
|
? t(SEARCH_I18N_KEYS.categoryTitle)
|
|
395
|
-
: (props.categoryLabel ??
|
|
396
|
-
|
|
397
|
-
|
|
424
|
+
: (props.categoryLabel ??
|
|
425
|
+
categoryLeaf(category) ??
|
|
426
|
+
t(SEARCH_I18N_KEYS.categoryTitle));
|
|
427
|
+
|
|
428
|
+
// The server counted a plan over a candidate set of ZERO. Every counted
|
|
429
|
+
// facet is therefore empty and drops out of `groups` above — and what is
|
|
430
|
+
// left standing is the band that never needed a count: the numeric
|
|
431
|
+
// attributes drawn from the category schema alone. On the live cars leaf
|
|
432
|
+
// that row read "Price / Colour / Availability / Steering side / Year /
|
|
433
|
+
// VIN / Dealer offer x9" — the make and the model gone, a body-number field
|
|
434
|
+
// and nine dealer promotions in their place, on a page with no cars on it.
|
|
435
|
+
// An unapplied attribute range on an empty result narrows nothing that is
|
|
436
|
+
// not already nothing, so it states nothing and takes the room the exits
|
|
437
|
+
// need. Applied ones stay: a constraint always keeps the control that
|
|
438
|
+
// removes it.
|
|
439
|
+
const barren = bag.counted.length > 0 && bag.candidates === 0;
|
|
440
|
+
const ordered = orderChipFilters(ranges, groups, { barren });
|
|
398
441
|
|
|
399
442
|
const geo = state.geo;
|
|
400
443
|
const showGeoChip =
|
|
@@ -250,6 +250,14 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
250
250
|
* not the viewport — not an escape hatch for "I prefer a column on phones".
|
|
251
251
|
*/
|
|
252
252
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
253
|
+
/**
|
|
254
|
+
* The host's own exits from an empty result — sibling sections with their
|
|
255
|
+
* counts. A SLOT for the same reason `breadcrumb` is one: walking the tree
|
|
256
|
+
* belongs to `categories-react`. Everything the pair can derive on its own
|
|
257
|
+
* (up a level, widen the radius, search everywhere, drop one filter) is
|
|
258
|
+
* offered whether this is filled or not — see {@link EmptyExits}.
|
|
259
|
+
*/
|
|
260
|
+
readonly renderEmptyExits?: () => ReactNode;
|
|
253
261
|
/**
|
|
254
262
|
* Open the phone filter sheet on mount.
|
|
255
263
|
*
|
|
@@ -306,6 +314,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
306
314
|
interface SearchPageBodyProps {
|
|
307
315
|
readonly renderCard?: SearchCardRenderer;
|
|
308
316
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
317
|
+
readonly renderEmptyExits?: () => ReactNode;
|
|
309
318
|
readonly locale?: string;
|
|
310
319
|
readonly resolveFacetLabels?: FacetLabelResolver;
|
|
311
320
|
readonly searchBox?: boolean;
|
|
@@ -415,6 +424,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
415
424
|
<FacetPanelPane
|
|
416
425
|
{...(layout === "sheet" ? { heading: null } : {})}
|
|
417
426
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
427
|
+
{...(props.renderEmptyExits !== undefined
|
|
428
|
+
? { renderEmptyExits: props.renderEmptyExits }
|
|
429
|
+
: {})}
|
|
418
430
|
{...(locale !== undefined ? { locale } : {})}
|
|
419
431
|
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
420
432
|
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
@@ -479,6 +491,10 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
479
491
|
{...(props.degradationNotice !== undefined
|
|
480
492
|
? { degradationNotice: props.degradationNotice }
|
|
481
493
|
: {})}
|
|
494
|
+
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
495
|
+
{...(props.renderEmptyExits !== undefined
|
|
496
|
+
? { renderEmptyExits: props.renderEmptyExits }
|
|
497
|
+
: {})}
|
|
482
498
|
/>
|
|
483
499
|
);
|
|
484
500
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
visuallyHidden,
|
|
37
37
|
} from "@stapel/tokens-antd/skin";
|
|
38
38
|
import { spacing } from "@stapel/tokens";
|
|
39
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
39
40
|
import type { SearchItem } from "../api/types.js";
|
|
40
41
|
import { SearchResults } from "../headless/SearchResults.js";
|
|
41
42
|
import type { SearchResultsBag } from "../headless/SearchResults.js";
|
|
@@ -43,6 +44,7 @@ import { useScorerNames } from "../headless/useScorerNames.js";
|
|
|
43
44
|
import { SEARCH_WINDOW_EXCEEDED } from "../i18n/errorsMap.js";
|
|
44
45
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
45
46
|
import { DegradationNotice } from "./DegradationNotice.js";
|
|
47
|
+
import { EmptyExits } from "./EmptyExits.js";
|
|
46
48
|
import type { DegradationNoticeVariant } from "./DegradationNotice.js";
|
|
47
49
|
import { SearchResultCard } from "./SearchResultCard.js";
|
|
48
50
|
import type { SearchCardRenderer } from "./SearchResultCard.js";
|
|
@@ -191,6 +193,20 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
191
193
|
* viewport is not spent saying "Results" over a list of results.
|
|
192
194
|
*/
|
|
193
195
|
readonly header?: "banner" | "compact";
|
|
196
|
+
/**
|
|
197
|
+
* The category's feature schema, used ONLY to name an applied filter in the
|
|
198
|
+
* empty state's exits ("Without Brand" rather than "Without vendor").
|
|
199
|
+
* Absent, an exit falls back to the slug, which is still a removable
|
|
200
|
+
* constraint the person can read.
|
|
201
|
+
*/
|
|
202
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
203
|
+
/**
|
|
204
|
+
* The host's own exits from an empty result — sibling sections with their
|
|
205
|
+
* counts, most usefully. Rendered above the derived exits (see
|
|
206
|
+
* {@link EmptyExits}); walking the tree is `categories-react`'s job, so
|
|
207
|
+
* this pair offers the slot and never the tree.
|
|
208
|
+
*/
|
|
209
|
+
readonly renderEmptyExits?: () => ReactNode;
|
|
194
210
|
}
|
|
195
211
|
|
|
196
212
|
function Count(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
@@ -311,10 +327,23 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
311
327
|
skeletonRows={6}
|
|
312
328
|
onRetry={bag.refetch}
|
|
313
329
|
empty={
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
330
|
+
<>
|
|
331
|
+
<EmptyState
|
|
332
|
+
title={t(SEARCH_I18N_KEYS.resultsEmpty)}
|
|
333
|
+
testId="search-empty"
|
|
334
|
+
/>
|
|
335
|
+
{/* The sentence alone was the terminal state of a whole
|
|
336
|
+
catalogue. Every exit here removes exactly one
|
|
337
|
+
constraint the person did not necessarily choose. */}
|
|
338
|
+
<EmptyExits
|
|
339
|
+
{...(props.categoryFeatures !== undefined
|
|
340
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
341
|
+
: {})}
|
|
342
|
+
{...(props.renderEmptyExits !== undefined
|
|
343
|
+
? { renderExtra: props.renderEmptyExits }
|
|
344
|
+
: {})}
|
|
345
|
+
/>
|
|
346
|
+
</>
|
|
318
347
|
}
|
|
319
348
|
failed={(error) => {
|
|
320
349
|
// The window refusal is a DIFFERENT sentence from a failed
|
package/src/default/index.ts
CHANGED
|
@@ -58,6 +58,8 @@ export {
|
|
|
58
58
|
orderChipFilters,
|
|
59
59
|
} from "./FilterChips.js";
|
|
60
60
|
export type { ChipBand, ChipSpec, FilterChipsProps } from "./FilterChips.js";
|
|
61
|
+
export { EmptyExits, RADIUS_WIDEN_FACTOR, parentCategory } from "./EmptyExits.js";
|
|
62
|
+
export type { EmptyExitsProps } from "./EmptyExits.js";
|
|
61
63
|
|
|
62
64
|
export { LocationSummaryLine } from "./LocationSummaryLine.js";
|
|
63
65
|
export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
|
|
@@ -173,24 +173,30 @@ function degradedWith(answer: SuggestAnswer | undefined, literal: string): boole
|
|
|
173
173
|
/**
|
|
174
174
|
* The destinations worth offering out of one answer.
|
|
175
175
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
176
|
+
* **Every category the server ranked, in the order it ranked them.** Until
|
|
177
|
+
* 0.15 this dropped every row whose count was `0`, on the reasoning that an
|
|
178
|
+
* empty section is a dead end dressed as a destination. That reasoning holds
|
|
179
|
+
* for a stocked board and inverts on a young one, which is what the live
|
|
180
|
+
* measurement showed: 3036 leaves, ~100 listings, so 2924 leaves read zero
|
|
181
|
+
* and the filter deleted the answer. Typing a word with six real sections behind it in
|
|
182
|
+
* the catalogue produced NO PANEL AT ALL, and so did two other everyday
|
|
183
|
+
* words; the type-ahead told a buyer those sections do not exist, about
|
|
184
|
+
* sections that do. A catalogue you cannot navigate until somebody stocks it
|
|
185
|
+
* is worse than one that admits a section is empty.
|
|
180
186
|
*
|
|
181
|
-
* The
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
187
|
+
* The server already ranks stocked sections above empty ones
|
|
188
|
+
* (`stapel-search` 0.8: stock, then match quality, then count) so the useful
|
|
189
|
+
* rows still come first, and every row carries its own `count` for the
|
|
190
|
+
* surface to print honestly — "0 listings" is a fact a person can act on,
|
|
191
|
+
* an absent panel is not. Nothing is dropped here; a surface that wants a
|
|
192
|
+
* shorter list slices it.
|
|
186
193
|
*/
|
|
187
194
|
export function offerableCategories(
|
|
188
195
|
answer: SuggestAnswer | undefined
|
|
189
196
|
): readonly SuggestCategory[] {
|
|
190
197
|
const categories = answer?.categories;
|
|
191
198
|
if (categories === undefined || categories.length === 0) return NO_CATEGORIES;
|
|
192
|
-
|
|
193
|
-
return categories.filter((category) => category.count > 0);
|
|
199
|
+
return categories;
|
|
194
200
|
}
|
|
195
201
|
|
|
196
202
|
export function useSearchBox(options: UseSearchBoxOptions = {}): SearchBoxBag {
|
package/src/i18n/es.ts
CHANGED
|
@@ -102,6 +102,11 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
102
102
|
"search.filters.short": "Filtros",
|
|
103
103
|
"search.filters.chip_more": ", +{count}",
|
|
104
104
|
|
|
105
|
+
"search.empty.exits_title": "Prueba a ampliar la búsqueda",
|
|
106
|
+
"search.empty.up_a_level": "Subir un nivel",
|
|
107
|
+
"search.empty.widen_radius": "Buscar en un radio de {km} km",
|
|
108
|
+
"search.empty.anywhere": "Buscar en todas partes",
|
|
109
|
+
"search.empty.drop_filter": "Sin «{name}»",
|
|
105
110
|
"search.category.title": "Categoría",
|
|
106
111
|
"search.category.clear": "Buscar en todo el catálogo",
|
|
107
112
|
"search.category.current": "Buscando dentro de {path}",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -162,6 +162,23 @@ export const SEARCH_I18N_KEYS = {
|
|
|
162
162
|
* family — nothing is being counted in words. */
|
|
163
163
|
filtersChipMore: "search.filters.chip_more",
|
|
164
164
|
|
|
165
|
+
// ── the way OUT of a search that found nothing ───────────────────────────
|
|
166
|
+
/** Caption above the exit buttons. Not a second "nothing found" — that
|
|
167
|
+
* sentence has already been said; this one says what can be done next. */
|
|
168
|
+
emptyExitsTitle: "search.empty.exits_title",
|
|
169
|
+
/** Drop the last segment of the category path. Deliberately unnamed: the
|
|
170
|
+
* pair holds a path of slugs or ids and cannot name the parent, and a
|
|
171
|
+
* guessed name on a navigation control is worse than an honest direction. */
|
|
172
|
+
emptyUpALevel: "search.empty.up_a_level",
|
|
173
|
+
/** "Search within {km} km" — the widened radius, already multiplied. */
|
|
174
|
+
emptyWidenRadius: "search.empty.widen_radius",
|
|
175
|
+
/** Drop the location constraint. Distinct from `search.geo.clear`, which
|
|
176
|
+
* labels the same action inside the location picker: here it is one exit
|
|
177
|
+
* among several and has to name the constraint it removes. */
|
|
178
|
+
emptyAnywhere: "search.empty.anywhere",
|
|
179
|
+
/** "Without {name}" — one applied filter, named as its own chip names it. */
|
|
180
|
+
emptyDropFilter: "search.empty.drop_filter",
|
|
181
|
+
|
|
165
182
|
// ── category (a host slot, plus the control that removes it) ─────────────
|
|
166
183
|
categoryTitle: "search.category.title",
|
|
167
184
|
categoryClear: "search.category.clear",
|
|
@@ -390,6 +407,11 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
390
407
|
"search.filters.short": "Filters",
|
|
391
408
|
"search.filters.chip_more": ", +{count}",
|
|
392
409
|
|
|
410
|
+
"search.empty.exits_title": "Try widening the search",
|
|
411
|
+
"search.empty.up_a_level": "Go up a level",
|
|
412
|
+
"search.empty.widen_radius": "Search within {km} km",
|
|
413
|
+
"search.empty.anywhere": "Search anywhere",
|
|
414
|
+
"search.empty.drop_filter": "Without {name}",
|
|
393
415
|
"search.category.title": "Category",
|
|
394
416
|
"search.category.clear": "Search the whole catalogue",
|
|
395
417
|
"search.category.current": "Searching inside {path}",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -131,6 +131,11 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
131
131
|
"search.filters.short": "Фильтры",
|
|
132
132
|
"search.filters.chip_more": ", +{count}",
|
|
133
133
|
|
|
134
|
+
"search.empty.exits_title": "Попробуйте расширить поиск",
|
|
135
|
+
"search.empty.up_a_level": "Подняться на уровень выше",
|
|
136
|
+
"search.empty.widen_radius": "Искать в радиусе {km} км",
|
|
137
|
+
"search.empty.anywhere": "Искать везде",
|
|
138
|
+
"search.empty.drop_filter": "Без «{name}»",
|
|
134
139
|
"search.category.title": "Категория",
|
|
135
140
|
"search.category.clear": "Искать по всему каталогу",
|
|
136
141
|
"search.category.current": "Ищем внутри {path}",
|