@stapel/search-react 0.12.0 → 0.14.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 +109 -0
- package/MODULE.md +7 -3
- package/dist/default/LocationSummaryLine.d.ts +19 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +111 -11
- package/dist/default/LocationSummaryLine.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/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/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +13 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.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/llms.txt +3 -3
- package/manifest.json +14 -3
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/default/LocationSummaryLine.tsx +150 -22
- package/src/default/SearchResultCard.tsx +157 -71
- package/src/default/cardPhotos.ts +195 -0
- package/src/i18n/es.ts +3 -0
- package/src/i18n/keys.ts +13 -0
- package/src/i18n/ru.ts +3 -0
- package/src/index.ts +1 -0
- package/src/model/runtime.ts +43 -4
|
@@ -27,34 +27,139 @@
|
|
|
27
27
|
*
|
|
28
28
|
* `<FilterChips>`'s leading chip is a 32px circle: a number inside it is a
|
|
29
29
|
* number nobody reads, so it shows a dot. This is a full-width row with a word
|
|
30
|
-
* on it, so
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* on it, so it has room to say HOW MANY constraints are applied — which is the
|
|
31
|
+
* difference between "something is filtered" and "four things are, and that is
|
|
32
|
+
* why there are three results". Both read the same `activeFilters` off the URL
|
|
33
|
+
* state; neither invents a second counter.
|
|
34
|
+
*
|
|
35
|
+
* ## The row overlapped itself on a phone, and why (defect C12)
|
|
36
|
+
*
|
|
37
|
+
* Measured on a live 390px SERP, in both themes, on every result page:
|
|
38
|
+
*
|
|
39
|
+
* ```
|
|
40
|
+
* {"kind":"clipped-left","t":"A chosen place on the map","x":-4}
|
|
41
|
+
* {"kind":"overlap","a":"· Within 25 km","b":"Filters","px":43}
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* Three separate causes, and none of them was the flex row itself:
|
|
45
|
+
*
|
|
46
|
+
* 1. **An antd `<Button>` CENTRES its content.** `minWidth: 0` let the left
|
|
47
|
+
* item shrink, and nothing clipped what was inside it, so the label
|
|
48
|
+
* overflowed its box symmetrically — off the left edge of the screen at
|
|
49
|
+
* `x = -4` and 43px across the word "Filters" at the other end. A shrunk
|
|
50
|
+
* box with no `overflow` is not a truncation, it is an overlap.
|
|
51
|
+
* 2. **Nothing declared which end may shrink.** Both ends were `1 1 auto`, so
|
|
52
|
+
* a long place name took width from a word that must never lose any.
|
|
53
|
+
* 3. **The count was an antd `<Badge count>`,** which is an absolutely
|
|
54
|
+
* positioned `sup` hung off the top-right CORNER of what it wraps. At the
|
|
55
|
+
* trailing edge of a full-width row that lands it outside the row entirely
|
|
56
|
+
* — a red plaque floating in the corner of the page, attached to nothing
|
|
57
|
+
* (measured at `y = 72` for a row whose own line is at `y = 93`).
|
|
58
|
+
*
|
|
59
|
+
* So: one compact line, `place · radius` on the left and `Filters` right; the
|
|
60
|
+
* left is the only half that shrinks and it truncates with an ellipsis; the
|
|
61
|
+
* count rides IN the flow beside the word it counts for. The first two rules
|
|
62
|
+
* are what an inline style cannot reach (they belong on the wrapper antd puts
|
|
63
|
+
* around a button's children), which is why this file hoists a stylesheet the
|
|
64
|
+
* way `<ListingCard>` and `<SkinCarousel>` do.
|
|
34
65
|
*/
|
|
35
66
|
import { useState } from "react";
|
|
36
67
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
37
|
-
import {
|
|
68
|
+
import { Button, Flex } from "antd";
|
|
38
69
|
import { useT } from "@stapel/core";
|
|
39
|
-
import { spacing } from "@stapel/tokens";
|
|
70
|
+
import { cssVar, fontSize, radii, spacing } from "@stapel/tokens";
|
|
40
71
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
41
72
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
42
73
|
import { geoSummaryFallback } from "./FacetPanelPane.js";
|
|
43
74
|
import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
|
|
44
75
|
import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
|
|
45
76
|
|
|
77
|
+
/** The class the row carries, for {@link locationLineCss}. */
|
|
78
|
+
export const LOCATION_LINE_CLASS = "stapel-search-location-line";
|
|
79
|
+
/** The class the shrinking half carries. */
|
|
80
|
+
export const LOCATION_LINE_WHERE_CLASS = "stapel-search-location-line-where";
|
|
81
|
+
/** The class the truncating label carries. */
|
|
82
|
+
export const LOCATION_LINE_LABEL_CLASS = "stapel-search-location-line-label";
|
|
83
|
+
/** The class the fixed half carries. */
|
|
84
|
+
export const LOCATION_LINE_END_CLASS = "stapel-search-location-line-end";
|
|
85
|
+
|
|
86
|
+
/** The `href` the hoisted stylesheet is deduplicated by. */
|
|
87
|
+
export const LOCATION_LINE_STYLE_HREF = "stapel-search-location-line";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The rules that keep the line ONE line — and that an inline style cannot
|
|
91
|
+
* reach, because they belong on the `<span>` antd wraps a button's children
|
|
92
|
+
* in. See the file header (defect C12) for what each of them stops.
|
|
93
|
+
*
|
|
94
|
+
* Static: nothing here varies per instance, so one hoisted copy serves a page
|
|
95
|
+
* in either theme.
|
|
96
|
+
*/
|
|
97
|
+
export function locationLineCss(): string {
|
|
98
|
+
const row = `.${LOCATION_LINE_CLASS}`;
|
|
99
|
+
const where = `.${LOCATION_LINE_WHERE_CLASS}`;
|
|
100
|
+
const label = `.${LOCATION_LINE_LABEL_CLASS}`;
|
|
101
|
+
const end = `.${LOCATION_LINE_END_CLASS}`;
|
|
102
|
+
return [
|
|
103
|
+
// `min-width:0` on the row too: a flex item's default `min-width:auto`
|
|
104
|
+
// is what makes a nested flex row refuse to shrink at all.
|
|
105
|
+
`${row}{min-inline-size:0}`,
|
|
106
|
+
// The ONLY half that shrinks, and it CLIPS what it cannot show. An antd
|
|
107
|
+
// Button is already a flex row and it CENTRES its children, so without
|
|
108
|
+
// these two the box shrank and its content overflowed both edges at once
|
|
109
|
+
// — the whole defect: `x:-4` on the left, 43px over "Filters" on the
|
|
110
|
+
// right.
|
|
111
|
+
`${where}{flex:1 1 auto;min-inline-size:0;overflow:hidden;` +
|
|
112
|
+
`justify-content:flex-start}`,
|
|
113
|
+
// Whatever the button puts its children in has to be allowed to shrink;
|
|
114
|
+
// a flex item's `min-width:auto` refuses to go below its content.
|
|
115
|
+
`${where}>span{min-inline-size:0}`,
|
|
116
|
+
// `display:block` is load-bearing: `text-overflow` applies to a BLOCK
|
|
117
|
+
// container and does nothing on a flex one, so a label that had been
|
|
118
|
+
// turned into a flex box truncated with a hard cut mid-glyph instead of
|
|
119
|
+
// an ellipsis.
|
|
120
|
+
`${label}{display:block;min-inline-size:0;overflow:hidden;` +
|
|
121
|
+
`text-overflow:ellipsis;white-space:nowrap}`,
|
|
122
|
+
// The word a person is looking for never loses a pixel to a place name.
|
|
123
|
+
`${end}{flex:0 0 auto}`,
|
|
124
|
+
].join("");
|
|
125
|
+
}
|
|
126
|
+
|
|
46
127
|
/** The row. Both ends are text buttons, so the row reads as a line of type
|
|
47
128
|
* rather than as two controls bolted onto a results page. */
|
|
48
129
|
const ROW: CSSProperties = { width: "100%" };
|
|
49
130
|
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
131
|
+
/** The shrinking half. The flex rules it needs live in the hoisted sheet
|
|
132
|
+
* (they have to reach antd's own wrapper span); this is the chrome. */
|
|
52
133
|
const LOCATION: CSSProperties = {
|
|
53
|
-
minWidth: 0,
|
|
54
134
|
paddingInline: 0,
|
|
55
135
|
textAlign: "start",
|
|
56
136
|
};
|
|
57
137
|
|
|
138
|
+
/** The pin, which is not allowed to shrink either — a squashed glyph is
|
|
139
|
+
* noise, and it costs 16px. */
|
|
140
|
+
const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The count, IN the flow.
|
|
144
|
+
*
|
|
145
|
+
* An antd `<Badge count>` is an absolutely positioned `sup` on the corner of
|
|
146
|
+
* whatever it wraps, and at the trailing edge of a full-width row that puts it
|
|
147
|
+
* outside the row: a red plaque in the corner of the page attached to nothing.
|
|
148
|
+
* A pill beside the word is the same fact, in the line it belongs to, and it
|
|
149
|
+
* takes part in the layout instead of floating over it.
|
|
150
|
+
*/
|
|
151
|
+
const COUNT: CSSProperties = {
|
|
152
|
+
display: "inline-block",
|
|
153
|
+
minInlineSize: `${String(spacing[4])}px`,
|
|
154
|
+
paddingInline: spacing[1],
|
|
155
|
+
borderRadius: radii.full,
|
|
156
|
+
background: cssVar("brand"),
|
|
157
|
+
color: cssVar("text-on-accent"),
|
|
158
|
+
fontSize: fontSize.xs.fontSize,
|
|
159
|
+
lineHeight: `${String(spacing[4])}px`,
|
|
160
|
+
textAlign: "center",
|
|
161
|
+
};
|
|
162
|
+
|
|
58
163
|
export interface LocationSummaryLineProps {
|
|
59
164
|
/**
|
|
60
165
|
* The location control the sheet opens onto — the same slot the panel and
|
|
@@ -123,18 +228,26 @@ export function LocationSummaryLine(
|
|
|
123
228
|
|
|
124
229
|
return (
|
|
125
230
|
<>
|
|
231
|
+
<style href={LOCATION_LINE_STYLE_HREF} precedence="default">
|
|
232
|
+
{locationLineCss()}
|
|
233
|
+
</style>
|
|
126
234
|
<Flex
|
|
127
235
|
align="center"
|
|
128
236
|
justify="space-between"
|
|
129
237
|
gap={spacing[2]}
|
|
130
238
|
style={ROW}
|
|
239
|
+
className={LOCATION_LINE_CLASS}
|
|
131
240
|
data-testid="search-location-summary"
|
|
132
241
|
data-geo={geo === undefined ? "off" : "on"}
|
|
133
242
|
>
|
|
243
|
+
{/* The glyph is rendered as a CHILD rather than through antd's `icon`
|
|
244
|
+
prop: the icon slot sits outside the wrapper span, so the label
|
|
245
|
+
beside it could not be given a min-width of its own — and a label
|
|
246
|
+
that cannot shrink is a label that overflows. */}
|
|
134
247
|
<Button
|
|
135
248
|
type="link"
|
|
136
249
|
style={LOCATION}
|
|
137
|
-
|
|
250
|
+
className={LOCATION_LINE_WHERE_CLASS}
|
|
138
251
|
data-testid="search-location-open"
|
|
139
252
|
data-analytics="none"
|
|
140
253
|
data-analytics-reason="opening the location sheet is a read, not a flow step"
|
|
@@ -142,13 +255,21 @@ export function LocationSummaryLine(
|
|
|
142
255
|
setOpen(true);
|
|
143
256
|
}}
|
|
144
257
|
>
|
|
145
|
-
{
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
258
|
+
<span style={PIN}>
|
|
259
|
+
<PinGlyph />
|
|
260
|
+
</span>
|
|
261
|
+
<span
|
|
262
|
+
className={LOCATION_LINE_LABEL_CLASS}
|
|
263
|
+
data-testid="search-location-label"
|
|
264
|
+
>
|
|
265
|
+
{where}
|
|
266
|
+
{radius !== undefined && (
|
|
267
|
+
<span data-testid="search-location-radius">
|
|
268
|
+
{" · "}
|
|
269
|
+
{radius}
|
|
270
|
+
</span>
|
|
271
|
+
)}
|
|
272
|
+
</span>
|
|
152
273
|
</Button>
|
|
153
274
|
|
|
154
275
|
{/* "Filters", not "All filters": this end of the row shares 390px
|
|
@@ -156,10 +277,12 @@ export function LocationSummaryLine(
|
|
|
156
277
|
the person is looking for is the noun. The panel's own heading
|
|
157
278
|
still says "All filters" — there it is naming a sheet, not a
|
|
158
279
|
door. */}
|
|
159
|
-
{/* The count, not a dot: this row has the width to say how many
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
280
|
+
{/* The count, not a dot: this row has the width to say how many — and
|
|
281
|
+
it rides IN the line rather than floating off its corner. */}
|
|
282
|
+
<Flex
|
|
283
|
+
align="center"
|
|
284
|
+
gap={spacing[1]}
|
|
285
|
+
className={LOCATION_LINE_END_CLASS}
|
|
163
286
|
data-testid="search-location-filters-badge"
|
|
164
287
|
>
|
|
165
288
|
<Button
|
|
@@ -173,7 +296,12 @@ export function LocationSummaryLine(
|
|
|
173
296
|
>
|
|
174
297
|
{t(SEARCH_I18N_KEYS.filtersShort)}
|
|
175
298
|
</Button>
|
|
176
|
-
|
|
299
|
+
{activeFilters > 0 && (
|
|
300
|
+
<span style={COUNT} data-testid="search-location-filters-count">
|
|
301
|
+
{activeFilters}
|
|
302
|
+
</span>
|
|
303
|
+
)}
|
|
304
|
+
</Flex>
|
|
177
305
|
</Flex>
|
|
178
306
|
|
|
179
307
|
<GeoSheet
|
|
@@ -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
|
) : (
|