@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
package/dist/default/index.js
CHANGED
|
@@ -33,7 +33,10 @@
|
|
|
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 { ListingCard
|
|
36
|
+
export { ListingCard,
|
|
37
|
+
// The card's row arm, for a container that wants to lay out against the same
|
|
38
|
+
// threshold (and for a test that measures it). See `<ListingCard>`'s header.
|
|
39
|
+
LISTING_CARD_ROW_MIN, LISTING_CARD_ROW_MEDIA, } from "./ListingCard.js";
|
|
37
40
|
export { ListingSerpCard } from "./ListingSerpCard.js";
|
|
38
41
|
export { ListingFeedCard } from "./ListingFeedCard.js";
|
|
39
42
|
export { FeedGrid, FEED_GRID_COLUMNS } from "./FeedGrid.js";
|
|
@@ -42,6 +45,6 @@ export { ListingComposerPage, COMPOSER_DETAILS_PLACEMENT, COMPOSER_MEASURE, comp
|
|
|
42
45
|
export { MyListingsPane } from "./MyListingsPane.js";
|
|
43
46
|
export { FavoritesPane, FAVORITES_CARD_MIN } from "./FavoritesPane.js";
|
|
44
47
|
export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
|
|
45
|
-
export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
|
|
48
|
+
export { ListingPhoto, ListingPhotoStrip, LISTING_PHOTO_ASPECT, } from "./ListingPhoto.js";
|
|
46
49
|
export { SignInLink } from "./SignInLink.js";
|
|
47
50
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/default/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/default/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EACL,WAAW;AACX,6EAA6E;AAC7E,6EAA6E;AAC7E,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAO1B,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAMvD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAKvD,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAE5D,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE7F,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,gBAAgB,EAChB,eAAe,GAChB,MAAM,0BAA0B,CAAC;AASlC,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAMvE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEnF,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @stapel/listings-react 0.
|
|
1
|
+
# @stapel/listings-react 0.14.1
|
|
2
2
|
|
|
3
3
|
Headless React flow pair for stapel-listings (contract >=0.10 <0.11) — business + state, zero visual opinion.
|
|
4
4
|
Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
|
|
@@ -106,14 +106,15 @@ const { tracked } = useTracked();
|
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
## Demos (defineDemo → manifest.demos; compiled, linted, rendered examples)
|
|
109
|
-
- listings.card → <ListingCard> [default|saved|visitor|no-photo] demo/ListingCard.demo.tsx
|
|
109
|
+
- listings.card → <ListingCard> [default|saved|visitor|grid cell|list row|no-photo] demo/ListingCard.demo.tsx
|
|
110
110
|
- listings.composer → <ListingComposerPage> [default|unwired|photos-in-flight|unsupported-type] demo/ListingComposer.demo.tsx
|
|
111
111
|
- listings.detail → <ListingDetailPane> [default|contact-unwired|owner|not-found] demo/ListingDetail.demo.tsx
|
|
112
112
|
- listings.favorites → <FavoritesPane> [default|paged|empty|failed|visitor] demo/Favorites.demo.tsx
|
|
113
113
|
- listings.feed-card → <ListingFeedCard> [default|tile|visitor|wide] demo/ListingFeedCard.demo.tsx
|
|
114
114
|
- listings.mine → <MyListingsPane> [default|no-editor|taken-down|visitor|failed] demo/MyListings.demo.tsx
|
|
115
115
|
- listings.parts → <ListingStatusBlock> [default|tags|notes|media|door] demo/ListingParts.demo.tsx
|
|
116
|
-
- listings.
|
|
116
|
+
- listings.photo-strip → <ListingPhotoStrip> [default|single|no photos] demo/ListingPhotoStrip.demo.tsx
|
|
117
|
+
- listings.serp-card → <ListingSerpCard> [default|trend|visitor|desktop row|bare] demo/ListingSerpCard.demo.tsx
|
|
117
118
|
Each source file is the canonical usage snippet (open the default variant).
|
|
118
119
|
|
|
119
120
|
## Snippets
|
package/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
|
|
3
3
|
"package": "@stapel/listings-react",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.1",
|
|
5
5
|
"backend": {
|
|
6
6
|
"module": "stapel-listings",
|
|
7
7
|
"contract": ">=0.10 <0.11"
|
|
@@ -669,6 +669,8 @@
|
|
|
669
669
|
"default",
|
|
670
670
|
"saved",
|
|
671
671
|
"visitor",
|
|
672
|
+
"grid cell",
|
|
673
|
+
"list row",
|
|
672
674
|
"no-photo"
|
|
673
675
|
],
|
|
674
676
|
"source": "demo/ListingCard.demo.tsx"
|
|
@@ -795,6 +797,22 @@
|
|
|
795
797
|
],
|
|
796
798
|
"source": "demo/ListingParts.demo.tsx"
|
|
797
799
|
},
|
|
800
|
+
{
|
|
801
|
+
"id": "listings.photo-strip",
|
|
802
|
+
"title": "Listing photo strip",
|
|
803
|
+
"description": "Every stored photo of a listing as one swipeable, snapping strip — the gallery <ListingCard> and <ListingSerpCard> both render, always as a SIBLING of the card's anchor. A swipeable strip is a control, a link may not contain one, and a horizontal swipe that ends inside an <a> is a swipe the browser may deliver as a click; that is what made the desktop gallery unreachable on a live deployment while the phone one worked.",
|
|
804
|
+
"component": "ListingPhotoStrip",
|
|
805
|
+
"tokens": [
|
|
806
|
+
"surface-sunken",
|
|
807
|
+
"text-muted"
|
|
808
|
+
],
|
|
809
|
+
"variants": [
|
|
810
|
+
"default",
|
|
811
|
+
"single",
|
|
812
|
+
"no photos"
|
|
813
|
+
],
|
|
814
|
+
"source": "demo/ListingPhotoStrip.demo.tsx"
|
|
815
|
+
},
|
|
798
816
|
{
|
|
799
817
|
"id": "listings.serp-card",
|
|
800
818
|
"title": "Listing SERP card",
|
|
@@ -811,6 +829,7 @@
|
|
|
811
829
|
"default",
|
|
812
830
|
"trend",
|
|
813
831
|
"visitor",
|
|
832
|
+
"desktop row",
|
|
814
833
|
"bare"
|
|
815
834
|
],
|
|
816
835
|
"source": "demo/ListingSerpCard.demo.tsx"
|
package/nav-manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/listings-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Headless React pair for stapel-listings: a typed client over the draft twin, the composer that turns a category schema into a submittable listing, and the owner's dashboard. Two independent axes are rendered as two — lifecycle `status` decides visibility and `moderation_status` decides nothing about it, so an edit to a live listing stays live and says 'under review' instead of vanishing. Feature values are drawn and mirrored by @stapel/attributes-react; photos arrive as an injected upload bag whose `refs` ARE `images_draft` and whose `settled` gates the submit; a publish refusal is routed onto the control that caused it by slug. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"size-limit": "^11.2.0",
|
|
103
103
|
"typescript": "^5.8.3",
|
|
104
104
|
"vitest": "^3.2.4",
|
|
105
|
-
"@stapel/attributes-react": "^0.
|
|
105
|
+
"@stapel/attributes-react": "^0.8.1",
|
|
106
106
|
"@stapel/core": "^0.22.0",
|
|
107
107
|
"@stapel/currencies-react": "^0.3.0",
|
|
108
108
|
"@stapel/image": "^0.4.2",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* Nothing on a classified works that way: the card IS the target, and the only
|
|
17
17
|
* separate control on it is the favourite heart.
|
|
18
18
|
*
|
|
19
|
-
* So the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* So the price, the title, the badges and the location live INSIDE one anchor
|
|
20
|
+
* that covers the whole card, and `listings.card.open` is retired rather than
|
|
21
|
+
* left orphaned in three catalogues.
|
|
22
22
|
*
|
|
23
23
|
* What that must not cost is the anchor semantics won earlier: this is a real
|
|
24
24
|
* `<a href>`, so middle-click opens a tab, ⌘-click opens a tab, "copy link
|
|
@@ -44,6 +44,29 @@
|
|
|
44
44
|
* A listing with no title falls back to `listings.card.untitled`, because a
|
|
45
45
|
* link announced as nothing is worse than one announced as untitled.
|
|
46
46
|
*
|
|
47
|
+
* ── The photos moved OUT of the anchor, and the card became a ROW ─────────
|
|
48
|
+
*
|
|
49
|
+
* Both from the same measurement of the live desktop SERP, in list view:
|
|
50
|
+
* **one card per screen** — 974×835, of which the photograph was 974×731 —
|
|
51
|
+
* showing a SINGLE photo, with no carousel, and that photo inside the anchor.
|
|
52
|
+
* The phone card beside it was correct on every count.
|
|
53
|
+
*
|
|
54
|
+
* The photo used to be inside the anchor on the argument that a still `<img>`
|
|
55
|
+
* in a link is just a bigger link, which is true and stops being true the
|
|
56
|
+
* moment there is more than one photo: a swipeable strip is a control, a link
|
|
57
|
+
* may not contain one, and a horizontal swipe that ends inside an `<a>` is a
|
|
58
|
+
* swipe the browser may deliver as a click. So the strip is a SIBLING of the
|
|
59
|
+
* anchor — the arrangement `<ListingSerpCard>` has always used, and the reason
|
|
60
|
+
* the phone gallery works — and the anchor still covers everything a person
|
|
61
|
+
* READS. `<ListingPhotoStrip>` is the one gallery both cards draw.
|
|
62
|
+
*
|
|
63
|
+
* The row is the other half. A grid card handed a full-page-wide track keeps
|
|
64
|
+
* its shape and becomes a banner; this card now asks its OWN width
|
|
65
|
+
* (`@container`, see {@link LISTING_CARD_ROW_MIN}) and lays the photo beside
|
|
66
|
+
* the text above 560px. In a grid its column is never that wide, so nothing
|
|
67
|
+
* about the grid changes; in a list several rows fit a screen, which is the
|
|
68
|
+
* only reason a list exists.
|
|
69
|
+
*
|
|
47
70
|
* ── Why the heart is a row under the card and not floating on the photo ────
|
|
48
71
|
*
|
|
49
72
|
* Because for a signed-out visitor — which is most of the traffic a storefront
|
|
@@ -88,7 +111,7 @@ import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
|
88
111
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
89
112
|
import { HeartIcon } from "./icons.js";
|
|
90
113
|
import { SignInLink } from "./SignInLink.js";
|
|
91
|
-
import {
|
|
114
|
+
import { ListingPhotoStrip } from "./ListingPhoto.js";
|
|
92
115
|
import { ListingPrice } from "./ListingPrice.js";
|
|
93
116
|
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
94
117
|
|
|
@@ -151,10 +174,45 @@ export type ListingCardBlockedReason = "text" | "line";
|
|
|
151
174
|
|
|
152
175
|
/** The class the whole-card target carries, for {@link cardTargetCss}. */
|
|
153
176
|
export const CARD_TARGET_CLASS = "stapel-listing-card-target";
|
|
177
|
+
/** The class the card's OUTER box carries — the size container the row layout
|
|
178
|
+
* is asked about. A container cannot answer a query about itself, which is why
|
|
179
|
+
* this is a wrapper and not the frame. */
|
|
180
|
+
export const CARD_QUERY_CLASS = "stapel-listing-card-q";
|
|
181
|
+
/** The class the media/reading frame carries. */
|
|
182
|
+
export const CARD_FRAME_CLASS = "stapel-listing-card-frame";
|
|
183
|
+
/** The class the photo strip's box carries. */
|
|
184
|
+
export const CARD_MEDIA_CLASS = "stapel-listing-card-media";
|
|
185
|
+
/** The class the reading column carries. */
|
|
186
|
+
export const CARD_MAIN_CLASS = "stapel-listing-card-main";
|
|
187
|
+
/** Added to the frame by a card whose media is FULL-BLEED when stacked
|
|
188
|
+
* (`<ListingCard>`), so the row arm gets the inset the stacked arm does not. */
|
|
189
|
+
export const CARD_BLEED_CLASS = "stapel-listing-card-bleed";
|
|
154
190
|
|
|
155
191
|
/** The `href` the hoisted card stylesheet is deduplicated by. */
|
|
156
192
|
export const CARD_TARGET_STYLE_HREF = "stapel-listings-card-target";
|
|
157
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The card's own inline size above which it stops being a card and becomes a
|
|
196
|
+
* ROW.
|
|
197
|
+
*
|
|
198
|
+
* Measured on the live desktop SERP in "list" view: **one card per screen**,
|
|
199
|
+
* 974×835, of which the photograph was 974×731 — a grid card handed a
|
|
200
|
+
* full-page track and asked to keep its shape. A shopper comparing offers got
|
|
201
|
+
* one offer per scroll.
|
|
202
|
+
*
|
|
203
|
+
* 560px is the width at which a 4:3 photo stops being a banner: below it a
|
|
204
|
+
* two-column row leaves the text nothing, above it the photo is a thumbnail
|
|
205
|
+
* beside a paragraph. A `@container` query rather than a media query because
|
|
206
|
+
* the card does not know the viewport and must not care: the same card is a
|
|
207
|
+
* grid cell 300px wide on a 1440px screen and a full-width row on a 700px one,
|
|
208
|
+
* and only its OWN width decides which it is.
|
|
209
|
+
*/
|
|
210
|
+
export const LISTING_CARD_ROW_MIN = 560;
|
|
211
|
+
|
|
212
|
+
/** The photo's width in the row arm. Wide enough to read the goods, narrow
|
|
213
|
+
* enough that several rows fit a screen — the whole point of a list. */
|
|
214
|
+
export const LISTING_CARD_ROW_MEDIA = 260;
|
|
215
|
+
|
|
158
216
|
/**
|
|
159
217
|
* The one rule an inline style cannot express: `:focus-visible`.
|
|
160
218
|
*
|
|
@@ -168,9 +226,33 @@ export const CARD_TARGET_STYLE_HREF = "stapel-listings-card-target";
|
|
|
168
226
|
* design system's ROLE catalogue and this is a component's private plumbing.
|
|
169
227
|
*/
|
|
170
228
|
export function cardTargetCss(): string {
|
|
229
|
+
const q = `.${CARD_QUERY_CLASS}`;
|
|
230
|
+
const frame = `.${CARD_FRAME_CLASS}`;
|
|
231
|
+
const media = `.${CARD_MEDIA_CLASS}`;
|
|
232
|
+
const main = `.${CARD_MAIN_CLASS}`;
|
|
233
|
+
const bleed = `.${CARD_BLEED_CLASS}`;
|
|
171
234
|
return [
|
|
172
235
|
`.${CARD_TARGET_CLASS}{display:block;color:inherit;text-decoration:none}`,
|
|
173
236
|
`.${CARD_TARGET_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
|
|
237
|
+
// The card asks about its OWN width, not the window's — see
|
|
238
|
+
// `LISTING_CARD_ROW_MIN`.
|
|
239
|
+
`${q}{container-type:inline-size}`,
|
|
240
|
+
`${frame}{display:flex;flex-direction:column;min-inline-size:0}`,
|
|
241
|
+
`${media}{min-inline-size:0}`,
|
|
242
|
+
`${main}{display:flex;flex-direction:column;flex:1 1 auto;min-inline-size:0}`,
|
|
243
|
+
// The row arm. `align-items:flex-start` so a short text column does not
|
|
244
|
+
// stretch the photo, and a fixed media basis so the picture cannot grow
|
|
245
|
+
// into the 974px banner the live SERP was measured at.
|
|
246
|
+
`@container (min-width:${String(LISTING_CARD_ROW_MIN)}px){` +
|
|
247
|
+
`${frame}{flex-direction:row;align-items:flex-start}` +
|
|
248
|
+
`${media}{flex:0 0 ${String(LISTING_CARD_ROW_MEDIA)}px;` +
|
|
249
|
+
`max-inline-size:${String(LISTING_CARD_ROW_MEDIA)}px}` +
|
|
250
|
+
// A full-bleed stacked card has no padding of its own to give the row
|
|
251
|
+
// arm, so the row arm states its own; the reading column's padding is
|
|
252
|
+
// what separates the two.
|
|
253
|
+
`${bleed}{padding-block:var(--listing-card-inset);` +
|
|
254
|
+
`padding-inline-start:var(--listing-card-inset)}` +
|
|
255
|
+
`}`,
|
|
174
256
|
].join("");
|
|
175
257
|
}
|
|
176
258
|
|
|
@@ -343,8 +425,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
343
425
|
title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
|
|
344
426
|
|
|
345
427
|
/**
|
|
346
|
-
* Everything a person
|
|
347
|
-
*
|
|
428
|
+
* Everything a person READS on the card, in the order a classified reads it:
|
|
429
|
+
* price, title, the seller's own spec line, the badges, the place. The
|
|
430
|
+
* photos are not here — they are the strip beside this, outside the anchor.
|
|
348
431
|
*
|
|
349
432
|
* The search projection carries `title`, `price`, `currency`,
|
|
350
433
|
* `location_label`, `image` and `published_at` and NO feature badges — so
|
|
@@ -353,57 +436,51 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
353
436
|
* they are the two fields a result always has.
|
|
354
437
|
*/
|
|
355
438
|
const content = (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
vertical
|
|
363
|
-
gap={spacing[1]}
|
|
364
|
-
style={{ minWidth: 0, padding: token.paddingSM }}
|
|
365
|
-
>
|
|
366
|
-
{props.badge}
|
|
367
|
-
|
|
368
|
-
<Typography.Text strong data-testid="listings-card-price">
|
|
369
|
-
<ListingPrice
|
|
370
|
-
amount={listing.price}
|
|
371
|
-
{...(listing.currency !== undefined ? { currency: listing.currency } : {})}
|
|
372
|
-
/>
|
|
373
|
-
</Typography.Text>
|
|
439
|
+
<Flex
|
|
440
|
+
vertical
|
|
441
|
+
gap={spacing[1]}
|
|
442
|
+
style={{ minWidth: 0, padding: token.paddingSM }}
|
|
443
|
+
>
|
|
444
|
+
{props.badge}
|
|
374
445
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
446
|
+
<Typography.Text strong data-testid="listings-card-price">
|
|
447
|
+
<ListingPrice
|
|
448
|
+
amount={listing.price}
|
|
449
|
+
{...(listing.currency !== undefined ? { currency: listing.currency } : {})}
|
|
450
|
+
/>
|
|
451
|
+
</Typography.Text>
|
|
378
452
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
<Typography.Text type="secondary" ellipsis>
|
|
383
|
-
<FeatureBadges
|
|
384
|
-
features={featuresFromDaoList(titleDaos, copy).map(
|
|
385
|
-
(view) => view.feature
|
|
386
|
-
)}
|
|
387
|
-
values={featuresDtoFromDaoList(titleDaos)}
|
|
388
|
-
/>
|
|
389
|
-
</Typography.Text>
|
|
390
|
-
) : null}
|
|
453
|
+
<Typography.Text ellipsis data-testid="listings-card-title">
|
|
454
|
+
{title}
|
|
455
|
+
</Typography.Text>
|
|
391
456
|
|
|
392
|
-
|
|
457
|
+
{/* The title features are a stored projection too — the seller's
|
|
458
|
+
"1.5 TB, black" line, already ordered by the server. */}
|
|
459
|
+
{titleDaos.length > 0 ? (
|
|
460
|
+
<Typography.Text type="secondary" ellipsis>
|
|
393
461
|
<FeatureBadges
|
|
394
|
-
features={
|
|
395
|
-
|
|
462
|
+
features={featuresFromDaoList(titleDaos, copy).map(
|
|
463
|
+
(view) => view.feature
|
|
464
|
+
)}
|
|
465
|
+
values={featuresDtoFromDaoList(titleDaos)}
|
|
396
466
|
/>
|
|
397
|
-
|
|
467
|
+
</Typography.Text>
|
|
468
|
+
) : null}
|
|
469
|
+
|
|
470
|
+
{badgeFeatures.length > 0 ? (
|
|
471
|
+
<FeatureBadges
|
|
472
|
+
features={badgeFeatures.map((view) => view.feature)}
|
|
473
|
+
values={badgeValues}
|
|
474
|
+
/>
|
|
475
|
+
) : null}
|
|
398
476
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
</>
|
|
477
|
+
{listing.location_label !== undefined &&
|
|
478
|
+
listing.location_label.length > 0 ? (
|
|
479
|
+
<Typography.Text type="secondary" data-testid="listings-card-location">
|
|
480
|
+
{listing.location_label}
|
|
481
|
+
</Typography.Text>
|
|
482
|
+
) : null}
|
|
483
|
+
</Flex>
|
|
407
484
|
);
|
|
408
485
|
|
|
409
486
|
return (
|
|
@@ -421,70 +498,92 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
421
498
|
{...(status !== undefined
|
|
422
499
|
? { "data-listing-status": status.status }
|
|
423
500
|
: {})}
|
|
424
|
-
// The body's own padding is zero because the
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
// back, from the same token
|
|
501
|
+
// The body's own padding is zero because the frame fills the card and
|
|
502
|
+
// the photo runs edge to edge when it is stacked: padding here would
|
|
503
|
+
// be a strip of card around a picture. The text block inside the
|
|
504
|
+
// anchor carries the same padding back, from the same token, and the
|
|
505
|
+
// row arm states its own inset (`--listing-card-inset`).
|
|
428
506
|
styles={{ body: { minWidth: 0, padding: 0 } }}
|
|
429
|
-
style={{
|
|
507
|
+
style={{
|
|
508
|
+
["--listing-card-focus" as string]: token.colorPrimary,
|
|
509
|
+
["--listing-card-inset" as string]: `${String(token.paddingSM)}px`,
|
|
510
|
+
}}
|
|
430
511
|
>
|
|
431
|
-
<
|
|
432
|
-
{
|
|
433
|
-
|
|
512
|
+
<div className={CARD_QUERY_CLASS}>
|
|
513
|
+
<div className={`${CARD_FRAME_CLASS} ${CARD_BLEED_CLASS}`}>
|
|
514
|
+
{/* The photos, OUTSIDE the anchor — see `<ListingPhotoStrip>`.
|
|
515
|
+
A swipeable strip is a control, and a link may not contain
|
|
516
|
+
one; the anchor still covers everything a person reads. */}
|
|
517
|
+
<div className={CARD_MEDIA_CLASS}>
|
|
518
|
+
<ListingPhotoStrip
|
|
519
|
+
images={listing.images ?? []}
|
|
520
|
+
title={title.length > 0 ? title : String(listing.id)}
|
|
521
|
+
testId="listings-card-photos"
|
|
522
|
+
/>
|
|
523
|
+
</div>
|
|
434
524
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
{props.showFavorite === false ? null : (
|
|
440
|
-
<div
|
|
441
|
-
style={{
|
|
442
|
-
paddingInline: token.paddingSM,
|
|
443
|
-
paddingBlockEnd: token.paddingSM,
|
|
444
|
-
}}
|
|
445
|
-
>
|
|
446
|
-
<GatedControl
|
|
447
|
-
gate={favorite.gate}
|
|
448
|
-
testId="listings-card-actions"
|
|
449
|
-
style={{ width: "100%" }}
|
|
450
|
-
>
|
|
451
|
-
{(bind) => (
|
|
452
|
-
<Flex justify="flex-end" style={{ width: "100%" }}>
|
|
453
|
-
<Button
|
|
454
|
-
disabled={bind.disabled}
|
|
455
|
-
data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
|
|
456
|
-
{...(bind["aria-describedby"] !== undefined
|
|
457
|
-
? { "aria-describedby": bind["aria-describedby"] }
|
|
458
|
-
: {})}
|
|
459
|
-
aria-label={favoriteLabel}
|
|
460
|
-
aria-pressed={favorite.favorited}
|
|
461
|
-
data-testid="listings-card-favorite"
|
|
462
|
-
data-favorited={String(favorite.favorited)}
|
|
463
|
-
data-analytics="none"
|
|
464
|
-
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
465
|
-
onClick={favorite.toggle}
|
|
466
|
-
icon={<HeartIcon filled={favorite.favorited} />}
|
|
467
|
-
/>
|
|
468
|
-
</Flex>
|
|
469
|
-
)}
|
|
470
|
-
</GatedControl>
|
|
525
|
+
<div className={CARD_MAIN_CLASS}>
|
|
526
|
+
<CardTarget {...props} listingId={listing.id} label={targetLabel}>
|
|
527
|
+
{content}
|
|
528
|
+
</CardTarget>
|
|
471
529
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
530
|
+
{/* The heart, and only the heart, is a separate CONTROL outside the
|
|
531
|
+
anchor: a button inside a link is neither valid HTML nor operable.
|
|
532
|
+
Its refusal gets a line of its own here, which is the whole reason
|
|
533
|
+
it is a row under the card rather than a glyph floating on the
|
|
534
|
+
photograph. */}
|
|
535
|
+
{props.showFavorite === false ? null : (
|
|
536
|
+
<div
|
|
537
|
+
style={{
|
|
538
|
+
paddingInline: token.paddingSM,
|
|
539
|
+
paddingBlockEnd: token.paddingSM,
|
|
540
|
+
}}
|
|
541
|
+
>
|
|
542
|
+
<GatedControl
|
|
543
|
+
gate={favorite.gate}
|
|
544
|
+
testId="listings-card-actions"
|
|
545
|
+
style={{ width: "100%" }}
|
|
546
|
+
>
|
|
547
|
+
{(bind) => (
|
|
548
|
+
<Flex justify="flex-end" style={{ width: "100%" }}>
|
|
549
|
+
<Button
|
|
550
|
+
disabled={bind.disabled}
|
|
551
|
+
data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
|
|
552
|
+
{...(bind["aria-describedby"] !== undefined
|
|
553
|
+
? { "aria-describedby": bind["aria-describedby"] }
|
|
554
|
+
: {})}
|
|
555
|
+
aria-label={favoriteLabel}
|
|
556
|
+
aria-pressed={favorite.favorited}
|
|
557
|
+
data-testid="listings-card-favorite"
|
|
558
|
+
data-favorited={String(favorite.favorited)}
|
|
559
|
+
data-analytics="none"
|
|
560
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
561
|
+
onClick={favorite.toggle}
|
|
562
|
+
icon={<HeartIcon filled={favorite.favorited} />}
|
|
563
|
+
/>
|
|
564
|
+
</Flex>
|
|
565
|
+
)}
|
|
566
|
+
</GatedControl>
|
|
567
|
+
|
|
568
|
+
{/* The door. `GatedControl` above already prints the reason and
|
|
569
|
+
wires `aria-describedby` to it; what it cannot know is WHERE a
|
|
570
|
+
visitor signs in, which is the container's business and arrives
|
|
571
|
+
as `signIn`. On a grid `blockedReason="line"` drops the door and
|
|
572
|
+
keeps the sentence — twenty-four doors to one place is not
|
|
573
|
+
twenty-four pieces of help. */}
|
|
574
|
+
{favoriteGate.reason === undefined || blockedReason === "line" ? null : (
|
|
575
|
+
<Typography.Text
|
|
576
|
+
type="secondary"
|
|
577
|
+
data-testid="listings-card-favorite-blocked"
|
|
578
|
+
>
|
|
579
|
+
<SignInLink cta={props.signIn} testId="listings-card-sign-in" />
|
|
580
|
+
</Typography.Text>
|
|
581
|
+
)}
|
|
582
|
+
</div>
|
|
583
|
+
)}
|
|
584
|
+
</div>
|
|
486
585
|
</div>
|
|
487
|
-
|
|
586
|
+
</div>
|
|
488
587
|
</Card>
|
|
489
588
|
</SkinTheme>
|
|
490
589
|
);
|
|
@@ -25,6 +25,7 @@ import type { CSSProperties, ReactElement } from "react";
|
|
|
25
25
|
import { useMemo } from "react";
|
|
26
26
|
import { Typography, theme as antdTheme } from "antd";
|
|
27
27
|
import { Image } from "@stapel/image";
|
|
28
|
+
import { SkinCarousel } from "@stapel/tokens-antd/skin";
|
|
28
29
|
import { useT } from "@stapel/core";
|
|
29
30
|
import { radii, spacing } from "@stapel/tokens";
|
|
30
31
|
import { useListingsRuntime } from "../model/context.js";
|
|
@@ -133,3 +134,66 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
|
|
|
133
134
|
/>
|
|
134
135
|
);
|
|
135
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A listing's photos as ONE swipeable strip — the shape every card surface in
|
|
140
|
+
* this pair uses, so there is one gallery and not three.
|
|
141
|
+
*
|
|
142
|
+
* ── Why the strip is never inside a card's anchor ──────────────────────────
|
|
143
|
+
*
|
|
144
|
+
* A `<SkinCarousel>` is a scroll container with its own tab stop, and a
|
|
145
|
+
* horizontal swipe that ends inside an `<a>` is a swipe the browser may
|
|
146
|
+
* deliver as a click: every attempt to look at photo two would open the
|
|
147
|
+
* listing. A link may not contain a control, and a swipeable strip is a
|
|
148
|
+
* control. So a card renders this as a SIBLING of its anchor and keeps the
|
|
149
|
+
* anchor around everything a person READS — which is the arrangement
|
|
150
|
+
* `<ListingSerpCard>` has shipped since it existed, and the one the live phone
|
|
151
|
+
* SERP was measured correct on while the desktop card (a still `<img>` inside
|
|
152
|
+
* the anchor, one photo, no dots) was not.
|
|
153
|
+
*
|
|
154
|
+
* A listing with no photos still gets one slide, so a row's height does not
|
|
155
|
+
* depend on whether a seller uploaded anything, and a ONE-photo strip gets
|
|
156
|
+
* neither peek nor dots: the sliver of a next slide is an affordance for
|
|
157
|
+
* something that is there.
|
|
158
|
+
*/
|
|
159
|
+
export function ListingPhotoStrip(props: {
|
|
160
|
+
/** The stored references, in the seller's order. */
|
|
161
|
+
readonly images: readonly string[];
|
|
162
|
+
/** The listing's title — the fallback alt for a single photo. */
|
|
163
|
+
readonly title: string;
|
|
164
|
+
/** The surface's own test id, so a screen holding two kinds of card does
|
|
165
|
+
* not hand a test two elements under one name. */
|
|
166
|
+
readonly testId: string;
|
|
167
|
+
}): ReactElement {
|
|
168
|
+
const t = useT();
|
|
169
|
+
const { images, title } = props;
|
|
170
|
+
const many = images.length > 1;
|
|
171
|
+
return (
|
|
172
|
+
<SkinCarousel
|
|
173
|
+
label={t(LISTINGS_I18N_KEYS.cardPhotos)}
|
|
174
|
+
aspectRatio={LISTING_PHOTO_ASPECT}
|
|
175
|
+
peek={many}
|
|
176
|
+
dots={many}
|
|
177
|
+
data-testid={props.testId}
|
|
178
|
+
>
|
|
179
|
+
{images.length === 0 ? (
|
|
180
|
+
<ListingPhoto imageRef={undefined} alt={title} />
|
|
181
|
+
) : (
|
|
182
|
+
images.map((reference, index) => (
|
|
183
|
+
<ListingPhoto
|
|
184
|
+
key={reference}
|
|
185
|
+
imageRef={reference}
|
|
186
|
+
alt={
|
|
187
|
+
many
|
|
188
|
+
? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
189
|
+
index: index + 1,
|
|
190
|
+
total: images.length,
|
|
191
|
+
})
|
|
192
|
+
: title
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
))
|
|
196
|
+
)}
|
|
197
|
+
</SkinCarousel>
|
|
198
|
+
);
|
|
199
|
+
}
|