@stapel/listings-react 0.7.0 → 0.8.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 (57) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/api/generated/schema.d.ts +92 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/ListingCard.d.ts +56 -0
  5. package/dist/default/ListingCard.d.ts.map +1 -1
  6. package/dist/default/ListingCard.js +92 -29
  7. package/dist/default/ListingCard.js.map +1 -1
  8. package/dist/headless/Favorites.d.ts.map +1 -1
  9. package/dist/headless/Favorites.js +19 -7
  10. package/dist/headless/Favorites.js.map +1 -1
  11. package/dist/headless/ListingDetail.d.ts.map +1 -1
  12. package/dist/headless/ListingDetail.js +7 -5
  13. package/dist/headless/ListingDetail.js.map +1 -1
  14. package/dist/headless/useMandateGate.d.ts +33 -2
  15. package/dist/headless/useMandateGate.d.ts.map +1 -1
  16. package/dist/headless/useMandateGate.js +42 -4
  17. package/dist/headless/useMandateGate.js.map +1 -1
  18. package/dist/i18n/es.d.ts.map +1 -1
  19. package/dist/i18n/es.js +2 -1
  20. package/dist/i18n/es.js.map +1 -1
  21. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  22. package/dist/i18n/generated/errors.es.gen.js +1 -1
  23. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  24. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  25. package/dist/i18n/generated/errors.gen.js +3 -0
  26. package/dist/i18n/generated/errors.gen.js.map +1 -1
  27. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  28. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  29. package/dist/i18n/keys.d.ts +11 -1
  30. package/dist/i18n/keys.d.ts.map +1 -1
  31. package/dist/i18n/keys.js +12 -2
  32. package/dist/i18n/keys.js.map +1 -1
  33. package/dist/i18n/ru.d.ts.map +1 -1
  34. package/dist/i18n/ru.js +2 -1
  35. package/dist/i18n/ru.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/llms.txt +3 -3
  41. package/manifest.json +30 -21
  42. package/nav-manifest.json +1 -1
  43. package/package.json +6 -6
  44. package/src/analytics/generated/events.json +1 -1
  45. package/src/api/generated/schema.ts +92 -2
  46. package/src/default/ListingCard.tsx +233 -108
  47. package/src/headless/Favorites.tsx +26 -7
  48. package/src/headless/ListingDetail.tsx +12 -5
  49. package/src/headless/useMandateGate.ts +49 -5
  50. package/src/i18n/es.ts +3 -1
  51. package/src/i18n/generated/errors.es.gen.ts +1 -1
  52. package/src/i18n/generated/errors.gen.ts +3 -0
  53. package/src/i18n/generated/errors.json +7 -0
  54. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  55. package/src/i18n/keys.ts +12 -2
  56. package/src/i18n/ru.ts +3 -1
  57. package/src/index.ts +5 -1
@@ -12,12 +12,21 @@ export interface paths {
12
12
  /**
13
13
  * @description Listings CRUD plus owner lifecycle actions and favorites.
14
14
  *
15
+ * Read and write live in one class, so the guest wall
16
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
17
+ * is applied per ACTION and never as a class permission: an anonymous
18
+ * session must keep browsing and keep its favorites, and only the
19
+ * authorship actions — ``create``, ``update``/``partial_update``,
20
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
21
+ * seller.
22
+ *
15
23
  * **Permissions:** `IsAuthenticatedOrReadOnly`
16
24
  */
17
25
  get: operations["listings_api_v1_listings_list"];
18
26
  put?: never;
19
27
  /**
20
- * @description Listings CRUD plus owner lifecycle actions and favorites.
28
+ * @description Open a draft the first act of becoming a seller, so the guest
29
+ * wall stands here and not only at ``publish``.
21
30
  *
22
31
  * **Permissions:** `IsAuthenticatedOrReadOnly`
23
32
  */
@@ -38,6 +47,14 @@ export interface paths {
38
47
  /**
39
48
  * @description Listings CRUD plus owner lifecycle actions and favorites.
40
49
  *
50
+ * Read and write live in one class, so the guest wall
51
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
52
+ * is applied per ACTION and never as a class permission: an anonymous
53
+ * session must keep browsing and keep its favorites, and only the
54
+ * authorship actions — ``create``, ``update``/``partial_update``,
55
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
56
+ * seller.
57
+ *
41
58
  * **Permissions:** `IsAuthenticatedOrReadOnly`
42
59
  */
43
60
  get: operations["listings_api_v1_listings_retrieve"];
@@ -53,6 +70,14 @@ export interface paths {
53
70
  /**
54
71
  * @description Listings CRUD plus owner lifecycle actions and favorites.
55
72
  *
73
+ * Read and write live in one class, so the guest wall
74
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
75
+ * is applied per ACTION and never as a class permission: an anonymous
76
+ * session must keep browsing and keep its favorites, and only the
77
+ * authorship actions — ``create``, ``update``/``partial_update``,
78
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
79
+ * seller.
80
+ *
56
81
  * **Permissions:** `IsAuthenticatedOrReadOnly`
57
82
  */
58
83
  delete: operations["listings_api_v1_listings_destroy"];
@@ -61,6 +86,14 @@ export interface paths {
61
86
  /**
62
87
  * @description Listings CRUD plus owner lifecycle actions and favorites.
63
88
  *
89
+ * Read and write live in one class, so the guest wall
90
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
91
+ * is applied per ACTION and never as a class permission: an anonymous
92
+ * session must keep browsing and keep its favorites, and only the
93
+ * authorship actions — ``create``, ``update``/``partial_update``,
94
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
95
+ * seller.
96
+ *
64
97
  * **Permissions:** `IsAuthenticatedOrReadOnly`
65
98
  */
66
99
  patch: operations["listings_api_v1_listings_partial_update"];
@@ -78,6 +111,14 @@ export interface paths {
78
111
  /**
79
112
  * @description Listings CRUD plus owner lifecycle actions and favorites.
80
113
  *
114
+ * Read and write live in one class, so the guest wall
115
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
116
+ * is applied per ACTION and never as a class permission: an anonymous
117
+ * session must keep browsing and keep its favorites, and only the
118
+ * authorship actions — ``create``, ``update``/``partial_update``,
119
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
120
+ * seller.
121
+ *
81
122
  * **Permissions:** `IsAuthenticated`
82
123
  */
83
124
  post: operations["listings_api_v1_listings_archive_create"];
@@ -120,6 +161,14 @@ export interface paths {
120
161
  /**
121
162
  * @description Listings CRUD plus owner lifecycle actions and favorites.
122
163
  *
164
+ * Read and write live in one class, so the guest wall
165
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
166
+ * is applied per ACTION and never as a class permission: an anonymous
167
+ * session must keep browsing and keep its favorites, and only the
168
+ * authorship actions — ``create``, ``update``/``partial_update``,
169
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
170
+ * seller.
171
+ *
123
172
  * **Permissions:** `IsAuthenticated`
124
173
  */
125
174
  post: operations["listings_api_v1_listings_favorite_create"];
@@ -139,7 +188,8 @@ export interface paths {
139
188
  get?: never;
140
189
  put?: never;
141
190
  /**
142
- * @description Listings CRUD plus owner lifecycle actions and favorites.
191
+ * @description Put a listing in front of buyers — the act the wall is named for:
192
+ * a seller nobody can reach again is not a seller.
143
193
  *
144
194
  * **Permissions:** `IsAuthenticated`
145
195
  */
@@ -181,6 +231,14 @@ export interface paths {
181
231
  /**
182
232
  * @description Listings CRUD plus owner lifecycle actions and favorites.
183
233
  *
234
+ * Read and write live in one class, so the guest wall
235
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
236
+ * is applied per ACTION and never as a class permission: an anonymous
237
+ * session must keep browsing and keep its favorites, and only the
238
+ * authorship actions — ``create``, ``update``/``partial_update``,
239
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
240
+ * seller.
241
+ *
184
242
  * **Permissions:** `AllowAny`
185
243
  */
186
244
  get: operations["listings_api_v1_listings_status_retrieve"];
@@ -204,6 +262,14 @@ export interface paths {
204
262
  /**
205
263
  * @description Listings CRUD plus owner lifecycle actions and favorites.
206
264
  *
265
+ * Read and write live in one class, so the guest wall
266
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
267
+ * is applied per ACTION and never as a class permission: an anonymous
268
+ * session must keep browsing and keep its favorites, and only the
269
+ * authorship actions — ``create``, ``update``/``partial_update``,
270
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
271
+ * seller.
272
+ *
207
273
  * **Permissions:** `IsAuthenticated`
208
274
  */
209
275
  post: operations["listings_api_v1_listings_unfavorite_create"];
@@ -223,6 +289,14 @@ export interface paths {
223
289
  /**
224
290
  * @description Listings CRUD plus owner lifecycle actions and favorites.
225
291
  *
292
+ * Read and write live in one class, so the guest wall
293
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
294
+ * is applied per ACTION and never as a class permission: an anonymous
295
+ * session must keep browsing and keep its favorites, and only the
296
+ * authorship actions — ``create``, ``update``/``partial_update``,
297
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
298
+ * seller.
299
+ *
226
300
  * **Permissions:** `IsAuthenticated`
227
301
  */
228
302
  get: operations["listings_api_v1_listings_validate_draft_retrieve"];
@@ -244,6 +318,14 @@ export interface paths {
244
318
  /**
245
319
  * @description Listings CRUD plus owner lifecycle actions and favorites.
246
320
  *
321
+ * Read and write live in one class, so the guest wall
322
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
323
+ * is applied per ACTION and never as a class permission: an anonymous
324
+ * session must keep browsing and keep its favorites, and only the
325
+ * authorship actions — ``create``, ``update``/``partial_update``,
326
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
327
+ * seller.
328
+ *
247
329
  * **Permissions:** `IsAuthenticated`
248
330
  */
249
331
  get: operations["listings_api_v1_listings_my_counters_retrieve"];
@@ -265,6 +347,14 @@ export interface paths {
265
347
  /**
266
348
  * @description Listings CRUD plus owner lifecycle actions and favorites.
267
349
  *
350
+ * Read and write live in one class, so the guest wall
351
+ * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
352
+ * is applied per ACTION and never as a class permission: an anonymous
353
+ * session must keep browsing and keep its favorites, and only the
354
+ * authorship actions — ``create``, ``update``/``partial_update``,
355
+ * ``save-draft``, ``publish`` — are the ones that turn a caller into a
356
+ * seller.
357
+ *
268
358
  * **Permissions:** `IsAuthenticated`
269
359
  */
270
360
  get: operations["listings_api_v1_listings_my_favorites_list"];
@@ -6,6 +6,26 @@
6
6
  * the CONTAINER is the seam, which is why this component takes a plain card
7
7
  * row and a plain `href` rather than reaching for a router.
8
8
  *
9
+ * ── The CARD is the link ───────────────────────────────────────────────────
10
+ *
11
+ * Owner ruling (2026-08-28, from the live stand): a card carrying its own
12
+ * "view" button is plainly wrong. The card used to render a separate "Open"
13
+ * control under its own content — a full-width primary button captioned with
14
+ * `listings.card.open` — so a person looking at a photo, a price and a title
15
+ * had to find and press a fourth thing to act on the three they were reading.
16
+ * Nothing on a classified works that way: the card IS the target, and the only
17
+ * separate control on it is the favourite heart.
18
+ *
19
+ * So the photo, the price, the title, the badges and the location now live
20
+ * INSIDE one anchor that covers the whole card, and `listings.card.open` is
21
+ * retired rather than left orphaned in three catalogues.
22
+ *
23
+ * What that must not cost is the anchor semantics won earlier: this is a real
24
+ * `<a href>`, so middle-click opens a tab, ⌘-click opens a tab, "copy link
25
+ * address" works and a crawler can follow it. It is NOT an `onClick` on a div,
26
+ * which is the shape every "whole card clickable" rewrite reaches for first
27
+ * and which has none of those properties.
28
+ *
9
29
  * ── One click, one navigation ──────────────────────────────────────────────
10
30
  *
11
31
  * `href` and `onOpen` used to be two optional props, and a card given both
@@ -14,6 +34,25 @@
14
34
  * button, or neither — and `linkComponent` rides on the link arm so a
15
35
  * container can hand in its router's `<Link>` and keep the anchor.
16
36
  *
37
+ * ── The accessible name is the TITLE, and only the title ───────────────────
38
+ *
39
+ * An anchor's name is computed from its contents unless it is given one, and
40
+ * the contents here are a photo, a price, three badges and a place: a screen
41
+ * reader reading a list of forty of those announces forty paragraphs. The
42
+ * anchor therefore carries an explicit `aria-label` — the listing's title,
43
+ * nothing else — and everything inside it stays readable by ordinary browsing.
44
+ * A listing with no title falls back to `listings.card.untitled`, because a
45
+ * link announced as nothing is worse than one announced as untitled.
46
+ *
47
+ * ── Why the heart is a row under the card and not floating on the photo ────
48
+ *
49
+ * Because for a signed-out visitor — which is most of the traffic a storefront
50
+ * gets — the heart is BLOCKED, and a blocked control states its reason as text
51
+ * beside it. There is nowhere to put that sentence on top of a photograph. The
52
+ * heart therefore sits in its own row beneath the content, outside the anchor
53
+ * (a button inside a link is neither valid nor operable), where the reason has
54
+ * a line to live on.
55
+ *
17
56
  * ── What it renders without asking the server anything else ────────────────
18
57
  *
19
58
  * Badges. `features_badges` is a stored DAO projection, and a DAO carries the
@@ -34,8 +73,8 @@
34
73
  * reason nobody could read on any device. That arm is gone; `blockedReason`
35
74
  * is now "text" (reason + door) or "line" (reason alone, for a grid).
36
75
  */
37
- import type { ReactElement, ReactNode } from "react";
38
- import { Button, Card, Flex, Typography } from "antd";
76
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
77
+ import { Button, Card, Flex, Typography, theme as antdTheme } from "antd";
39
78
  import { GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
40
79
  import { useActionGate, useT } from "@stapel/core";
41
80
  import type { LinkComponent, SignInCtaProp } from "@stapel/core";
@@ -109,6 +148,53 @@ export type ListingCardOpenProps =
109
148
  */
110
149
  export type ListingCardBlockedReason = "text" | "line";
111
150
 
151
+ /** The class the whole-card target carries, for {@link cardTargetCss}. */
152
+ export const CARD_TARGET_CLASS = "stapel-listing-card-target";
153
+
154
+ /** The `href` the hoisted card stylesheet is deduplicated by. */
155
+ export const CARD_TARGET_STYLE_HREF = "stapel-listings-card-target";
156
+
157
+ /**
158
+ * The one rule an inline style cannot express: `:focus-visible`.
159
+ *
160
+ * A whole-card link is the largest focus target on a results page and it must
161
+ * SHOW that it has focus — a keyboard visitor tabbing a grid of forty cards
162
+ * with no ring has no idea which one Enter will open. The outline is drawn
163
+ * from the theme's own focus colour, which arrives as a custom property on the
164
+ * element (the sheet is static, so one hoisted copy serves either theme).
165
+ *
166
+ * `--listing-*` rather than `--stapel-*`: the `--stapel-` namespace is the
167
+ * design system's ROLE catalogue and this is a component's private plumbing.
168
+ */
169
+ export function cardTargetCss(): string {
170
+ return [
171
+ `.${CARD_TARGET_CLASS}{display:block;color:inherit;text-decoration:none}`,
172
+ `.${CARD_TARGET_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
173
+ ].join("");
174
+ }
175
+
176
+ /** The anchor: a block that inherits the card's own type colour rather than
177
+ * painting every card's contents link-blue. */
178
+ const TARGET_STYLE: CSSProperties = {
179
+ display: "block",
180
+ color: "inherit",
181
+ textDecoration: "none",
182
+ };
183
+
184
+ /** The callback arm's button, reset to look like the anchor does. A card that
185
+ * is a target on one deployment and a card-plus-a-button on another would be
186
+ * two different products. */
187
+ const BUTTON_TARGET_STYLE: CSSProperties = {
188
+ ...TARGET_STYLE,
189
+ width: "100%",
190
+ padding: 0,
191
+ border: "none",
192
+ background: "none",
193
+ font: "inherit",
194
+ textAlign: "start",
195
+ cursor: "pointer",
196
+ };
197
+
112
198
  export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
113
199
  readonly listing: ListingCardData;
114
200
  /** See {@link ListingCardBlockedReason}. Default `"text"`. */
@@ -124,60 +210,66 @@ export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
124
210
  export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
125
211
 
126
212
  /**
127
- * The one control that opens the card: an anchor, a button, or nothing.
213
+ * The card's own body, wrapped in whatever makes it openable: an anchor, a
214
+ * button, or nothing at all.
128
215
  *
129
216
  * Exactly one of the three renders, so exactly one navigation happens per
130
- * click. That is the whole fix — the branch below has no arm in which both a
131
- * handler and an `href` reach the DOM.
217
+ * click — the branch below has no arm in which both a handler and an `href`
218
+ * reach the DOM.
132
219
  *
133
- * It is the card's PRIMARY action and is drawn as one: a full-width button,
134
- * which on a phone is 44px tall because `SkinTheme` sets antd's
135
- * `controlHeight` there. It was a bare text link beside a 40px icon button,
136
- * with no hierarchy between them and neither one a real touch target.
220
+ * The BUTTON arm exists for a container that routes by callback. It wraps the
221
+ * same content in a `<button>` reset to look like nothing, rather than drawing
222
+ * a separate captioned control: a card that is a target on one deployment and
223
+ * a card-plus-a-button on another would be two different products.
137
224
  */
138
- function OpenControl(
139
- props: ListingCardOpenProps & { readonly listingId: number }
140
- ): ReactElement | null {
141
- const t = useT();
142
- const label = t(LISTINGS_I18N_KEYS.cardOpen);
225
+ function CardTarget(
226
+ props: ListingCardOpenProps & {
227
+ readonly listingId: number;
228
+ readonly label: string;
229
+ readonly children: ReactNode;
230
+ }
231
+ ): ReactElement {
232
+ const { label, children } = props;
143
233
 
144
234
  if (props.href !== undefined) {
145
235
  const Link = props.linkComponent;
146
236
  // The host's component is rendered as it comes: this pair has no CSS and
147
- // no way to hand antd's button styling to a foreign element, and a wrapper
148
- // element around a link is a click target that is not the link. A host
149
- // that wants the antd look styles its own `<Link>` — it is one component,
150
- // written once, and it is already the thing that knows the design system.
237
+ // no way to hand its own styling to a foreign element. A wrapper element
238
+ // around a link would be a click target that is not the link.
151
239
  return Link !== undefined ? (
152
240
  <Link
153
241
  href={props.href}
154
242
  aria-label={label}
243
+ className={CARD_TARGET_CLASS}
155
244
  data-testid="listings-card-open"
156
245
  data-analytics="none"
157
246
  data-analytics-reason="business action — host app wraps with its own tracked()"
158
247
  >
159
- {label}
248
+ {children}
160
249
  </Link>
161
250
  ) : (
162
- <Button
163
- type="primary"
164
- block
251
+ <a
165
252
  href={props.href}
253
+ aria-label={label}
254
+ className={CARD_TARGET_CLASS}
255
+ style={TARGET_STYLE}
166
256
  data-testid="listings-card-open"
167
257
  data-analytics="none"
168
258
  data-analytics-reason="business action — host app wraps with its own tracked()"
169
259
  >
170
- {label}
171
- </Button>
260
+ {children}
261
+ </a>
172
262
  );
173
263
  }
174
264
 
175
265
  if (props.onOpen !== undefined) {
176
266
  const onOpen = props.onOpen;
177
267
  return (
178
- <Button
179
- type="primary"
180
- block
268
+ <button
269
+ type="button"
270
+ aria-label={label}
271
+ className={CARD_TARGET_CLASS}
272
+ style={BUTTON_TARGET_STYLE}
181
273
  data-testid="listings-card-open"
182
274
  data-analytics="none"
183
275
  data-analytics-reason="business action — host app wraps with its own tracked()"
@@ -185,18 +277,20 @@ function OpenControl(
185
277
  onOpen(props.listingId);
186
278
  }}
187
279
  >
188
- {label}
189
- </Button>
280
+ {children}
281
+ </button>
190
282
  );
191
283
  }
192
284
 
193
- return null;
285
+ // No open control at all — a card inside a screen that IS the listing.
286
+ return <div data-testid="listings-card-body">{children}</div>;
194
287
  }
195
288
 
196
289
  export function ListingCard(props: ListingCardProps): ReactElement {
197
290
  const t = useT();
198
291
  const { listing } = props;
199
292
  const favorite = useFavoriteToggle(listing.id, listing.is_favorited);
293
+ const { token } = antdTheme.useToken();
200
294
 
201
295
  const badgeDaos = asFeatureDaoList(listing.features_badges);
202
296
  const badgeFeatures = featuresFromDaoList(badgeDaos);
@@ -214,11 +308,83 @@ export function ListingCard(props: ListingCardProps): ReactElement {
214
308
  : LISTINGS_I18N_KEYS.cardFavoriteAdd
215
309
  );
216
310
 
311
+ const title = listing.title ?? "";
312
+ // The anchor's name is the TITLE and nothing else. A card with no title is
313
+ // still a link, and a link announced as nothing is worse than one announced
314
+ // as untitled.
315
+ const targetLabel =
316
+ title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
317
+
318
+ /**
319
+ * Everything a person reads on the card, in the order a classified reads it:
320
+ * photo, price, title, the seller's own spec line, the badges, the place.
321
+ *
322
+ * The search projection carries `title`, `price`, `currency`,
323
+ * `location_label`, `image` and `published_at` and NO feature badges — so
324
+ * every line below is conditional and the card has to look deliberate with
325
+ * all of them absent. That is why the photo and the price carry the layout:
326
+ * they are the two fields a result always has.
327
+ */
328
+ const content = (
329
+ <>
330
+ <ListingPhoto
331
+ imageRef={listing.images?.[0]}
332
+ alt={title.length > 0 ? title : String(listing.id)}
333
+ />
334
+ <Flex
335
+ vertical
336
+ gap={spacing[1]}
337
+ style={{ minWidth: 0, padding: token.paddingSM }}
338
+ >
339
+ {props.badge}
340
+
341
+ <Typography.Text strong data-testid="listings-card-price">
342
+ <ListingPrice
343
+ amount={listing.price}
344
+ {...(listing.currency !== undefined ? { currency: listing.currency } : {})}
345
+ />
346
+ </Typography.Text>
347
+
348
+ <Typography.Text ellipsis data-testid="listings-card-title">
349
+ {title}
350
+ </Typography.Text>
351
+
352
+ {/* The title features are a stored projection too — the seller's
353
+ "1.5 TB, black" line, already ordered by the server. */}
354
+ {titleDaos.length > 0 ? (
355
+ <Typography.Text type="secondary" ellipsis>
356
+ <FeatureBadges
357
+ features={featuresFromDaoList(titleDaos).map((view) => view.feature)}
358
+ values={featuresDtoFromDaoList(titleDaos)}
359
+ />
360
+ </Typography.Text>
361
+ ) : null}
362
+
363
+ {badgeFeatures.length > 0 ? (
364
+ <FeatureBadges
365
+ features={badgeFeatures.map((view) => view.feature)}
366
+ values={badgeValues}
367
+ />
368
+ ) : null}
369
+
370
+ {listing.location_label !== undefined &&
371
+ listing.location_label.length > 0 ? (
372
+ <Typography.Text type="secondary" data-testid="listings-card-location">
373
+ {listing.location_label}
374
+ </Typography.Text>
375
+ ) : null}
376
+ </Flex>
377
+ </>
378
+ );
379
+
217
380
  return (
218
381
  <SkinTheme
219
382
  surface="bare"
220
383
  {...(props.mode !== undefined ? { mode: props.mode } : {})}
221
384
  >
385
+ <style href={CARD_TARGET_STYLE_HREF} precedence="default">
386
+ {cardTargetCss()}
387
+ </style>
222
388
  <Card
223
389
  size="small"
224
390
  data-testid="listings-card"
@@ -226,74 +392,35 @@ export function ListingCard(props: ListingCardProps): ReactElement {
226
392
  {...(status !== undefined
227
393
  ? { "data-listing-status": status.status }
228
394
  : {})}
229
- styles={{ body: { minWidth: 0 } }}
230
- cover={
231
- <ListingPhoto
232
- imageRef={listing.images?.[0]}
233
- alt={listing.title ?? String(listing.id)}
234
- />
235
- }
395
+ // The body's own padding is zero because the ANCHOR fills the card:
396
+ // padding outside it would be a strip of card that looks pressable and
397
+ // is not. The text block inside the anchor carries the same padding
398
+ // back, from the same token.
399
+ styles={{ body: { minWidth: 0, padding: 0 } }}
400
+ style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
236
401
  >
237
- <Flex vertical gap={spacing[1]} style={{ minWidth: 0 }}>
238
- {props.badge}
239
-
240
- <Typography.Text strong data-testid="listings-card-price">
241
- <ListingPrice
242
- amount={listing.price}
243
- {...(listing.currency !== undefined ? { currency: listing.currency } : {})}
244
- />
245
- </Typography.Text>
246
-
247
- <Typography.Text ellipsis data-testid="listings-card-title">
248
- {listing.title ?? ""}
249
- </Typography.Text>
402
+ <CardTarget {...props} listingId={listing.id} label={targetLabel}>
403
+ {content}
404
+ </CardTarget>
250
405
 
251
- {/* The title features are a stored projection too the seller's
252
- "1.5 TB, black" line, already ordered by the server. */}
253
- {titleDaos.length > 0 ? (
254
- <Typography.Text type="secondary" ellipsis>
255
- <FeatureBadges
256
- features={featuresFromDaoList(titleDaos)
257
- .map((view) => view.feature)}
258
- values={featuresDtoFromDaoList(titleDaos)}
259
- />
260
- </Typography.Text>
261
- ) : null}
262
-
263
- {badgeFeatures.length > 0 ? (
264
- <FeatureBadges
265
- features={badgeFeatures.map((view) => view.feature)}
266
- values={badgeValues}
267
- />
268
- ) : null}
269
-
270
- {listing.location_label !== undefined &&
271
- listing.location_label.length > 0 ? (
272
- <Typography.Text type="secondary" data-testid="listings-card-location">
273
- {listing.location_label}
274
- </Typography.Text>
275
- ) : null}
276
-
277
- {/* The action row is wrapped in ONE `GatedControl`, so the reason a
278
- blocked heart carries lands full width UNDER both controls
279
- rather than in the two-centimetre column beside the icon. */}
280
- {props.showFavorite === false ? (
281
- <OpenControl {...props} listingId={listing.id} />
282
- ) : (
406
+ {/* The heart, and only the heart, lives OUTSIDE the anchor: a button
407
+ inside a link is neither valid HTML nor operable. Its refusal gets
408
+ a line of its own here, which is the whole reason it is a row under
409
+ the card rather than a glyph floating on the photograph. */}
410
+ {props.showFavorite === false ? null : (
411
+ <div
412
+ style={{
413
+ paddingInline: token.paddingSM,
414
+ paddingBlockEnd: token.paddingSM,
415
+ }}
416
+ >
283
417
  <GatedControl
284
418
  gate={favorite.gate}
285
419
  testId="listings-card-actions"
286
420
  style={{ width: "100%" }}
287
421
  >
288
422
  {(bind) => (
289
- <Flex
290
- gap={spacing[2]}
291
- align="center"
292
- style={{ width: "100%", minWidth: 0 }}
293
- >
294
- <div style={{ flex: "1 1 auto", minWidth: 0 }}>
295
- <OpenControl {...props} listingId={listing.id} />
296
- </div>
423
+ <Flex justify="flex-end" style={{ width: "100%" }}>
297
424
  <Button
298
425
  disabled={bind.disabled}
299
426
  data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
@@ -312,25 +439,23 @@ export function ListingCard(props: ListingCardProps): ReactElement {
312
439
  </Flex>
313
440
  )}
314
441
  </GatedControl>
315
- )}
316
442
 
317
- {/* The door. `GatedControl` above already prints the reason and
318
- wires `aria-describedby` to it; what it cannot know is WHERE a
319
- visitor signs in, which is the container's business and arrives
320
- as `signIn`. On a grid `blockedReason="line"` drops the door and
321
- keeps the sentence — twenty-four doors to one place is not
322
- twenty-four pieces of help. */}
323
- {props.showFavorite === false ||
324
- favoriteGate.reason === undefined ||
325
- blockedReason === "line" ? null : (
326
- <Typography.Text
327
- type="secondary"
328
- data-testid="listings-card-favorite-blocked"
329
- >
330
- <SignInLink cta={props.signIn} testId="listings-card-sign-in" />
331
- </Typography.Text>
332
- )}
333
- </Flex>
443
+ {/* The door. `GatedControl` above already prints the reason and
444
+ wires `aria-describedby` to it; what it cannot know is WHERE a
445
+ visitor signs in, which is the container's business and arrives
446
+ as `signIn`. On a grid `blockedReason="line"` drops the door and
447
+ keeps the sentence — twenty-four doors to one place is not
448
+ twenty-four pieces of help. */}
449
+ {favoriteGate.reason === undefined || blockedReason === "line" ? null : (
450
+ <Typography.Text
451
+ type="secondary"
452
+ data-testid="listings-card-favorite-blocked"
453
+ >
454
+ <SignInLink cta={props.signIn} testId="listings-card-sign-in" />
455
+ </Typography.Text>
456
+ )}
457
+ </div>
458
+ )}
334
459
  </Card>
335
460
  </SkinTheme>
336
461
  );