@stapel/search-react 0.11.0 → 0.13.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 +178 -0
- package/MODULE.md +118 -3
- package/README.md +42 -0
- package/dist/api/generated/schema.d.ts +60 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/searchApi.d.ts +13 -6
- package/dist/api/searchApi.d.ts.map +1 -1
- package/dist/api/searchApi.js.map +1 -1
- package/dist/api/types.d.ts +107 -1
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +29 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +13 -2
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +3 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +88 -3
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +161 -13
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +1 -1
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchBox.d.ts.map +1 -1
- package/dist/default/SearchBox.js +74 -4
- package/dist/default/SearchBox.js.map +1 -1
- package/dist/default/SearchPage.d.ts +29 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +25 -10
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +83 -37
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +22 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +2 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +23 -0
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +29 -9
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/cardPhotos.d.ts +65 -0
- package/dist/default/cardPhotos.d.ts.map +1 -0
- package/dist/default/cardPhotos.js +127 -0
- package/dist/default/cardPhotos.js.map +1 -0
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +10 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +9 -1
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useFacetLabels.d.ts +39 -0
- package/dist/headless/useFacetLabels.d.ts.map +1 -0
- package/dist/headless/useFacetLabels.js +129 -0
- package/dist/headless/useFacetLabels.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +71 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -1
- package/dist/headless/useSearchBox.js +67 -2
- package/dist/headless/useSearchBox.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +10 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +55 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +67 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +12 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +6 -5
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +4 -3
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -0
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +7 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/runtime.d.ts +40 -3
- package/dist/model/runtime.d.ts.map +1 -1
- package/dist/model/runtime.js +2 -1
- package/dist/model/runtime.js.map +1 -1
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +3 -2
- package/dist/nav/manifest.js.map +1 -1
- package/dist/state/facets.d.ts +56 -15
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +101 -27
- package/dist/state/facets.js.map +1 -1
- package/llms.txt +3 -3
- package/manifest.json +33 -4
- package/nav-manifest.json +4 -3
- package/package.json +11 -11
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +60 -6
- package/src/api/searchApi.ts +13 -6
- package/src/api/types.ts +115 -1
- package/src/default/FacetPanelPane.tsx +16 -2
- package/src/default/FilterChips.tsx +285 -38
- package/src/default/LocationSummaryLine.tsx +6 -1
- package/src/default/SearchBox.tsx +134 -6
- package/src/default/SearchPage.tsx +79 -5
- package/src/default/SearchResultCard.tsx +157 -71
- package/src/default/SearchResultsPane.tsx +48 -11
- package/src/default/SortSelect.tsx +70 -9
- package/src/default/cardPhotos.ts +195 -0
- package/src/default/index.ts +10 -2
- package/src/headless/FacetPanel.tsx +20 -1
- package/src/headless/useFacetLabels.ts +172 -0
- package/src/headless/useSearchBox.ts +132 -5
- package/src/i18n/es.ts +11 -0
- package/src/i18n/keys.ts +69 -0
- package/src/i18n/ru.ts +13 -0
- package/src/index.ts +22 -3
- package/src/model/queries.ts +6 -5
- package/src/model/queryKeys.ts +23 -0
- package/src/model/runtime.ts +43 -4
- package/src/nav/manifest.ts +24 -2
- package/src/state/facets.ts +123 -39
|
@@ -16,6 +16,16 @@
|
|
|
16
16
|
* the catalogue — never a query log, because stapel-search keeps none — so
|
|
17
17
|
* every suggestion is a search that has results. The endpoint was typed and
|
|
18
18
|
* unreachable for three releases (S-8).
|
|
19
|
+
* - **Reaches the CATALOGUE, not only the titles.** stapel-search 0.7.0
|
|
20
|
+
* answers with CATEGORIES too, and they render as their own group above the
|
|
21
|
+
* terms: on a live classified deployment, typing a word that names a
|
|
22
|
+
* section answered listing titles and nothing else, so the search field
|
|
23
|
+
* could not reach a category at all. Each row prints the ancestor path
|
|
24
|
+
* (three catalogues have a "Shorts"; only the path tells them apart) and
|
|
25
|
+
* the live listing count, and follows the server's own `category` string.
|
|
26
|
+
* See
|
|
27
|
+
* `useSearchBox` for why the group is absent rather than empty when the
|
|
28
|
+
* server says it had no provider.
|
|
19
29
|
* - **Never grows a "no results" dropdown.** With nothing to suggest the menu
|
|
20
30
|
* stays shut: an empty popover under a half-typed word says "there is
|
|
21
31
|
* nothing" about a search that has not run.
|
|
@@ -28,16 +38,43 @@
|
|
|
28
38
|
* as everything else; a header outside one renders `<SearchPage>`'s copy.
|
|
29
39
|
*/
|
|
30
40
|
import { useState } from "react";
|
|
31
|
-
import type { ReactElement } from "react";
|
|
32
|
-
import { AutoComplete, Button, Flex, Input } from "antd";
|
|
33
|
-
import { useT } from "@stapel/core";
|
|
41
|
+
import type { ReactElement, ReactNode } from "react";
|
|
42
|
+
import { AutoComplete, Button, Flex, Input, Typography } from "antd";
|
|
43
|
+
import { useT, useTPlural } from "@stapel/core";
|
|
34
44
|
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
35
45
|
import { spacing } from "@stapel/tokens";
|
|
36
46
|
import { useSearchBox } from "../headless/useSearchBox.js";
|
|
37
47
|
import type { UseSearchBoxOptions } from "../headless/useSearchBox.js";
|
|
48
|
+
import type { SuggestCategory } from "../api/types.js";
|
|
38
49
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
39
50
|
import type { ThemeModeProp } from "./types.js";
|
|
40
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The separator between the ancestor names of a category row.
|
|
54
|
+
*
|
|
55
|
+
* The same one the vocabulary and hierarchical formatters use fleet-wide, so a
|
|
56
|
+
* path reads identically in the box, on a card and in the composer.
|
|
57
|
+
*/
|
|
58
|
+
const PATH_SEPARATOR = " / ";
|
|
59
|
+
|
|
60
|
+
/** Marks a menu row as a DESTINATION rather than a search term.
|
|
61
|
+
*
|
|
62
|
+
* The kind travels on the option OBJECT and never inside its `value`: a
|
|
63
|
+
* category's `category` string and a title prefix are both strings, and
|
|
64
|
+
* telling them apart by sniffing the text is how a term that happens to look
|
|
65
|
+
* like a path silently navigates somewhere. */
|
|
66
|
+
interface BoxOption {
|
|
67
|
+
readonly value: string;
|
|
68
|
+
readonly label?: ReactNode;
|
|
69
|
+
readonly stapelCategory?: SuggestCategory;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A labelled group of rows — antd renders the label as a group heading. */
|
|
73
|
+
interface BoxOptionGroup {
|
|
74
|
+
readonly label: ReactNode;
|
|
75
|
+
readonly options: BoxOption[];
|
|
76
|
+
}
|
|
77
|
+
|
|
41
78
|
export interface SearchBoxProps extends ThemeModeProp, UseSearchBoxOptions {
|
|
42
79
|
/** Override the placeholder — a category page says what it searches. */
|
|
43
80
|
readonly placeholder?: string;
|
|
@@ -47,6 +84,33 @@ export interface SearchBoxProps extends ThemeModeProp, UseSearchBoxOptions {
|
|
|
47
84
|
readonly autoFocus?: boolean;
|
|
48
85
|
}
|
|
49
86
|
|
|
87
|
+
/** One destination row: the ancestor path, and how many live listings are
|
|
88
|
+
* behind it. */
|
|
89
|
+
function CategoryRow(props: {
|
|
90
|
+
readonly category: SuggestCategory;
|
|
91
|
+
readonly showCount: boolean;
|
|
92
|
+
}): ReactElement {
|
|
93
|
+
const tPlural = useTPlural();
|
|
94
|
+
const { category } = props;
|
|
95
|
+
return (
|
|
96
|
+
<Flex
|
|
97
|
+
justify="space-between"
|
|
98
|
+
align="center"
|
|
99
|
+
gap={spacing[2]}
|
|
100
|
+
data-testid={`search-box-category-${category.category}`}
|
|
101
|
+
>
|
|
102
|
+
{/* The whole path, not the leaf: three catalogues have a "Shorts", and
|
|
103
|
+
the path is the only thing that says which one this is. */}
|
|
104
|
+
<span>{category.path.join(PATH_SEPARATOR)}</span>
|
|
105
|
+
{props.showCount && (
|
|
106
|
+
<Typography.Text type="secondary">
|
|
107
|
+
{tPlural(SEARCH_I18N_KEYS.boxCategoryCount, { count: category.count })}
|
|
108
|
+
</Typography.Text>
|
|
109
|
+
)}
|
|
110
|
+
</Flex>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
50
114
|
export function SearchBox(props: SearchBoxProps): ReactElement {
|
|
51
115
|
const t = useT();
|
|
52
116
|
const {
|
|
@@ -59,7 +123,62 @@ export function SearchBox(props: SearchBoxProps): ReactElement {
|
|
|
59
123
|
const box = useSearchBox(boxOptions);
|
|
60
124
|
const [open, setOpen] = useState(false);
|
|
61
125
|
|
|
62
|
-
const
|
|
126
|
+
const categoryOptions: BoxOption[] = box.categories.map(
|
|
127
|
+
(category) => ({
|
|
128
|
+
// The server's own string, verbatim — it is unique per row and it is
|
|
129
|
+
// exactly what the SERP's `category` parameter takes.
|
|
130
|
+
value: category.category,
|
|
131
|
+
label: (
|
|
132
|
+
<CategoryRow
|
|
133
|
+
category={category}
|
|
134
|
+
showCount={!box.categoryCountsUnknown}
|
|
135
|
+
/>
|
|
136
|
+
),
|
|
137
|
+
stapelCategory: category,
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
const termOptions: BoxOption[] = box.suggestions.map((value) => ({ value }));
|
|
141
|
+
|
|
142
|
+
/*
|
|
143
|
+
* Destinations FIRST, then terms.
|
|
144
|
+
*
|
|
145
|
+
* A classified's box is a navigation control before it is a text filter:
|
|
146
|
+
* the person who typed a section's name wants the section, and the titles
|
|
147
|
+
* that happen to contain the word are the fallback, not the answer. The
|
|
148
|
+
* server ranks the destinations by live listing count and this list keeps
|
|
149
|
+
* that order.
|
|
150
|
+
*
|
|
151
|
+
* The group is absent — not empty — when the server had no category
|
|
152
|
+
* provider, which falls out of `box.categories` being empty: a heading over
|
|
153
|
+
* nothing would be the box claiming the catalogue has no such section, a
|
|
154
|
+
* claim the answer never made. See `useSearchBox`.
|
|
155
|
+
*/
|
|
156
|
+
const options: (BoxOption | BoxOptionGroup)[] =
|
|
157
|
+
categoryOptions.length > 0
|
|
158
|
+
? [
|
|
159
|
+
{
|
|
160
|
+
label: (
|
|
161
|
+
<span data-testid="search-box-categories-heading">
|
|
162
|
+
{t(SEARCH_I18N_KEYS.boxCategories)}
|
|
163
|
+
</span>
|
|
164
|
+
),
|
|
165
|
+
options: categoryOptions,
|
|
166
|
+
},
|
|
167
|
+
...(termOptions.length > 0
|
|
168
|
+
? [
|
|
169
|
+
{
|
|
170
|
+
label: (
|
|
171
|
+
<span data-testid="search-box-terms-heading">
|
|
172
|
+
{t(SEARCH_I18N_KEYS.boxSuggestions)}
|
|
173
|
+
</span>
|
|
174
|
+
),
|
|
175
|
+
options: termOptions,
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
: []),
|
|
179
|
+
]
|
|
180
|
+
: termOptions;
|
|
181
|
+
const hasOptions = categoryOptions.length > 0 || termOptions.length > 0;
|
|
63
182
|
|
|
64
183
|
return (
|
|
65
184
|
<SkinTheme
|
|
@@ -75,10 +194,19 @@ export function SearchBox(props: SearchBoxProps): ReactElement {
|
|
|
75
194
|
// open state is the skin's, not antd's: the prop that reports the
|
|
76
195
|
// menu opening was renamed between antd 5 and 6 and this package
|
|
77
196
|
// supports both, so nothing here asks antd when to open.
|
|
78
|
-
open={open &&
|
|
197
|
+
open={open && hasOptions}
|
|
79
198
|
style={{ flex: 1, minWidth: 0 }}
|
|
80
|
-
onSelect={(value: string) => {
|
|
199
|
+
onSelect={(value: string, option: BoxOption | BoxOptionGroup) => {
|
|
81
200
|
setOpen(false);
|
|
201
|
+
// A destination navigates; a term searches. The kind is read off
|
|
202
|
+
// the option OBJECT, never sniffed out of the text — see
|
|
203
|
+
// `BoxOption`.
|
|
204
|
+
const category =
|
|
205
|
+
"stapelCategory" in option ? option.stapelCategory : undefined;
|
|
206
|
+
if (category !== undefined) {
|
|
207
|
+
box.chooseCategory(category);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
82
210
|
box.submit(value);
|
|
83
211
|
}}
|
|
84
212
|
onChange={(value: string) => {
|
|
@@ -71,6 +71,7 @@ import type { FeatureDef } from "@stapel/attributes-react";
|
|
|
71
71
|
import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
|
|
72
72
|
import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
|
|
73
73
|
import { useFacetPanel } from "../headless/FacetPanel.js";
|
|
74
|
+
import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
|
|
74
75
|
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
75
76
|
import type { ParseSearchStateOptions } from "../state/urlState.js";
|
|
76
77
|
import type { SearchGeo } from "../api/types.js";
|
|
@@ -147,13 +148,40 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
147
148
|
readonly renderCard?: SearchCardRenderer;
|
|
148
149
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
149
150
|
readonly locale?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Name the facet values neither the answer nor the category schema names —
|
|
153
|
+
* see {@link FacetLabelResolver}.
|
|
154
|
+
*
|
|
155
|
+
* Set once here and it reaches BOTH filter surfaces, the desktop panel and
|
|
156
|
+
* the phone chip row, the same way `geoLabel` does. It is the seam a
|
|
157
|
+
* `ref_select` facet needs: its config carries a pointer to a vocabulary and
|
|
158
|
+
* no option table, the vocabulary is a service this pair does not talk to,
|
|
159
|
+
* and a server older than stapel-search 0.4.0 sends no `facet_labels` to
|
|
160
|
+
* cover for it — so without this the chips print `apple` and `128-gb`.
|
|
161
|
+
*/
|
|
162
|
+
readonly resolveFacetLabels?: FacetLabelResolver;
|
|
150
163
|
/** Render the query box at the top. `false` for a container whose HEADER
|
|
151
164
|
* already mounts `<SearchBox>` — one box per screen, not two. */
|
|
152
165
|
readonly searchBox?: boolean;
|
|
153
166
|
/** BCP-47 tags this deployment indexes, for the language filter. */
|
|
154
167
|
readonly languages?: readonly string[];
|
|
155
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* The catalogue picker slot — see {@link FacetPanelPaneProps}.
|
|
170
|
+
*
|
|
171
|
+
* It reaches TWO surfaces: the filter panel's category row, and — on the
|
|
172
|
+
* phone — the LEADING chip of the filter row, which opens the same control
|
|
173
|
+
* in the same kind of sheet as every other chip. The owner's navigation
|
|
174
|
+
* model chooses levels 1-2 from tiles and everything deeper as a
|
|
175
|
+
* characteristic, and on a result list that is what a chip is.
|
|
176
|
+
*/
|
|
156
177
|
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
178
|
+
/**
|
|
179
|
+
* What the current category is CALLED — the chip's own text. The pair holds
|
|
180
|
+
* a path of slugs and no way to turn one into a catalogue name; absent, the
|
|
181
|
+
* chip states the path's last segment. See
|
|
182
|
+
* {@link FilterChipsProps.categoryLabel}.
|
|
183
|
+
*/
|
|
184
|
+
readonly categoryLabel?: ReactNode;
|
|
157
185
|
/** The location control slot (`geo-react`). */
|
|
158
186
|
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
159
187
|
/**
|
|
@@ -273,9 +301,11 @@ interface SearchPageBodyProps {
|
|
|
273
301
|
readonly renderCard?: SearchCardRenderer;
|
|
274
302
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
275
303
|
readonly locale?: string;
|
|
304
|
+
readonly resolveFacetLabels?: FacetLabelResolver;
|
|
276
305
|
readonly searchBox?: boolean;
|
|
277
306
|
readonly languages?: readonly string[];
|
|
278
307
|
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
308
|
+
readonly categoryLabel?: ReactNode;
|
|
279
309
|
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
280
310
|
readonly geoLabel?: ReactNode;
|
|
281
311
|
readonly footer?: ReactNode;
|
|
@@ -301,11 +331,12 @@ interface SearchPageBodyProps {
|
|
|
301
331
|
function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
302
332
|
const t = useT();
|
|
303
333
|
const tPlural = useTPlural();
|
|
304
|
-
const { categoryFeatures, locale, filtersHeader } = props;
|
|
334
|
+
const { categoryFeatures, locale, resolveFacetLabels, filtersHeader } = props;
|
|
305
335
|
const { state } = useSearchState();
|
|
306
336
|
const facets = useFacetPanel({
|
|
307
337
|
...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
308
338
|
...(locale !== undefined ? { locale } : {}),
|
|
339
|
+
...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {}),
|
|
309
340
|
});
|
|
310
341
|
const applied = useAppliedCount();
|
|
311
342
|
const surface = useDialogSurface();
|
|
@@ -379,6 +410,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
379
410
|
{...(layout === "sheet" ? { heading: null } : {})}
|
|
380
411
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
381
412
|
{...(locale !== undefined ? { locale } : {})}
|
|
413
|
+
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
382
414
|
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
383
415
|
{...(props.renderCategoryFilter !== undefined
|
|
384
416
|
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
@@ -392,9 +424,32 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
392
424
|
</Flex>
|
|
393
425
|
);
|
|
394
426
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
427
|
+
/*
|
|
428
|
+
* The toolbar over the results: how they are ARRANGED, how they are ORDERED,
|
|
429
|
+
* how many per page — and the surface's own action at the trailing end.
|
|
430
|
+
*
|
|
431
|
+
* TWO shapes, because at 390px the desktop shape is not a smaller version of
|
|
432
|
+
* itself, it is four stacked rows. The phone form is the reference's own
|
|
433
|
+
* sort row: the ordering at one end, the surface's action at the other, one
|
|
434
|
+
* line, nothing else. `pageSize` is already the surface's call; the view
|
|
435
|
+
* switch draws nothing for a single view and stays in the row for the
|
|
436
|
+
* surfaces that offer two.
|
|
437
|
+
*/
|
|
438
|
+
const phoneToolbar = layout === "sheet";
|
|
439
|
+
const toolbar = phoneToolbar ? (
|
|
440
|
+
<Flex
|
|
441
|
+
align="center"
|
|
442
|
+
justify="space-between"
|
|
443
|
+
gap={spacing[2]}
|
|
444
|
+
style={{ width: "100%" }}
|
|
445
|
+
>
|
|
446
|
+
<Flex align="center" gap={spacing[2]} style={{ minWidth: 0 }}>
|
|
447
|
+
<ViewSwitch views={views} value={view.id} onChange={changeView} />
|
|
448
|
+
<SortSelect compact />
|
|
449
|
+
</Flex>
|
|
450
|
+
{props.resultsAction}
|
|
451
|
+
</Flex>
|
|
452
|
+
) : (
|
|
398
453
|
<Flex align="center" wrap gap={spacing[3]}>
|
|
399
454
|
<ViewSwitch views={views} value={view.id} onChange={changeView} />
|
|
400
455
|
<SortSelect />
|
|
@@ -406,6 +461,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
406
461
|
const results = (
|
|
407
462
|
<SearchResultsPane
|
|
408
463
|
toolbar={toolbar}
|
|
464
|
+
{...(phoneToolbar ? { header: "compact" as const } : {})}
|
|
409
465
|
headingLevel={props.resultsHeadingLevel ?? 1}
|
|
410
466
|
{...(view.render !== undefined ? { renderResults: view.render } : {})}
|
|
411
467
|
{...(view.layout !== undefined ? { layout: view.layout } : {})}
|
|
@@ -456,10 +512,24 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
456
512
|
}}
|
|
457
513
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
458
514
|
{...(locale !== undefined ? { locale } : {})}
|
|
515
|
+
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
516
|
+
/* The catalogue picker becomes the row's leading chip. The panel
|
|
517
|
+
behind the circle keeps its own copy of the control; both write
|
|
518
|
+
the same `category` parameter, so they cannot disagree. */
|
|
519
|
+
{...(props.renderCategoryFilter !== undefined
|
|
520
|
+
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
521
|
+
: {})}
|
|
522
|
+
{...(props.categoryLabel !== undefined
|
|
523
|
+
? { categoryLabel: props.categoryLabel }
|
|
524
|
+
: {})}
|
|
459
525
|
{...(props.renderGeoFilter !== undefined
|
|
460
526
|
? { renderGeoFilter: props.renderGeoFilter }
|
|
461
527
|
: {})}
|
|
462
528
|
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
529
|
+
/* The row above already states the location and opens its own
|
|
530
|
+
sheet, so the chip would be the second control over one filter
|
|
531
|
+
— see `FilterChipsProps.geoChip`. */
|
|
532
|
+
{...(props.resultsHeader !== undefined ? { geoChip: false } : {})}
|
|
463
533
|
/>
|
|
464
534
|
<SkinDialog
|
|
465
535
|
open={sheetOpen}
|
|
@@ -512,9 +582,11 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
512
582
|
renderCard,
|
|
513
583
|
categoryFeatures,
|
|
514
584
|
locale,
|
|
585
|
+
resolveFacetLabels,
|
|
515
586
|
searchBox,
|
|
516
587
|
languages,
|
|
517
588
|
renderCategoryFilter,
|
|
589
|
+
categoryLabel,
|
|
518
590
|
renderGeoFilter,
|
|
519
591
|
geoLabel,
|
|
520
592
|
defaultGeo,
|
|
@@ -543,9 +615,11 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
543
615
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
544
616
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
545
617
|
{...(locale !== undefined ? { locale } : {})}
|
|
618
|
+
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
546
619
|
{...(searchBox !== undefined ? { searchBox } : {})}
|
|
547
620
|
{...(languages !== undefined ? { languages } : {})}
|
|
548
621
|
{...(renderCategoryFilter !== undefined ? { renderCategoryFilter } : {})}
|
|
622
|
+
{...(categoryLabel !== undefined ? { categoryLabel } : {})}
|
|
549
623
|
{...(renderGeoFilter !== undefined ? { renderGeoFilter } : {})}
|
|
550
624
|
{...(geoLabel !== undefined ? { geoLabel } : {})}
|
|
551
625
|
{...(footer !== undefined ? { footer } : {})}
|
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
*
|
|
19
19
|
* ── Two things this card used to get wrong ────────────────────────────────
|
|
20
20
|
*
|
|
21
|
-
* 1. **It
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* 1. **It read a photo shape nothing in this fleet emits.** `card.image` was
|
|
22
|
+
* read as an object with a `url` key and `card.image_url` as the fallback;
|
|
23
|
+
* what the fleet actually stores is a `<type>/<hash>` CDN reference, and
|
|
24
|
+
* where it does store an object that object carries `ref` + `variants[]`
|
|
25
|
+
* and no top-level `url`. So every consumer that did not pass its own
|
|
26
|
+
* `renderCard` got a card with no photo at all. `cardPhotos.ts` reads the
|
|
27
|
+
* real shapes, through the runtime's `resolveImage` seam, and the whole
|
|
28
|
+
* `images[]` gallery rather than one photo.
|
|
26
29
|
* 2. **The marking's EXPLANATION was in a `Tooltip`.** Touch has no hover, so
|
|
27
30
|
* on the device most of this traffic arrives from, the sentence the law is
|
|
28
31
|
* actually about was unreachable — the tag said "Promoted" and nothing on
|
|
@@ -47,11 +50,14 @@ import { useMemo } from "react";
|
|
|
47
50
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
48
51
|
import { Card, Flex, Typography } from "antd";
|
|
49
52
|
import { Image } from "@stapel/image";
|
|
50
|
-
import
|
|
53
|
+
import { SkinCarousel } from "@stapel/tokens-antd/skin";
|
|
51
54
|
import { useFormat, useT } from "@stapel/core";
|
|
52
55
|
import type { Format } from "@stapel/core";
|
|
53
56
|
import { cssVar, fontSize, fontWeight, radii, spacing } from "@stapel/tokens";
|
|
54
57
|
import type { SearchItem } from "../api/types.js";
|
|
58
|
+
import { useSearchRuntime } from "../model/context.js";
|
|
59
|
+
import { readCardPhotos } from "./cardPhotos.js";
|
|
60
|
+
import type { CardPhotos } from "./cardPhotos.js";
|
|
55
61
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
56
62
|
|
|
57
63
|
/** What the card slot is handed. */
|
|
@@ -78,7 +84,11 @@ export const GENERIC_CARD_FIELDS: readonly string[] = [
|
|
|
78
84
|
"price",
|
|
79
85
|
"currency",
|
|
80
86
|
"location",
|
|
81
|
-
|
|
87
|
+
// The gallery, and the singular first photo the projection keeps beside it
|
|
88
|
+
// (`images[0]`). Both hold CDN references; see `cardPhotos.ts`. The old
|
|
89
|
+
// `image_url` is gone — no backend in this fleet ever wrote it.
|
|
90
|
+
"images",
|
|
91
|
+
"image",
|
|
82
92
|
"url",
|
|
83
93
|
];
|
|
84
94
|
|
|
@@ -150,41 +160,130 @@ const PROMOTED_TAG: CSSProperties = {
|
|
|
150
160
|
whiteSpace: "nowrap",
|
|
151
161
|
};
|
|
152
162
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The shape of one photo well, everywhere on this card. Declared once so the
|
|
165
|
+
* placeholder and the loaded photo cannot drift into two different crops, and
|
|
166
|
+
* so the well is on screen before the network answers.
|
|
167
|
+
*/
|
|
168
|
+
const CARD_PHOTO_ASPECT = "4 / 3";
|
|
169
|
+
|
|
170
|
+
/** The placeholder well: the box the photo would have occupied. */
|
|
171
|
+
const PHOTO_ABSENT: CSSProperties = {
|
|
172
|
+
display: "flex",
|
|
173
|
+
flexDirection: "column",
|
|
174
|
+
alignItems: "center",
|
|
175
|
+
justifyContent: "center",
|
|
176
|
+
gap: spacing[1],
|
|
155
177
|
width: "100%",
|
|
156
|
-
aspectRatio:
|
|
178
|
+
aspectRatio: CARD_PHOTO_ASPECT,
|
|
157
179
|
overflow: "hidden",
|
|
158
180
|
borderRadius: radii.md,
|
|
159
181
|
background: cssVar("surface-sunken"),
|
|
182
|
+
color: cssVar("text-muted"),
|
|
160
183
|
};
|
|
161
184
|
|
|
185
|
+
/** The slide's own fill — the strip's well owns the shape. */
|
|
186
|
+
const PHOTO_FILL: CSSProperties = { width: "100%", height: "100%" };
|
|
187
|
+
|
|
188
|
+
/** A camera outline in `currentColor` — no icon dependency, and it inherits
|
|
189
|
+
* the theme rather than carrying a colour. `aria-hidden` because the sentence
|
|
190
|
+
* beside it is the label. */
|
|
191
|
+
function CameraGlyph(): ReactElement {
|
|
192
|
+
return (
|
|
193
|
+
<svg
|
|
194
|
+
width="24"
|
|
195
|
+
height="24"
|
|
196
|
+
viewBox="0 0 24 24"
|
|
197
|
+
fill="none"
|
|
198
|
+
stroke="currentColor"
|
|
199
|
+
strokeWidth="1.5"
|
|
200
|
+
strokeLinecap="round"
|
|
201
|
+
strokeLinejoin="round"
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
>
|
|
204
|
+
<path d="M3 8.5A2.5 2.5 0 0 1 5.5 6h1.7l1.2-2h6.2l1.2 2h1.7A2.5 2.5 0 0 1 20 8.5v8A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5z" />
|
|
205
|
+
<circle cx="12" cy="12.5" r="3.2" />
|
|
206
|
+
</svg>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
162
210
|
/**
|
|
163
|
-
*
|
|
211
|
+
* The card's photos — the whole gallery, as a swipeable strip.
|
|
212
|
+
*
|
|
213
|
+
* ── Why the strip is OUTSIDE the card's anchor ────────────────────────────
|
|
214
|
+
*
|
|
215
|
+
* `<SkinCarousel>` is a scroll container with its own tab stop, and a
|
|
216
|
+
* horizontal swipe that ends inside an `<a>` is a swipe the browser may
|
|
217
|
+
* deliver as a click. Inside the anchor, every attempt to look at photo two
|
|
218
|
+
* would navigate to the result — the defect that makes phone galleries
|
|
219
|
+
* unusable. So it sits above the anchor as a sibling, exactly as
|
|
220
|
+
* `<ListingSerpCard>` does it, and the anchor still covers everything a
|
|
221
|
+
* person reads.
|
|
164
222
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
223
|
+
* Three states, and they are three different facts. No photo FIELD at all —
|
|
224
|
+
* a doc type that indexes text — draws nothing, because reserving a 4:3 well
|
|
225
|
+
* for a corpus that has no photos is a hole in every row. A field with
|
|
226
|
+
* nothing behind it draws the well and says the photo is unavailable: that is
|
|
227
|
+
* usually an unwired `resolveImage`, and a sentence gets it fixed where an
|
|
228
|
+
* empty grey box does not. Anything else is the strip.
|
|
169
229
|
*/
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
230
|
+
function CardPhotoStrip(props: {
|
|
231
|
+
photos: CardPhotos;
|
|
232
|
+
title: string;
|
|
233
|
+
}): ReactElement | null {
|
|
234
|
+
const t = useT();
|
|
235
|
+
const { photos, title } = props;
|
|
236
|
+
if (photos.stored === 0) return null;
|
|
237
|
+
|
|
238
|
+
if (photos.images.length === 0) {
|
|
239
|
+
const caption = t(SEARCH_I18N_KEYS.resultsPhotoUnavailable);
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
role="img"
|
|
243
|
+
aria-label={caption}
|
|
244
|
+
data-testid="search-result-photo-absent"
|
|
245
|
+
style={PHOTO_ABSENT}
|
|
246
|
+
>
|
|
247
|
+
<CameraGlyph />
|
|
248
|
+
<Typography.Text type="secondary" style={{ fontSize: fontSize.xs.fontSize }}>
|
|
249
|
+
{caption}
|
|
250
|
+
</Typography.Text>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
174
253
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
254
|
+
|
|
255
|
+
const total = photos.images.length;
|
|
256
|
+
// A one-photo strip gets neither a peek nor dots: the sliver of a next
|
|
257
|
+
// slide is an affordance for something that is there.
|
|
258
|
+
const many = total > 1;
|
|
259
|
+
return (
|
|
260
|
+
<SkinCarousel
|
|
261
|
+
label={t(SEARCH_I18N_KEYS.resultsPhotos)}
|
|
262
|
+
aspectRatio={CARD_PHOTO_ASPECT}
|
|
263
|
+
peek={many}
|
|
264
|
+
dots={many}
|
|
265
|
+
data-testid="search-result-photos"
|
|
266
|
+
>
|
|
267
|
+
{photos.images.map((image, index) => (
|
|
268
|
+
<Image
|
|
269
|
+
key={`${String(index)}:${image.url}`}
|
|
270
|
+
meta={image}
|
|
271
|
+
alt={
|
|
272
|
+
many
|
|
273
|
+
? t(SEARCH_I18N_KEYS.resultsPhotoAlt, {
|
|
274
|
+
index: index + 1,
|
|
275
|
+
total,
|
|
276
|
+
title,
|
|
277
|
+
})
|
|
278
|
+
: t(SEARCH_I18N_KEYS.resultsImageAlt, { title })
|
|
279
|
+
}
|
|
280
|
+
fit="cover"
|
|
281
|
+
data-testid="search-result-photo"
|
|
282
|
+
style={PHOTO_FILL}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
285
|
+
</SkinCarousel>
|
|
286
|
+
);
|
|
188
287
|
}
|
|
189
288
|
|
|
190
289
|
/**
|
|
@@ -208,6 +307,7 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
|
|
|
208
307
|
const t = useT();
|
|
209
308
|
const format = useFormat();
|
|
210
309
|
const card = props.item.card;
|
|
310
|
+
const resolve = useSearchRuntime().resolveImage;
|
|
211
311
|
const title = text(card["title"]) ?? t(SEARCH_I18N_KEYS.resultsUntitled);
|
|
212
312
|
const price = formatCardPrice(format, text(card["price"]), text(card["currency"]));
|
|
213
313
|
const href = text(card["url"]);
|
|
@@ -218,48 +318,32 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
|
|
|
218
318
|
km: props.item.distance_km.toFixed(1),
|
|
219
319
|
})
|
|
220
320
|
: undefined;
|
|
221
|
-
// Memoised on the
|
|
222
|
-
//
|
|
223
|
-
|
|
321
|
+
// Memoised on the card and the resolver: a host resolver is a plain
|
|
322
|
+
// function returning a fresh object, so resolving inline hands `<Image>` a
|
|
323
|
+
// new `meta` identity on every render — a load it then has to decide is or
|
|
324
|
+
// is not the same one.
|
|
325
|
+
const photos = useMemo(() => readCardPhotos(card, resolve), [card, resolve]);
|
|
224
326
|
|
|
225
327
|
const body = (
|
|
226
|
-
<Flex vertical gap={spacing[
|
|
227
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
328
|
+
<Flex vertical gap={spacing[1]}>
|
|
329
|
+
<Typography.Text strong>{title}</Typography.Text>
|
|
330
|
+
{/* The price is the strongest line on a catalogue card: it is what the
|
|
331
|
+
eye scans a grid for. It used to share a type step with the location
|
|
332
|
+
and sit under a disclosure three times its size. */}
|
|
333
|
+
{price !== undefined && (
|
|
334
|
+
<Typography.Text
|
|
335
|
+
strong
|
|
336
|
+
style={{ fontSize: fontSize.lg.fontSize }}
|
|
337
|
+
data-testid="search-result-price"
|
|
338
|
+
>
|
|
339
|
+
{price}
|
|
340
|
+
</Typography.Text>
|
|
341
|
+
)}
|
|
342
|
+
{(location !== undefined || distance !== undefined) && (
|
|
343
|
+
<Typography.Text type="secondary">
|
|
344
|
+
{[location, distance].filter((v) => v !== undefined).join(" · ")}
|
|
345
|
+
</Typography.Text>
|
|
242
346
|
)}
|
|
243
|
-
<Flex vertical gap={spacing[1]}>
|
|
244
|
-
<Typography.Text strong>{title}</Typography.Text>
|
|
245
|
-
{/* The price is the strongest line on a catalogue card: it is what
|
|
246
|
-
the eye scans a grid for. It used to share a type step with the
|
|
247
|
-
location and sit under a disclosure three times its size. */}
|
|
248
|
-
{price !== undefined && (
|
|
249
|
-
<Typography.Text
|
|
250
|
-
strong
|
|
251
|
-
style={{ fontSize: fontSize.lg.fontSize }}
|
|
252
|
-
data-testid="search-result-price"
|
|
253
|
-
>
|
|
254
|
-
{price}
|
|
255
|
-
</Typography.Text>
|
|
256
|
-
)}
|
|
257
|
-
{(location !== undefined || distance !== undefined) && (
|
|
258
|
-
<Typography.Text type="secondary">
|
|
259
|
-
{[location, distance].filter((v) => v !== undefined).join(" · ")}
|
|
260
|
-
</Typography.Text>
|
|
261
|
-
)}
|
|
262
|
-
</Flex>
|
|
263
347
|
</Flex>
|
|
264
348
|
);
|
|
265
349
|
|
|
@@ -272,6 +356,8 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
|
|
|
272
356
|
styles={{ body: { padding: spacing[3] } }}
|
|
273
357
|
>
|
|
274
358
|
<Flex vertical gap={spacing[2]}>
|
|
359
|
+
{/* A SIBLING of the anchor, never a child — see `CardPhotoStrip`. */}
|
|
360
|
+
<CardPhotoStrip photos={photos} title={title} />
|
|
275
361
|
{href === undefined ? (
|
|
276
362
|
body
|
|
277
363
|
) : (
|