@stapel/listings-react 0.13.0 → 0.14.1
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 +64 -1
- package/dist/default/ListingCard.d.ts +59 -3
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +79 -17
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +30 -0
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +33 -0
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts +11 -0
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +7 -15
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +5 -2
- package/dist/default/index.js.map +1 -1
- package/llms.txt +4 -3
- package/manifest.json +20 -1
- package/nav-manifest.json +1 -1
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/default/ListingCard.tsx +209 -110
- package/src/default/ListingPhoto.tsx +64 -0
- package/src/default/ListingSerpCard.tsx +130 -135
- package/src/default/index.ts +12 -2
|
@@ -34,6 +34,17 @@
|
|
|
34
34
|
* covers the four things that identify the listing — price, title, specs,
|
|
35
35
|
* badges — so the card opens from everything a person reads.
|
|
36
36
|
*
|
|
37
|
+
* ── One column on a phone, one ROW on a desktop ───────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* The card is still one per row; what changes with width is where the photo
|
|
40
|
+
* sits. Full-bleed above the text is right at 390px and wrong at 974px, where
|
|
41
|
+
* it makes the picture the whole screen and the offer a footnote — measured on
|
|
42
|
+
* the live desktop SERP as one card per scroll. Above
|
|
43
|
+
* {@link LISTING_CARD_ROW_MIN} the strip moves BESIDE the text at a fixed
|
|
44
|
+
* width, from the card's own `@container` query rather than a viewport
|
|
45
|
+
* breakpoint: this card knows how wide it is and has no business knowing how
|
|
46
|
+
* wide the window is.
|
|
47
|
+
*
|
|
37
48
|
* ── The two slots, and what they are honestly for ─────────────────────────
|
|
38
49
|
*
|
|
39
50
|
* `sellerSlot` is the seller's name and rating. It is a SLOT because a rating
|
|
@@ -64,7 +75,7 @@
|
|
|
64
75
|
*/
|
|
65
76
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
66
77
|
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
67
|
-
import {
|
|
78
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
68
79
|
import { useT } from "@stapel/core";
|
|
69
80
|
import { fontSize, spacing } from "@stapel/tokens";
|
|
70
81
|
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
@@ -80,12 +91,16 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
80
91
|
import { PriceTrendIcon } from "./icons.js";
|
|
81
92
|
import { FavoriteHeart } from "./favorite.js";
|
|
82
93
|
import {
|
|
94
|
+
CARD_FRAME_CLASS,
|
|
95
|
+
CARD_MAIN_CLASS,
|
|
96
|
+
CARD_MEDIA_CLASS,
|
|
97
|
+
CARD_QUERY_CLASS,
|
|
83
98
|
CARD_TARGET_STYLE_HREF,
|
|
84
99
|
CardTarget,
|
|
85
100
|
cardTargetCss,
|
|
86
101
|
} from "./ListingCard.js";
|
|
87
102
|
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
88
|
-
import {
|
|
103
|
+
import { ListingPhotoStrip } from "./ListingPhoto.js";
|
|
89
104
|
import { ListingPrice } from "./ListingPrice.js";
|
|
90
105
|
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
91
106
|
|
|
@@ -177,12 +192,6 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
177
192
|
const currency =
|
|
178
193
|
listing.currency !== undefined ? { currency: listing.currency } : {};
|
|
179
194
|
|
|
180
|
-
// A one-photo strip gets neither a peek nor dots: the sliver of a next slide
|
|
181
|
-
// is an affordance for something that is there, and on a single photo it is
|
|
182
|
-
// just a strip of dead space at the trailing edge (`SkinCarousel` says the
|
|
183
|
-
// same thing from its own side).
|
|
184
|
-
const many = photos.length > 1;
|
|
185
|
-
|
|
186
195
|
const rail =
|
|
187
196
|
props.actionsRail !== undefined || props.showFavorite !== false ? (
|
|
188
197
|
<Flex
|
|
@@ -221,144 +230,130 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
221
230
|
styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
|
|
222
231
|
style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
|
|
223
232
|
>
|
|
224
|
-
<
|
|
225
|
-
{
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
>
|
|
236
|
-
{photos.length === 0 ? (
|
|
237
|
-
<ListingPhoto
|
|
238
|
-
imageRef={undefined}
|
|
239
|
-
alt={title.length > 0 ? title : String(listing.id)}
|
|
233
|
+
<div className={CARD_QUERY_CLASS}>
|
|
234
|
+
<div className={CARD_FRAME_CLASS} style={{ gap: spacing[3] }}>
|
|
235
|
+
{/* The strip is a SIBLING of the anchor, never a child — see the
|
|
236
|
+
file header. A listing with no photos still gets one slide, so
|
|
237
|
+
the card's height does not depend on whether a seller uploaded
|
|
238
|
+
anything. */}
|
|
239
|
+
<div className={CARD_MEDIA_CLASS}>
|
|
240
|
+
<ListingPhotoStrip
|
|
241
|
+
images={photos}
|
|
242
|
+
title={title.length > 0 ? title : String(listing.id)}
|
|
243
|
+
testId="listings-serp-photos"
|
|
240
244
|
/>
|
|
241
|
-
|
|
242
|
-
photos.map((reference, index) => (
|
|
243
|
-
<ListingPhoto
|
|
244
|
-
key={reference}
|
|
245
|
-
imageRef={reference}
|
|
246
|
-
alt={t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
247
|
-
index: index + 1,
|
|
248
|
-
total: photos.length,
|
|
249
|
-
})}
|
|
250
|
-
/>
|
|
251
|
-
))
|
|
252
|
-
)}
|
|
253
|
-
</SkinCarousel>
|
|
254
|
-
|
|
255
|
-
<Flex gap={spacing[3]} align="flex-start">
|
|
256
|
-
<Flex vertical gap={spacing[1]} style={BODY}>
|
|
257
|
-
<CardTarget
|
|
258
|
-
{...openProps(props)}
|
|
259
|
-
listingId={listing.id}
|
|
260
|
-
label={targetLabel}
|
|
261
|
-
testId="listings-serp-open"
|
|
262
|
-
bodyTestId="listings-serp-body"
|
|
263
|
-
>
|
|
264
|
-
{props.badge}
|
|
245
|
+
</div>
|
|
265
246
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
247
|
+
<div className={CARD_MAIN_CLASS}>
|
|
248
|
+
<Flex gap={spacing[3]} align="flex-start">
|
|
249
|
+
<Flex vertical gap={spacing[1]} style={BODY}>
|
|
250
|
+
<CardTarget
|
|
251
|
+
{...openProps(props)}
|
|
252
|
+
listingId={listing.id}
|
|
253
|
+
label={targetLabel}
|
|
254
|
+
testId="listings-serp-open"
|
|
255
|
+
bodyTestId="listings-serp-body"
|
|
275
256
|
>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
257
|
+
{props.badge}
|
|
258
|
+
|
|
259
|
+
{/* PRICE FIRST, and loud. `fontSize.xl` rather than an antd
|
|
260
|
+
heading: this is a price, not a section title, and it must
|
|
261
|
+
not enter the document outline of a page holding twenty of
|
|
262
|
+
them. */}
|
|
263
|
+
<Flex align="center" gap={spacing[2]} wrap>
|
|
264
|
+
<Typography.Text
|
|
265
|
+
strong
|
|
266
|
+
style={{ fontSize: fontSize.xl.fontSize }}
|
|
267
|
+
data-testid="listings-serp-price"
|
|
268
|
+
>
|
|
269
|
+
<ListingPrice amount={listing.price} {...currency} />
|
|
270
|
+
</Typography.Text>
|
|
271
|
+
{priceTrend !== undefined && (
|
|
272
|
+
<PriceTrendIcon
|
|
273
|
+
direction={priceTrend.direction}
|
|
274
|
+
label={t(
|
|
275
|
+
priceTrend.direction === "down"
|
|
276
|
+
? LISTINGS_I18N_KEYS.cardPriceDropped
|
|
277
|
+
: LISTINGS_I18N_KEYS.cardPriceRaised
|
|
278
|
+
)}
|
|
279
|
+
/>
|
|
285
280
|
)}
|
|
286
|
-
|
|
287
|
-
)}
|
|
288
|
-
</Flex>
|
|
281
|
+
</Flex>
|
|
289
282
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
283
|
+
{priceTrend !== undefined && (
|
|
284
|
+
<Flex
|
|
285
|
+
align="baseline"
|
|
286
|
+
gap={spacing[1]}
|
|
287
|
+
data-testid="listings-serp-old-price"
|
|
288
|
+
>
|
|
289
|
+
{/* The strike-through is what a sighted reader sees and
|
|
290
|
+
nothing a screen reader announces, so the word is on
|
|
291
|
+
the line too rather than left to the styling. */}
|
|
292
|
+
<Typography.Text type="secondary">
|
|
293
|
+
{t(LISTINGS_I18N_KEYS.cardPriceWas)}
|
|
294
|
+
</Typography.Text>
|
|
295
|
+
<Typography.Text type="secondary" delete>
|
|
296
|
+
<ListingPrice amount={priceTrend.oldPrice} {...currency} />
|
|
297
|
+
</Typography.Text>
|
|
298
|
+
</Flex>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
<Typography.Text data-testid="listings-serp-title">
|
|
302
|
+
{title}
|
|
304
303
|
</Typography.Text>
|
|
305
|
-
</Flex>
|
|
306
|
-
)}
|
|
307
304
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
305
|
+
{props.specsLine !== undefined && props.specsLine.length > 0 ? (
|
|
306
|
+
<Typography.Text
|
|
307
|
+
type="secondary"
|
|
308
|
+
ellipsis
|
|
309
|
+
data-testid="listings-serp-specs"
|
|
310
|
+
>
|
|
311
|
+
{props.specsLine}
|
|
312
|
+
</Typography.Text>
|
|
313
|
+
) : titleDaos.length > 0 ? (
|
|
314
|
+
<Typography.Text
|
|
315
|
+
type="secondary"
|
|
316
|
+
ellipsis
|
|
317
|
+
data-testid="listings-serp-specs"
|
|
318
|
+
>
|
|
319
|
+
<FeatureBadges
|
|
320
|
+
features={featuresFromDaoList(titleDaos, copy).map(
|
|
321
|
+
(view) => view.feature,
|
|
322
|
+
)}
|
|
323
|
+
values={featuresDtoFromDaoList(titleDaos)}
|
|
324
|
+
/>
|
|
325
|
+
</Typography.Text>
|
|
326
|
+
) : null}
|
|
311
327
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
</Typography.Text>
|
|
320
|
-
) : titleDaos.length > 0 ? (
|
|
321
|
-
<Typography.Text
|
|
322
|
-
type="secondary"
|
|
323
|
-
ellipsis
|
|
324
|
-
data-testid="listings-serp-specs"
|
|
325
|
-
>
|
|
326
|
-
<FeatureBadges
|
|
327
|
-
features={featuresFromDaoList(titleDaos, copy).map(
|
|
328
|
-
(view) => view.feature,
|
|
329
|
-
)}
|
|
330
|
-
values={featuresDtoFromDaoList(titleDaos)}
|
|
331
|
-
/>
|
|
332
|
-
</Typography.Text>
|
|
333
|
-
) : null}
|
|
328
|
+
{badgeFeatures.length > 0 ? (
|
|
329
|
+
<FeatureBadges
|
|
330
|
+
features={badgeFeatures.map((view) => view.feature)}
|
|
331
|
+
values={badgeValues}
|
|
332
|
+
/>
|
|
333
|
+
) : null}
|
|
334
|
+
</CardTarget>
|
|
334
335
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
/>
|
|
340
|
-
) : null}
|
|
341
|
-
</CardTarget>
|
|
336
|
+
{/* Outside the anchor, both of them: a seller line usually holds
|
|
337
|
+
a link to the seller, and the place is the last thing read
|
|
338
|
+
rather than part of what the card is called. */}
|
|
339
|
+
{props.sellerSlot ?? null}
|
|
342
340
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
>
|
|
354
|
-
{listing.location_label}
|
|
355
|
-
</Typography.Text>
|
|
356
|
-
) : null}
|
|
357
|
-
</Flex>
|
|
341
|
+
{listing.location_label !== undefined &&
|
|
342
|
+
listing.location_label.length > 0 ? (
|
|
343
|
+
<Typography.Text
|
|
344
|
+
type="secondary"
|
|
345
|
+
data-testid="listings-serp-location"
|
|
346
|
+
>
|
|
347
|
+
{listing.location_label}
|
|
348
|
+
</Typography.Text>
|
|
349
|
+
) : null}
|
|
350
|
+
</Flex>
|
|
358
351
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
352
|
+
{rail}
|
|
353
|
+
</Flex>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
362
357
|
</Card>
|
|
363
358
|
</SkinTheme>
|
|
364
359
|
);
|
package/src/default/index.ts
CHANGED
|
@@ -33,7 +33,13 @@
|
|
|
33
33
|
* that wrapped its own composition in `<ListingsSkinTheme>` imports
|
|
34
34
|
* `<SkinTheme>` from the substrate instead — same props, plus a `surface`.
|
|
35
35
|
*/
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
ListingCard,
|
|
38
|
+
// The card's row arm, for a container that wants to lay out against the same
|
|
39
|
+
// threshold (and for a test that measures it). See `<ListingCard>`'s header.
|
|
40
|
+
LISTING_CARD_ROW_MIN,
|
|
41
|
+
LISTING_CARD_ROW_MEDIA,
|
|
42
|
+
} from "./ListingCard.js";
|
|
37
43
|
export type {
|
|
38
44
|
ListingCardProps,
|
|
39
45
|
ListingCardBaseProps,
|
|
@@ -79,7 +85,11 @@ export type {
|
|
|
79
85
|
|
|
80
86
|
export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
|
|
81
87
|
export type { ListingStatusProps } from "./StatusTags.js";
|
|
82
|
-
export {
|
|
88
|
+
export {
|
|
89
|
+
ListingPhoto,
|
|
90
|
+
ListingPhotoStrip,
|
|
91
|
+
LISTING_PHOTO_ASPECT,
|
|
92
|
+
} from "./ListingPhoto.js";
|
|
83
93
|
export type { ListingPhotoProps } from "./ListingPhoto.js";
|
|
84
94
|
export { SignInLink } from "./SignInLink.js";
|
|
85
95
|
export type { SignInLinkProps } from "./SignInLink.js";
|