@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/MODULE.md +118 -3
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +60 -6
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/searchApi.d.ts +13 -6
  7. package/dist/api/searchApi.d.ts.map +1 -1
  8. package/dist/api/searchApi.js.map +1 -1
  9. package/dist/api/types.d.ts +107 -1
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js +29 -0
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/FacetPanelPane.d.ts +13 -2
  14. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  15. package/dist/default/FacetPanelPane.js +3 -1
  16. package/dist/default/FacetPanelPane.js.map +1 -1
  17. package/dist/default/FilterChips.d.ts +88 -3
  18. package/dist/default/FilterChips.d.ts.map +1 -1
  19. package/dist/default/FilterChips.js +161 -13
  20. package/dist/default/FilterChips.js.map +1 -1
  21. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  22. package/dist/default/LocationSummaryLine.js +1 -1
  23. package/dist/default/LocationSummaryLine.js.map +1 -1
  24. package/dist/default/SearchBox.d.ts.map +1 -1
  25. package/dist/default/SearchBox.js +74 -4
  26. package/dist/default/SearchBox.js.map +1 -1
  27. package/dist/default/SearchPage.d.ts +29 -1
  28. package/dist/default/SearchPage.d.ts.map +1 -1
  29. package/dist/default/SearchPage.js +25 -10
  30. package/dist/default/SearchPage.js.map +1 -1
  31. package/dist/default/SearchResultCard.d.ts.map +1 -1
  32. package/dist/default/SearchResultCard.js +83 -37
  33. package/dist/default/SearchResultCard.js.map +1 -1
  34. package/dist/default/SearchResultsPane.d.ts +22 -0
  35. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  36. package/dist/default/SearchResultsPane.js +2 -2
  37. package/dist/default/SearchResultsPane.js.map +1 -1
  38. package/dist/default/SortSelect.d.ts +23 -0
  39. package/dist/default/SortSelect.d.ts.map +1 -1
  40. package/dist/default/SortSelect.js +29 -9
  41. package/dist/default/SortSelect.js.map +1 -1
  42. package/dist/default/cardPhotos.d.ts +65 -0
  43. package/dist/default/cardPhotos.d.ts.map +1 -0
  44. package/dist/default/cardPhotos.js +127 -0
  45. package/dist/default/cardPhotos.js.map +1 -0
  46. package/dist/default/index.d.ts +2 -2
  47. package/dist/default/index.d.ts.map +1 -1
  48. package/dist/default/index.js +1 -1
  49. package/dist/default/index.js.map +1 -1
  50. package/dist/headless/FacetPanel.d.ts +10 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +9 -1
  53. package/dist/headless/FacetPanel.js.map +1 -1
  54. package/dist/headless/useFacetLabels.d.ts +39 -0
  55. package/dist/headless/useFacetLabels.d.ts.map +1 -0
  56. package/dist/headless/useFacetLabels.js +129 -0
  57. package/dist/headless/useFacetLabels.js.map +1 -0
  58. package/dist/headless/useSearchBox.d.ts +71 -0
  59. package/dist/headless/useSearchBox.d.ts.map +1 -1
  60. package/dist/headless/useSearchBox.js +67 -2
  61. package/dist/headless/useSearchBox.js.map +1 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +10 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +55 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +67 -0
  68. package/dist/i18n/keys.js.map +1 -1
  69. package/dist/i18n/ru.d.ts.map +1 -1
  70. package/dist/i18n/ru.js +12 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +7 -5
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +4 -3
  75. package/dist/index.js.map +1 -1
  76. package/dist/model/queries.d.ts +6 -5
  77. package/dist/model/queries.d.ts.map +1 -1
  78. package/dist/model/queries.js +4 -3
  79. package/dist/model/queries.js.map +1 -1
  80. package/dist/model/queryKeys.d.ts +12 -0
  81. package/dist/model/queryKeys.d.ts.map +1 -1
  82. package/dist/model/queryKeys.js +7 -0
  83. package/dist/model/queryKeys.js.map +1 -1
  84. package/dist/model/runtime.d.ts +40 -3
  85. package/dist/model/runtime.d.ts.map +1 -1
  86. package/dist/model/runtime.js +2 -1
  87. package/dist/model/runtime.js.map +1 -1
  88. package/dist/nav/manifest.d.ts +21 -0
  89. package/dist/nav/manifest.d.ts.map +1 -1
  90. package/dist/nav/manifest.js +3 -2
  91. package/dist/nav/manifest.js.map +1 -1
  92. package/dist/state/facets.d.ts +56 -15
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +101 -27
  95. package/dist/state/facets.js.map +1 -1
  96. package/llms.txt +3 -3
  97. package/manifest.json +33 -4
  98. package/nav-manifest.json +4 -3
  99. package/package.json +11 -11
  100. package/src/analytics/generated/events.json +1 -1
  101. package/src/api/generated/schema.ts +60 -6
  102. package/src/api/searchApi.ts +13 -6
  103. package/src/api/types.ts +115 -1
  104. package/src/default/FacetPanelPane.tsx +16 -2
  105. package/src/default/FilterChips.tsx +285 -38
  106. package/src/default/LocationSummaryLine.tsx +6 -1
  107. package/src/default/SearchBox.tsx +134 -6
  108. package/src/default/SearchPage.tsx +79 -5
  109. package/src/default/SearchResultCard.tsx +157 -71
  110. package/src/default/SearchResultsPane.tsx +48 -11
  111. package/src/default/SortSelect.tsx +70 -9
  112. package/src/default/cardPhotos.ts +195 -0
  113. package/src/default/index.ts +10 -2
  114. package/src/headless/FacetPanel.tsx +20 -1
  115. package/src/headless/useFacetLabels.ts +172 -0
  116. package/src/headless/useSearchBox.ts +132 -5
  117. package/src/i18n/es.ts +11 -0
  118. package/src/i18n/keys.ts +69 -0
  119. package/src/i18n/ru.ts +13 -0
  120. package/src/index.ts +22 -3
  121. package/src/model/queries.ts +6 -5
  122. package/src/model/queryKeys.ts +23 -0
  123. package/src/model/runtime.ts +43 -4
  124. package/src/nav/manifest.ts +24 -2
  125. 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 options = box.suggestions.map((value) => ({ value }));
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 && options.length > 0}
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
- /** The catalogue picker slot — see {@link FacetPanelPaneProps}. */
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
- // The toolbar over the results: how they are ARRANGED, how they are ORDERED,
396
- // how many per page and the surface's own action at the trailing end.
397
- const toolbar = (
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 declared `image_url` and drew nothing.** A text-only card in a
22
- * classifieds search is not a card; the field was in `GENERIC_CARD_FIELDS`
23
- * and in no render path. It is drawn now, through `@stapel/image`, so the
24
- * aspect box lands before the network does and a dead URL renders a named
25
- * placeholder instead of the browser's torn-page icon.
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 type { StapelImage } from "@stapel/image";
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
- "image_url",
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
- /** The photo's box: a 4:3 well the image fills, drawn before it loads. */
154
- const PHOTO: CSSProperties = {
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: "4 / 3",
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
- * A plain `image_url` as the descriptor `<Image>` consumes.
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
- * A doc type that stores a bare URL has no variant ladder and no dimensions
166
- * `source: "link"` is exactly that case, and `<Image>` degrades to the single
167
- * URL rather than pretending to shop a tier. A doc type that stores the whole
168
- * `StapelImage` snapshot (`card.image`) gets the ladder, blur-up and all.
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 cardImage(card: Readonly<Record<string, unknown>>): StapelImage | undefined {
171
- const rich = card["image"];
172
- if (rich !== null && typeof rich === "object" && "url" in rich) {
173
- return rich as StapelImage;
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
- const url = text(card["image_url"]);
176
- if (url === undefined) return undefined;
177
- return {
178
- source: "link",
179
- url,
180
- mime: null,
181
- width: null,
182
- height: null,
183
- aspect: null,
184
- square: false,
185
- preview_b64: null,
186
- variants: [],
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 two fields it reads: a fresh `meta` identity every render
222
- // is a load `<Image>` has to decide is or is not the same one.
223
- const image = useMemo(() => cardImage(card), [card]);
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[2]}>
227
- {image !== undefined && (
228
- <div style={PHOTO} data-testid="search-result-photo">
229
- {/* The WELL owns the shape (4:3, drawn before the network answers),
230
- so the image is told to fill it. Without an explicit box the
231
- image's own container reserves height only from the snapshot's
232
- `aspect` — which a doc type storing a bare `image_url` does not
233
- have — and a `cover` image inside a zero-height parent is a
234
- photo that loaded and was never seen. */}
235
- <Image
236
- meta={image}
237
- alt={t(SEARCH_I18N_KEYS.resultsImageAlt, { title })}
238
- fit="cover"
239
- style={{ width: "100%", height: "100%" }}
240
- />
241
- </div>
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
  ) : (