@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
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
GatedButton,
|
|
34
34
|
LoadList,
|
|
35
35
|
SkinTheme,
|
|
36
|
+
visuallyHidden,
|
|
36
37
|
} from "@stapel/tokens-antd/skin";
|
|
37
38
|
import { spacing } from "@stapel/tokens";
|
|
38
39
|
import type { SearchItem } from "../api/types.js";
|
|
@@ -168,6 +169,28 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
168
169
|
* heading list is for.
|
|
169
170
|
*/
|
|
170
171
|
readonly headingLevel?: 1 | 2 | 3 | 4 | 5;
|
|
172
|
+
/**
|
|
173
|
+
* How the row above the results is arranged. Default `"banner"`.
|
|
174
|
+
*
|
|
175
|
+
* `"banner"` — the heading on the left, the count and the toolbar on the
|
|
176
|
+
* right of one line. A desktop results page, where that line is wide enough
|
|
177
|
+
* to hold all three.
|
|
178
|
+
*
|
|
179
|
+
* `"compact"` — the phone form, and it is a different SHAPE rather than the
|
|
180
|
+
* same shape at a smaller size. At 390px the banner's one line wraps to
|
|
181
|
+
* four: a display-size "Results", the count under it, the sort control under
|
|
182
|
+
* that, and the surface's action under that — a whole viewport of chrome
|
|
183
|
+
* above the first card, which is what a live phone SERP printed. Compact
|
|
184
|
+
* gives the toolbar its own row (the surface arranges it: sort at one end,
|
|
185
|
+
* its action at the other) and puts the COUNT immediately above the cards,
|
|
186
|
+
* where it is a caption for the list rather than a fourth heading.
|
|
187
|
+
*
|
|
188
|
+
* The heading does not disappear — a results screen whose only heading is
|
|
189
|
+
* gone has a document outline that starts at the footer. It becomes
|
|
190
|
+
* visually hidden, so a screen reader's heading list is unchanged and the
|
|
191
|
+
* viewport is not spent saying "Results" over a list of results.
|
|
192
|
+
*/
|
|
193
|
+
readonly header?: "banner" | "compact";
|
|
171
194
|
}
|
|
172
195
|
|
|
173
196
|
function Count(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
@@ -248,19 +271,33 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
248
271
|
<SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
|
|
249
272
|
{(bag) => (
|
|
250
273
|
<Flex vertical gap={spacing[4]}>
|
|
251
|
-
|
|
252
|
-
<
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<Count bag={bag} />
|
|
274
|
+
{props.header === "compact" ? (
|
|
275
|
+
<Flex vertical gap={spacing[2]} data-testid="search-results-header-compact">
|
|
276
|
+
<Typography.Title
|
|
277
|
+
level={props.headingLevel ?? 4}
|
|
278
|
+
style={visuallyHidden}
|
|
279
|
+
data-testid="search-results-heading"
|
|
280
|
+
>
|
|
281
|
+
{props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
|
|
282
|
+
</Typography.Title>
|
|
261
283
|
{props.toolbar}
|
|
284
|
+
<Count bag={bag} />
|
|
285
|
+
</Flex>
|
|
286
|
+
) : (
|
|
287
|
+
<Flex justify="space-between" align="center" wrap gap={spacing[2]}>
|
|
288
|
+
<Typography.Title
|
|
289
|
+
level={props.headingLevel ?? 4}
|
|
290
|
+
style={{ margin: 0 }}
|
|
291
|
+
data-testid="search-results-heading"
|
|
292
|
+
>
|
|
293
|
+
{props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
|
|
294
|
+
</Typography.Title>
|
|
295
|
+
<Flex align="center" wrap gap={spacing[3]}>
|
|
296
|
+
<Count bag={bag} />
|
|
297
|
+
{props.toolbar}
|
|
298
|
+
</Flex>
|
|
262
299
|
</Flex>
|
|
263
|
-
|
|
300
|
+
)}
|
|
264
301
|
|
|
265
302
|
<DegradationNotice
|
|
266
303
|
degradations={bag.degradations}
|
|
@@ -34,10 +34,37 @@ import { sortLabelKey } from "./sortLabels.js";
|
|
|
34
34
|
*/
|
|
35
35
|
export const SORT_SELECT_MIN_WIDTH = 200;
|
|
36
36
|
|
|
37
|
+
/** Why `sort=distance` is refused without a centre — the server's own code, so
|
|
38
|
+
* the control and the 400 it would have earned say the same sentence. */
|
|
39
|
+
const SORT_DISTANCE_BLOCKED = "error.400.search_sort_needs_center";
|
|
40
|
+
|
|
37
41
|
export interface SortSelectProps {
|
|
38
42
|
/** The sort the SERVER applied, shown when the URL names none. Omitted, it
|
|
39
43
|
* is read from the page already in cache — see {@link useAppliedSort}. */
|
|
40
44
|
readonly appliedSort?: string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* The one-line form, for a phone toolbar. Default `false`.
|
|
47
|
+
*
|
|
48
|
+
* Three things change, and the third is the interesting one:
|
|
49
|
+
*
|
|
50
|
+
* - the "Sort" caption goes (the select already shows a sort by name; the
|
|
51
|
+
* accessible name keeps the word);
|
|
52
|
+
* - the {@link SORT_SELECT_MIN_WIDTH} floor goes, so the control shares one
|
|
53
|
+
* row with whatever the surface puts beside it instead of pushing it to
|
|
54
|
+
* the next line;
|
|
55
|
+
* - the blocked option's REASON moves from a line under the control into
|
|
56
|
+
* the option's own label.
|
|
57
|
+
*
|
|
58
|
+
* That last move is not the reason being dropped. This file exists because
|
|
59
|
+
* the reason used to live in a `title=` a phone can never surface, and a
|
|
60
|
+
* phone is exactly where "sort by distance" is greyed out most often. On a
|
|
61
|
+
* 390px toolbar the reason as a separate row costs a whole band of the
|
|
62
|
+
* viewport above the first result — so it goes where the person actually
|
|
63
|
+
* meets the refusal: on the disabled row of the open list, which a screen
|
|
64
|
+
* reader reads out with the option and a thumb reads at the moment of the
|
|
65
|
+
* tap. Nothing is hidden; it is closer to the thing it explains.
|
|
66
|
+
*/
|
|
67
|
+
readonly compact?: boolean;
|
|
41
68
|
}
|
|
42
69
|
|
|
43
70
|
export function SortSelect(props: SortSelectProps): ReactElement {
|
|
@@ -65,7 +92,48 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
65
92
|
// four sorts work perfectly well without a location.
|
|
66
93
|
const distance: ActionAvailability = hasCentre
|
|
67
94
|
? actionAvailable()
|
|
68
|
-
: actionBlocked(
|
|
95
|
+
: actionBlocked(SORT_DISTANCE_BLOCKED);
|
|
96
|
+
|
|
97
|
+
const optionsFor = (describedBy?: string): {
|
|
98
|
+
readonly value: string;
|
|
99
|
+
readonly label: string;
|
|
100
|
+
readonly disabled: boolean;
|
|
101
|
+
}[] =>
|
|
102
|
+
values.map((value) => {
|
|
103
|
+
const key = sortLabelKey(value);
|
|
104
|
+
const label = key !== undefined ? t(key) : value;
|
|
105
|
+
const blocked = value === "distance" && !hasCentre;
|
|
106
|
+
return {
|
|
107
|
+
value,
|
|
108
|
+
// In the compact form the option carries its own reason — see
|
|
109
|
+
// `SortSelectProps.compact`. Elsewhere `GatedControl` renders it once,
|
|
110
|
+
// beside the control, and repeating it here would say it twice.
|
|
111
|
+
label:
|
|
112
|
+
blocked && props.compact === true && describedBy === undefined
|
|
113
|
+
? `${label} — ${t(SORT_DISTANCE_BLOCKED)}`
|
|
114
|
+
: label,
|
|
115
|
+
disabled: blocked,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (props.compact === true) {
|
|
120
|
+
return (
|
|
121
|
+
<Select<string>
|
|
122
|
+
data-testid="search-sort"
|
|
123
|
+
data-stapel-gated={hasCentre ? "available" : "blocked"}
|
|
124
|
+
aria-label={t(SEARCH_I18N_KEYS.sortLabel)}
|
|
125
|
+
// `minWidth: 0` and not the floor: a control that refuses to be
|
|
126
|
+
// narrower than 200px is a control that wraps a two-item toolbar onto
|
|
127
|
+
// two rows at 390px.
|
|
128
|
+
style={{ minWidth: 0, flex: "0 1 auto" }}
|
|
129
|
+
value={active ?? null}
|
|
130
|
+
onChange={(next) => {
|
|
131
|
+
setSort(next);
|
|
132
|
+
}}
|
|
133
|
+
options={optionsFor()}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
69
137
|
|
|
70
138
|
return (
|
|
71
139
|
<GatedControl gate={distance} testId="search-sort-gate">
|
|
@@ -83,14 +151,7 @@ export function SortSelect(props: SortSelectProps): ReactElement {
|
|
|
83
151
|
onChange={(next) => {
|
|
84
152
|
setSort(next);
|
|
85
153
|
}}
|
|
86
|
-
options={
|
|
87
|
-
const key = sortLabelKey(value);
|
|
88
|
-
return {
|
|
89
|
-
value,
|
|
90
|
-
label: key !== undefined ? t(key) : value,
|
|
91
|
-
disabled: value === "distance" && !hasCentre,
|
|
92
|
-
};
|
|
93
|
-
})}
|
|
154
|
+
options={optionsFor(bind["aria-describedby"] ?? "")}
|
|
94
155
|
/>
|
|
95
156
|
</Flex>
|
|
96
157
|
)}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A SEARCH CARD ACTUALLY STORES FOR A PHOTO, and how it becomes something
|
|
3
|
+
* `@stapel/image` can draw.
|
|
4
|
+
*
|
|
5
|
+
* ── The defect this file exists to end ────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The default card used to read `card.image` expecting an object with a `url`
|
|
8
|
+
* key, and fall back to `card.image_url`. Neither is what this fleet emits:
|
|
9
|
+
*
|
|
10
|
+
* - `image_url` is a convention NOTHING in the fleet writes. It was declared
|
|
11
|
+
* in `GENERIC_CARD_FIELDS`, drawn in the demos from a data URI, and never
|
|
12
|
+
* once served by a backend.
|
|
13
|
+
* - `card.image` IS emitted — by `stapel-classified`'s search projection —
|
|
14
|
+
* and it is a plain `<type>/<hash>` STRING, not an object.
|
|
15
|
+
* - where a card DOES carry an object (chat's subject card, which serves the
|
|
16
|
+
* same CDN render descriptor its attachments carry), that object has `ref`
|
|
17
|
+
* and `variants[]` and NO top-level `url` — so the `"url" in rich` guard
|
|
18
|
+
* rejected the one rich shape the fleet has.
|
|
19
|
+
*
|
|
20
|
+
* The net effect was a card with no photo on every consumer that did not pass
|
|
21
|
+
* its own `renderCard`. Read the two real shapes instead, and read `images[]`
|
|
22
|
+
* first: since stapel-classified 0.7.0 the projection carries the whole
|
|
23
|
+
* gallery in seller order, deduplicated and capped by `CARD_IMAGES_LIMIT`,
|
|
24
|
+
* with the singular `image` kept as `images[0]`.
|
|
25
|
+
*
|
|
26
|
+
* ── Three shapes, one output ──────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* 1. A CDN reference (`"image/9f2c…"`) — resolved through the runtime's
|
|
29
|
+
* {@link SearchImageResolver}, the same seam `@stapel/listings-react`
|
|
30
|
+
* states, for the same reason: no contract in this fleet resolves a
|
|
31
|
+
* stranger's reference, so the deployment hands its own knowledge in once.
|
|
32
|
+
* 2. A URL a doc type stored directly (`"https://…"`, `"/media/…"`,
|
|
33
|
+
* `"data:image/…"`) — no ladder to shop, so it degrades to `source:
|
|
34
|
+
* "link"`. A CDN reference is `<type>/<hash>`: no scheme and no leading
|
|
35
|
+
* slash, so the two are told apart by shape and never by a guess.
|
|
36
|
+
* 3. A render descriptor object — `variants[]` with a `tier` each, an inline
|
|
37
|
+
* `preview_b64`, geometry. Read defensively (the field is `unknown` by
|
|
38
|
+
* contract) exactly as `@stapel/chat-react` reads the same descriptor.
|
|
39
|
+
*/
|
|
40
|
+
import type { StapelImage } from "@stapel/image";
|
|
41
|
+
import type { SearchImageResolver } from "../model/runtime.js";
|
|
42
|
+
|
|
43
|
+
/** A card's photo fields, and how many of them the card carried at all. */
|
|
44
|
+
export interface CardPhotos {
|
|
45
|
+
/**
|
|
46
|
+
* How many photo entries the card STORES — before resolving. `0` means the
|
|
47
|
+
* doc type has no photo for this row (or no photo field at all), which is a
|
|
48
|
+
* different thing from "a photo nothing could resolve", and the card draws
|
|
49
|
+
* the two differently.
|
|
50
|
+
*/
|
|
51
|
+
readonly stored: number;
|
|
52
|
+
/** The ones that became something drawable, in the stored order. */
|
|
53
|
+
readonly images: readonly StapelImage[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function str(value: unknown): string | undefined {
|
|
61
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function num(value: unknown): number | null {
|
|
65
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A stored string that is already a URL rather than a CDN reference.
|
|
70
|
+
*
|
|
71
|
+
* stapel-cdn's unit is `<type>/<hash>` — a relative pair with no scheme and no
|
|
72
|
+
* leading slash — so anything carrying a scheme, a protocol-relative prefix or
|
|
73
|
+
* a leading `/` is a URL the doc type stored itself.
|
|
74
|
+
*/
|
|
75
|
+
function isUrl(value: string): boolean {
|
|
76
|
+
return (
|
|
77
|
+
value.startsWith("/") ||
|
|
78
|
+
value.startsWith("data:") ||
|
|
79
|
+
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A bare URL as the descriptor `<Image>` consumes: one rung, no ladder. */
|
|
84
|
+
function linkImage(url: string): StapelImage {
|
|
85
|
+
return {
|
|
86
|
+
source: "link",
|
|
87
|
+
url,
|
|
88
|
+
mime: null,
|
|
89
|
+
width: null,
|
|
90
|
+
height: null,
|
|
91
|
+
aspect: null,
|
|
92
|
+
square: false,
|
|
93
|
+
preview_b64: null,
|
|
94
|
+
variants: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One rung of a render descriptor's ladder, in `@stapel/image`'s spelling.
|
|
100
|
+
*
|
|
101
|
+
* `tier` arrives in two spellings inside the SAME array — an int for the
|
|
102
|
+
* ladder rungs and the string sentinel `"original"` for the entry the snapshot
|
|
103
|
+
* builder appends. `String()` is right for both, which is why there is no
|
|
104
|
+
* branch here (`@stapel/cdn-react`'s `model/refs.ts` argues it at length).
|
|
105
|
+
*/
|
|
106
|
+
function rung(value: unknown): StapelImage["variants"][number] | undefined {
|
|
107
|
+
if (!isRecord(value)) return undefined;
|
|
108
|
+
const url = str(value["url"]);
|
|
109
|
+
if (url === undefined) return undefined;
|
|
110
|
+
const tier = value["tier"];
|
|
111
|
+
const branch = value["branch"];
|
|
112
|
+
return {
|
|
113
|
+
tier: typeof tier === "number" || typeof tier === "string" ? String(tier) : "",
|
|
114
|
+
branch: branch === "w" || branch === "h" ? branch : null,
|
|
115
|
+
url,
|
|
116
|
+
width: num(value["width"]),
|
|
117
|
+
height: num(value["height"]),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A CDN render descriptor → the descriptor `<Image>` consumes.
|
|
123
|
+
*
|
|
124
|
+
* The display URL is the descriptor's own `url` when it has one, else the
|
|
125
|
+
* `original` rung, else the largest rung — a snapshot carries no
|
|
126
|
+
* `original_url`, so the top of the ladder IS the original. With no rung at
|
|
127
|
+
* all the inline `preview_b64` is used: a real, honestly blurry image beats
|
|
128
|
+
* nothing. A descriptor with neither is not drawable and says so.
|
|
129
|
+
*/
|
|
130
|
+
function metaImage(meta: Readonly<Record<string, unknown>>): StapelImage | undefined {
|
|
131
|
+
const raw = meta["variants"];
|
|
132
|
+
const variants = (Array.isArray(raw) ? raw : [])
|
|
133
|
+
.map(rung)
|
|
134
|
+
.filter((v): v is StapelImage["variants"][number] => v !== undefined);
|
|
135
|
+
const original = variants.find((v) => v.tier === "original");
|
|
136
|
+
const largest = variants.reduce<StapelImage["variants"][number] | undefined>(
|
|
137
|
+
(best, v) => {
|
|
138
|
+
const size = Number(v.tier);
|
|
139
|
+
if (!Number.isFinite(size)) return best;
|
|
140
|
+
return best === undefined || size > Number(best.tier) ? v : best;
|
|
141
|
+
},
|
|
142
|
+
undefined
|
|
143
|
+
);
|
|
144
|
+
const preview = str(meta["preview_b64"]);
|
|
145
|
+
const url = str(meta["url"]) ?? original?.url ?? largest?.url ?? preview;
|
|
146
|
+
if (url === undefined) return undefined;
|
|
147
|
+
return {
|
|
148
|
+
source: "cdn",
|
|
149
|
+
url,
|
|
150
|
+
mime: str(meta["mime"]) ?? null,
|
|
151
|
+
width: num(meta["width"]),
|
|
152
|
+
height: num(meta["height"]),
|
|
153
|
+
aspect: num(meta["aspect"]),
|
|
154
|
+
square: meta["square"] === true,
|
|
155
|
+
preview_b64: preview ?? null,
|
|
156
|
+
variants,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One stored entry, in whichever of the three shapes it arrived. */
|
|
161
|
+
export function cardImage(
|
|
162
|
+
value: unknown,
|
|
163
|
+
resolve: SearchImageResolver | undefined
|
|
164
|
+
): StapelImage | undefined {
|
|
165
|
+
const ref = str(value);
|
|
166
|
+
if (ref !== undefined) {
|
|
167
|
+
return isUrl(ref) ? linkImage(ref) : resolve?.(ref);
|
|
168
|
+
}
|
|
169
|
+
if (isRecord(value)) return metaImage(value);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The card's gallery: `images[]` when the doc type carries one, the singular
|
|
175
|
+
* `image` otherwise.
|
|
176
|
+
*
|
|
177
|
+
* Never both — `stapel-classified` stores `image` as `images[0]`, so reading
|
|
178
|
+
* the singular after the list would draw the first photo twice. The singular
|
|
179
|
+
* stays the fallback for a doc type that never grew a list.
|
|
180
|
+
*/
|
|
181
|
+
export function readCardPhotos(
|
|
182
|
+
card: Readonly<Record<string, unknown>>,
|
|
183
|
+
resolve: SearchImageResolver | undefined
|
|
184
|
+
): CardPhotos {
|
|
185
|
+
const list = card["images"];
|
|
186
|
+
const stored: readonly unknown[] = Array.isArray(list)
|
|
187
|
+
? list
|
|
188
|
+
: card["image"] === undefined || card["image"] === null
|
|
189
|
+
? []
|
|
190
|
+
: [card["image"]];
|
|
191
|
+
const images = stored
|
|
192
|
+
.map((entry) => cardImage(entry, resolve))
|
|
193
|
+
.filter((image): image is StapelImage => image !== undefined);
|
|
194
|
+
return { stored: stored.length, images };
|
|
195
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -48,8 +48,16 @@ export type {
|
|
|
48
48
|
SearchResultsRenderer,
|
|
49
49
|
} from "./SearchResultsPane.js";
|
|
50
50
|
|
|
51
|
-
export {
|
|
52
|
-
|
|
51
|
+
export {
|
|
52
|
+
FilterChips,
|
|
53
|
+
CHIP_BAND_ORDER,
|
|
54
|
+
CHIP_ROW_CLASS,
|
|
55
|
+
CHIP_ROW_STYLE_HREF,
|
|
56
|
+
categoryLeaf,
|
|
57
|
+
chipRowCss,
|
|
58
|
+
orderChipFilters,
|
|
59
|
+
} from "./FilterChips.js";
|
|
60
|
+
export type { ChipBand, ChipSpec, FilterChipsProps } from "./FilterChips.js";
|
|
53
61
|
|
|
54
62
|
export { LocationSummaryLine } from "./LocationSummaryLine.js";
|
|
55
63
|
export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
|
|
@@ -6,6 +6,8 @@ import type { FacetMeta, SearchRange } from "../api/types.js";
|
|
|
6
6
|
import { useSearchQuery } from "../model/queries.js";
|
|
7
7
|
import { buildFacetGroups } from "../state/facets.js";
|
|
8
8
|
import type { FacetGroup } from "../state/facets.js";
|
|
9
|
+
import { useHostFacetLabels } from "./useFacetLabels.js";
|
|
10
|
+
import type { FacetLabelResolver } from "./useFacetLabels.js";
|
|
9
11
|
import { useSearchState } from "./SearchStateProvider.js";
|
|
10
12
|
|
|
11
13
|
/** The bag `<FacetPanel>` hands its render prop. */
|
|
@@ -87,6 +89,8 @@ export function FacetPanel(props: {
|
|
|
87
89
|
/** BCP-47 tag for `date`-typed option labels. Defaults to the runtime's. */
|
|
88
90
|
locale?: string;
|
|
89
91
|
enabled?: boolean;
|
|
92
|
+
/** The host's vocabulary lookup — see {@link FacetLabelResolver}. */
|
|
93
|
+
resolveFacetLabels?: FacetLabelResolver;
|
|
90
94
|
children: (bag: FacetPanelBag) => ReactNode;
|
|
91
95
|
}): ReactNode {
|
|
92
96
|
return props.children(
|
|
@@ -96,6 +100,9 @@ export function FacetPanel(props: {
|
|
|
96
100
|
: {}),
|
|
97
101
|
...(props.locale !== undefined ? { locale: props.locale } : {}),
|
|
98
102
|
...(props.enabled !== undefined ? { enabled: props.enabled } : {}),
|
|
103
|
+
...(props.resolveFacetLabels !== undefined
|
|
104
|
+
? { resolveFacetLabels: props.resolveFacetLabels }
|
|
105
|
+
: {}),
|
|
99
106
|
})
|
|
100
107
|
);
|
|
101
108
|
}
|
|
@@ -116,6 +123,13 @@ export function useFacetPanel(props: {
|
|
|
116
123
|
categoryFeatures?: readonly FeatureDef[];
|
|
117
124
|
locale?: string;
|
|
118
125
|
enabled?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* The host's lookup for values neither the answer nor the schema names —
|
|
128
|
+
* see {@link FacetLabelResolver}. Threaded here rather than into each skin
|
|
129
|
+
* so the chip row and the filter panel, which both call this hook, cannot
|
|
130
|
+
* end up printing two different words for one value.
|
|
131
|
+
*/
|
|
132
|
+
resolveFacetLabels?: FacetLabelResolver;
|
|
119
133
|
} = {}): FacetPanelBag {
|
|
120
134
|
const { state: searchState, setFilter, setRange, clearAll, toggleFilter, activeFilters } =
|
|
121
135
|
useSearchState();
|
|
@@ -142,8 +156,13 @@ export function useFacetPanel(props: {
|
|
|
142
156
|
})
|
|
143
157
|
);
|
|
144
158
|
|
|
159
|
+
// The host seam runs AFTER `buildFacetGroups`, on what it could not name:
|
|
160
|
+
// the precedence is server captions, then the schema's own option table,
|
|
161
|
+
// then this. See `useFacetLabels.ts`.
|
|
162
|
+
const labelled = useHostFacetLabels(groups, props.resolveFacetLabels, props.locale);
|
|
163
|
+
|
|
145
164
|
return {
|
|
146
|
-
state:
|
|
165
|
+
state: labelled,
|
|
147
166
|
approximate: meta.approximate,
|
|
148
167
|
skipped: meta.skipped,
|
|
149
168
|
counted: meta.counted,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HOST LABEL SEAM — the third source of a facet option's caption, and the
|
|
3
|
+
* only one that can reach a vocabulary.
|
|
4
|
+
*
|
|
5
|
+
* ── The defect ────────────────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* A live classified deployment's category schema types `vendor`, `model`,
|
|
8
|
+
* `memory_size` and `color_ref_select` as `ref_select`: their config carries
|
|
9
|
+
* no `options` table at all, only a POINTER —
|
|
10
|
+
* `{"optionsRef": {"level": "Vendor", "vocabulary": "avito-phone-catalog"}}`.
|
|
11
|
+
* The words live in the vocabulary the pointer names, which is a different
|
|
12
|
+
* service with a different client. So the two label sources this pair already
|
|
13
|
+
* has both come up empty: the answer's `facet_labels` because the deployed
|
|
14
|
+
* server predates stapel-search 0.4.0 and sends no such key, and the schema
|
|
15
|
+
* because a pointer is not a table. The chips printed `apple`, `128-gb`,
|
|
16
|
+
* `chernyy` — storage slugs, at buyers.
|
|
17
|
+
*
|
|
18
|
+
* This pair must not grow a vocabulary client to fix that: it would tie every
|
|
19
|
+
* storefront's search to a service it may not run, and the host ALREADY has
|
|
20
|
+
* that client (`@stapel/attributes-react`'s `VocabularyClientProvider` is
|
|
21
|
+
* mounted for the composer). So the fetch is the host's and the discipline is
|
|
22
|
+
* this module's — the same division as `renderGeoFilter` and
|
|
23
|
+
* `renderCategoryFilter`, one seam lower because what crosses it is data
|
|
24
|
+
* rather than a control.
|
|
25
|
+
*
|
|
26
|
+
* ── Precedence, stated once ───────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* 1. the answer's `facet_labels` — the server saw the write-time snapshot;
|
|
29
|
+
* 2. the feature def's inline `options` table — for the plain `select`
|
|
30
|
+
* family, which carries its words with it;
|
|
31
|
+
* 3. this resolver;
|
|
32
|
+
* 4. the raw value.
|
|
33
|
+
*
|
|
34
|
+
* `buildFacetGroups` has already applied 1 and 2 by the time this hook runs,
|
|
35
|
+
* and it leaves an option it could not name with `label === value` — which is
|
|
36
|
+
* what makes "did anyone name this?" answerable without a second lookup. Only
|
|
37
|
+
* those values are asked about, so the host is never called for a value the
|
|
38
|
+
* server or the schema already captioned, and a resolver that returns nothing
|
|
39
|
+
* for a value leaves the raw value on screen. A chip that silently dropped an
|
|
40
|
+
* option would be worse than one showing a slug: the option is real, it has a
|
|
41
|
+
* count, and it is the only way to reach those documents.
|
|
42
|
+
*
|
|
43
|
+
* ── The fetch discipline ──────────────────────────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* TanStack Query, exactly like the pair's other three reads, and for the four
|
|
46
|
+
* reasons that made it right there:
|
|
47
|
+
*
|
|
48
|
+
* - **batched per group.** One call per facet slug carrying every unresolved
|
|
49
|
+
* value of it, not one call per chip.
|
|
50
|
+
* - **cached.** `staleTime: Infinity` — a vocabulary term's caption changes
|
|
51
|
+
* when somebody edits the catalogue, not between two clicks on a filter.
|
|
52
|
+
* - **deduplicated.** The chip row, the filter panel and the page's layout
|
|
53
|
+
* probe each call `useFacetPanel`, so three components ask for the same
|
|
54
|
+
* group's captions in one render pass; one query key means one request.
|
|
55
|
+
* - **aborted on supersession.** The `signal` is the query's own, so a
|
|
56
|
+
* resolver that honours it stops a request whose answer nobody will read.
|
|
57
|
+
*/
|
|
58
|
+
import { useQueries } from "@tanstack/react-query";
|
|
59
|
+
import { mapLoad } from "@stapel/core";
|
|
60
|
+
import type { LoadState } from "@stapel/core";
|
|
61
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
62
|
+
import { searchQueryKeys } from "../model/queryKeys.js";
|
|
63
|
+
import type { FacetGroup } from "../state/facets.js";
|
|
64
|
+
|
|
65
|
+
/** What the host is asked to name. */
|
|
66
|
+
export interface FacetLabelRequest {
|
|
67
|
+
/** The facet slug — `vendor`, `color_ref_select`. */
|
|
68
|
+
readonly slug: string;
|
|
69
|
+
/**
|
|
70
|
+
* The category's own def for the slug, when the host supplied a schema.
|
|
71
|
+
* It carries the `optionsRef` pointer (vocabulary + level) a resolver needs
|
|
72
|
+
* to know WHICH vocabulary these codes belong to, so a host does not have to
|
|
73
|
+
* keep a second copy of the schema to answer.
|
|
74
|
+
*/
|
|
75
|
+
readonly feature: FeatureDef | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* The values nobody has named yet, sorted. Only the unresolved ones: a
|
|
78
|
+
* resolver is never asked about a value the server or the schema captioned.
|
|
79
|
+
*/
|
|
80
|
+
readonly values: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A host's answer: `{value: caption}`.
|
|
85
|
+
*
|
|
86
|
+
* Partial answers are the expected case, not an error — a code the vocabulary
|
|
87
|
+
* no longer holds simply is not in the map, and its chip keeps printing the
|
|
88
|
+
* raw value. Returning `{}` is a legitimate "I cannot name any of these".
|
|
89
|
+
*/
|
|
90
|
+
export type FacetLabelResolver = (
|
|
91
|
+
request: FacetLabelRequest,
|
|
92
|
+
options: { readonly signal: AbortSignal }
|
|
93
|
+
) => Promise<Readonly<Record<string, string>>>;
|
|
94
|
+
|
|
95
|
+
/** One frozen empty list, so a render with no unresolved values is stable. */
|
|
96
|
+
const NO_REQUESTS: readonly FacetLabelRequest[] = [];
|
|
97
|
+
|
|
98
|
+
/** The values of one group nobody has named — see the precedence note above. */
|
|
99
|
+
function unresolvedValues(group: FacetGroup): readonly string[] {
|
|
100
|
+
return group.options
|
|
101
|
+
.filter((option) => option.label === option.value)
|
|
102
|
+
.map((option) => option.value)
|
|
103
|
+
.sort((a, b) => a.localeCompare(b));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isCaptionMap(value: unknown): value is Readonly<Record<string, string>> {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fill in what the server and the schema could not name, through the host.
|
|
112
|
+
*
|
|
113
|
+
* Returns the groups UNCHANGED — same object identity — when there is no
|
|
114
|
+
* resolver or nothing came back, so a page with the seam unwired pays neither
|
|
115
|
+
* a request nor a re-render.
|
|
116
|
+
*/
|
|
117
|
+
export function useHostFacetLabels(
|
|
118
|
+
groups: LoadState<readonly FacetGroup[]>,
|
|
119
|
+
resolve: FacetLabelResolver | undefined,
|
|
120
|
+
locale: string | undefined
|
|
121
|
+
): LoadState<readonly FacetGroup[]> {
|
|
122
|
+
const requests: readonly FacetLabelRequest[] =
|
|
123
|
+
resolve === undefined || groups.status !== "ready"
|
|
124
|
+
? NO_REQUESTS
|
|
125
|
+
: groups.data
|
|
126
|
+
.map((group) => ({
|
|
127
|
+
slug: group.slug,
|
|
128
|
+
feature: group.feature,
|
|
129
|
+
values: unresolvedValues(group),
|
|
130
|
+
}))
|
|
131
|
+
.filter((request) => request.values.length > 0);
|
|
132
|
+
|
|
133
|
+
const answers = useQueries({
|
|
134
|
+
queries: requests.map((request) => ({
|
|
135
|
+
queryKey: searchQueryKeys.facetLabels(request.slug, request.values, locale),
|
|
136
|
+
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<unknown> =>
|
|
137
|
+
resolve === undefined ? {} : await resolve(request, { signal }),
|
|
138
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
139
|
+
// A vocabulary that answered 500 answers 500 again a millisecond later,
|
|
140
|
+
// and three retries only delay the chip's raw value by three round
|
|
141
|
+
// trips — the same discipline the pair's other reads keep.
|
|
142
|
+
retry: false,
|
|
143
|
+
})),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const captions = new Map<string, Readonly<Record<string, string>>>();
|
|
147
|
+
requests.forEach((request, index) => {
|
|
148
|
+
const data = answers[index]?.data;
|
|
149
|
+
if (isCaptionMap(data)) captions.set(request.slug, data);
|
|
150
|
+
});
|
|
151
|
+
if (captions.size === 0) return groups;
|
|
152
|
+
|
|
153
|
+
return mapLoad(groups, (list) =>
|
|
154
|
+
list.map((group) => {
|
|
155
|
+
const named = captions.get(group.slug);
|
|
156
|
+
if (named === undefined) return group;
|
|
157
|
+
return {
|
|
158
|
+
...group,
|
|
159
|
+
options: group.options.map((option) => {
|
|
160
|
+
// Precedence again, enforced rather than assumed: an option whose
|
|
161
|
+
// label already differs from its value was named by the server or
|
|
162
|
+
// the schema, and the host does not get to overwrite either.
|
|
163
|
+
if (option.label !== option.value) return option;
|
|
164
|
+
const caption = named[option.value];
|
|
165
|
+
return caption === undefined || caption.length === 0
|
|
166
|
+
? option
|
|
167
|
+
: { ...option, label: caption };
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
}
|