@stapel/listings-react 0.18.2 → 0.20.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 (120) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/api/generated/schema.d.ts +101 -13
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/listingsApi.d.ts +26 -1
  5. package/dist/api/listingsApi.d.ts.map +1 -1
  6. package/dist/api/listingsApi.js +9 -0
  7. package/dist/api/listingsApi.js.map +1 -1
  8. package/dist/api/types.d.ts +91 -6
  9. package/dist/api/types.d.ts.map +1 -1
  10. package/dist/api/types.js +32 -0
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/GateReasonPopover.d.ts.map +1 -1
  13. package/dist/default/GateReasonPopover.js +92 -2
  14. package/dist/default/GateReasonPopover.js.map +1 -1
  15. package/dist/default/ListingCard.d.ts +32 -1
  16. package/dist/default/ListingCard.d.ts.map +1 -1
  17. package/dist/default/ListingCard.js +65 -14
  18. package/dist/default/ListingCard.js.map +1 -1
  19. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  20. package/dist/default/ListingDetailPane.js +18 -9
  21. package/dist/default/ListingDetailPane.js.map +1 -1
  22. package/dist/default/ListingFeedCard.d.ts +32 -7
  23. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  24. package/dist/default/ListingFeedCard.js +11 -3
  25. package/dist/default/ListingFeedCard.js.map +1 -1
  26. package/dist/default/ListingPrice.d.ts +13 -2
  27. package/dist/default/ListingPrice.d.ts.map +1 -1
  28. package/dist/default/ListingPrice.js.map +1 -1
  29. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  30. package/dist/default/ListingSerpCard.js +10 -2
  31. package/dist/default/ListingSerpCard.js.map +1 -1
  32. package/dist/default/favorite.d.ts +11 -0
  33. package/dist/default/favorite.d.ts.map +1 -1
  34. package/dist/default/favorite.js +24 -9
  35. package/dist/default/favorite.js.map +1 -1
  36. package/dist/default/icons.d.ts +17 -3
  37. package/dist/default/icons.d.ts.map +1 -1
  38. package/dist/default/icons.js +2 -1
  39. package/dist/default/icons.js.map +1 -1
  40. package/dist/default/index.d.ts +1 -1
  41. package/dist/default/index.d.ts.map +1 -1
  42. package/dist/default/index.js +4 -1
  43. package/dist/default/index.js.map +1 -1
  44. package/dist/headless/Engagement.d.ts +62 -0
  45. package/dist/headless/Engagement.d.ts.map +1 -0
  46. package/dist/headless/Engagement.js +116 -0
  47. package/dist/headless/Engagement.js.map +1 -0
  48. package/dist/headless/Favorites.d.ts +50 -0
  49. package/dist/headless/Favorites.d.ts.map +1 -1
  50. package/dist/headless/Favorites.js +63 -3
  51. package/dist/headless/Favorites.js.map +1 -1
  52. package/dist/headless/ListingDetail.d.ts +21 -1
  53. package/dist/headless/ListingDetail.d.ts.map +1 -1
  54. package/dist/headless/ListingDetail.js +39 -3
  55. package/dist/headless/ListingDetail.js.map +1 -1
  56. package/dist/i18n/es.d.ts.map +1 -1
  57. package/dist/i18n/es.js +4 -0
  58. package/dist/i18n/es.js.map +1 -1
  59. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  60. package/dist/i18n/generated/errors.es.gen.js +1 -1
  61. package/dist/i18n/generated/errors.gen.d.ts +12 -0
  62. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  63. package/dist/i18n/generated/errors.gen.js +6 -0
  64. package/dist/i18n/generated/errors.gen.js.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  66. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  67. package/dist/i18n/keys.d.ts +7 -0
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +8 -0
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts.map +1 -1
  72. package/dist/i18n/ru.js +6 -0
  73. package/dist/i18n/ru.js.map +1 -1
  74. package/dist/index.d.ts +6 -3
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +5 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/model/engagement.d.ts +85 -0
  79. package/dist/model/engagement.d.ts.map +1 -0
  80. package/dist/model/engagement.js +59 -0
  81. package/dist/model/engagement.js.map +1 -0
  82. package/dist/model/queries.d.ts +28 -1
  83. package/dist/model/queries.d.ts.map +1 -1
  84. package/dist/model/queries.js +36 -0
  85. package/dist/model/queries.js.map +1 -1
  86. package/dist/model/queryKeys.d.ts +12 -13
  87. package/dist/model/queryKeys.d.ts.map +1 -1
  88. package/dist/model/queryKeys.js +15 -0
  89. package/dist/model/queryKeys.js.map +1 -1
  90. package/llms.txt +6 -3
  91. package/manifest.json +71 -3
  92. package/nav-manifest.json +1 -1
  93. package/package.json +8 -8
  94. package/src/analytics/generated/events.json +1 -1
  95. package/src/api/generated/schema.ts +101 -13
  96. package/src/api/listingsApi.ts +39 -0
  97. package/src/api/types.ts +115 -6
  98. package/src/default/GateReasonPopover.tsx +114 -7
  99. package/src/default/ListingCard.tsx +84 -10
  100. package/src/default/ListingDetailPane.tsx +77 -20
  101. package/src/default/ListingFeedCard.tsx +45 -8
  102. package/src/default/ListingPrice.tsx +13 -2
  103. package/src/default/ListingSerpCard.tsx +11 -1
  104. package/src/default/favorite.tsx +49 -21
  105. package/src/default/icons.tsx +21 -3
  106. package/src/default/index.ts +4 -0
  107. package/src/headless/Engagement.tsx +177 -0
  108. package/src/headless/Favorites.tsx +93 -3
  109. package/src/headless/ListingDetail.tsx +64 -4
  110. package/src/i18n/es.ts +7 -0
  111. package/src/i18n/generated/errors.es.gen.ts +1 -1
  112. package/src/i18n/generated/errors.gen.ts +6 -0
  113. package/src/i18n/generated/errors.json +14 -0
  114. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  115. package/src/i18n/keys.ts +8 -0
  116. package/src/i18n/ru.ts +9 -0
  117. package/src/index.ts +27 -0
  118. package/src/model/engagement.ts +118 -0
  119. package/src/model/queries.ts +41 -0
  120. package/src/model/queryKeys.ts +14 -0
@@ -51,7 +51,7 @@
51
51
  * and the default `"column"` renders exactly what existing hosts already get.
52
52
  */
53
53
  import type { ReactElement, ReactNode } from "react";
54
- import { Descriptions, Divider, Flex, Typography } from "antd";
54
+ import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
55
55
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
56
56
  import {
57
57
  ErrorAlert,
@@ -175,6 +175,7 @@ export interface ListingDetailPaneProps
175
175
  export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
176
176
  const t = useT();
177
177
  const { locale } = useI18n();
178
+ const { token } = antdTheme.useToken();
178
179
  const bag = useListingDetail(props.id, {
179
180
  ...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
180
181
  ...(props.categoryFeatures !== undefined
@@ -190,10 +191,19 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
190
191
  const split = props.layout === "split";
191
192
 
192
193
  const favoriteLabel = t(
193
- bag.isFavorited === true
194
+ bag.isFavorited
194
195
  ? LISTINGS_I18N_KEYS.cardFavoriteRemove
195
196
  : LISTINGS_I18N_KEYS.cardFavoriteAdd
196
197
  );
198
+ // Saved is a SOLID accent shape; not-saved (and `is_favorited: null`, which
199
+ // is "nobody asked", not "no") is the outline. The bag flips it on the
200
+ // gesture and rolls it back if the write fails — see `useListingDetail`.
201
+ const heartIcon = (
202
+ <HeartIcon
203
+ filled={bag.isFavorited}
204
+ {...(bag.isFavorited ? { color: token.colorPrimary } : {})}
205
+ />
206
+ );
197
207
 
198
208
  return (
199
209
  <SkinTheme
@@ -334,6 +344,33 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
334
344
  .join(" · ")}
335
345
  </Typography.Text>
336
346
  ) : null}
347
+
348
+ {/* How many people opened it — the listing's own meta line,
349
+ under the title, where a reader looks for how much company
350
+ they have. It spent one release as a row of the
351
+ `<Descriptions>` below (walker D106): wedged between the
352
+ colour attribute and the location row, two screens down,
353
+ read as a PROPERTY OF THE GOODS rather than a fact about
354
+ the page. A view count is not a characteristic of a phone.
355
+
356
+ `bag.viewCount` is `undefined` when the response carries
357
+ no such field, and then there is no line at all — never a
358
+ zero standing in for an absence. There is no favourite
359
+ count beside it because the wire has none: the schema
360
+ carries `is_favorited`, a per-reader boolean, and no
361
+ aggregate anywhere (asserted in test/engagementState). */}
362
+ {bag.viewCount !== undefined ? (
363
+ <Typography.Text
364
+ type="secondary"
365
+ data-testid="listings-detail-meta"
366
+ style={{ display: "block" }}
367
+ >
368
+ {t(LISTINGS_I18N_KEYS.detailViews)}:{" "}
369
+ <span data-testid="listings-detail-views">
370
+ {bag.viewCount}
371
+ </span>
372
+ </Typography.Text>
373
+ ) : null}
337
374
  </>
338
375
  );
339
376
 
@@ -416,9 +453,10 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
416
453
  aria-disabled
417
454
  {...bind}
418
455
  aria-label={favoriteLabel}
419
- aria-pressed={bag.isFavorited === true}
420
- icon={<HeartIcon filled={bag.isFavorited === true} />}
456
+ aria-pressed={bag.isFavorited}
457
+ icon={heartIcon}
421
458
  data-testid="listings-detail-favorite"
459
+ data-favorited={String(bag.isFavorited)}
422
460
  data-analytics="none"
423
461
  data-analytics-reason="business action — host app wraps with its own tracked()"
424
462
  onClick={bag.toggleFavorite}
@@ -435,15 +473,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
435
473
  >
436
474
  {(bind) => (
437
475
  <Button
438
- disabled={bind.disabled}
439
- data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
440
- {...(bind["aria-describedby"] !== undefined
441
- ? { "aria-describedby": bind["aria-describedby"] }
442
- : {})}
476
+ // See `<ListingCard>`: the binding, spread whole.
477
+ {...bind}
443
478
  aria-label={favoriteLabel}
444
- aria-pressed={bag.isFavorited === true}
445
- icon={<HeartIcon filled={bag.isFavorited === true} />}
479
+ aria-pressed={bag.isFavorited}
480
+ icon={heartIcon}
446
481
  data-testid="listings-detail-favorite"
482
+ data-favorited={String(bag.isFavorited)}
447
483
  data-analytics="none"
448
484
  data-analytics-reason="business action — host app wraps with its own tracked()"
449
485
  onClick={bag.toggleFavorite}
@@ -470,14 +506,26 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
470
506
  </Flex>
471
507
  );
472
508
 
473
- const actionError =
474
- actions.error !== undefined && actions.error !== null ? (
509
+ const actionError = (
510
+ <>
511
+ {actions.error !== undefined && actions.error !== null ? (
512
+ <ErrorAlert
513
+ testId="listings-detail-action-error"
514
+ thrown={actions.error}
515
+ variant="inline"
516
+ />
517
+ ) : null}
518
+ {/* A save that did not save. The heart has already rolled
519
+ back to the state the tap started from — that is the
520
+ honest picture and a silent one, so the sentence goes
521
+ beside it. `ErrorAlert` renders nothing for nothing. */}
475
522
  <ErrorAlert
476
- testId="listings-detail-action-error"
477
- thrown={actions.error}
523
+ testId="listings-detail-favorite-error"
524
+ thrown={bag.favoriteError}
478
525
  variant="inline"
479
526
  />
480
- ) : null;
527
+ </>
528
+ );
481
529
 
482
530
  const description = (
483
531
  <>
@@ -565,26 +613,35 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
565
613
  </>
566
614
  );
567
615
 
568
- const meta = (
616
+ const hasStock = listing.stock_quantity != null;
617
+ const hasPlace =
618
+ listing.location_label !== undefined &&
619
+ listing.location_label.length > 0;
620
+
621
+ // Both rows absent draws an empty table, which the view count used
622
+ // to hide by almost always being there. Nothing is nothing.
623
+ const meta = !hasStock && !hasPlace ? null : (
569
624
  <Descriptions size="small" column={1}>
570
625
  {/* Label cell and value cell, which is what a `<Descriptions>`
571
626
  row IS: the label key carries no `{count}` (it did, and the
572
627
  page printed the placeholder), the quantity is the value. */}
573
- {listing.stock_quantity != null ? (
628
+ {hasStock ? (
574
629
  <Descriptions.Item label={t(LISTINGS_I18N_KEYS.detailStock)}>
575
630
  <span data-testid="listings-detail-stock">
576
631
  {listing.stock_quantity}
577
632
  </span>
578
633
  </Descriptions.Item>
579
634
  ) : null}
580
- {listing.location_label !== undefined &&
581
- listing.location_label.length > 0 ? (
635
+ {hasPlace ? (
582
636
  <Descriptions.Item
583
637
  label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
584
638
  >
585
639
  {listing.location_label}
586
640
  </Descriptions.Item>
587
641
  ) : null}
642
+ {/* The view count used to be a third row here. It is a fact
643
+ about the PAGE, not a property of the goods, so it now
644
+ reads on the meta line under the title — see `heading`. */}
588
645
  </Descriptions>
589
646
  );
590
647
 
@@ -24,13 +24,27 @@
24
24
  * has no line to spare. A full row of "Sign in to save this" under every one
25
25
  * of twenty tiles is not twenty pieces of help — it is the feed.
26
26
  *
27
- * The fleet already has the mechanism for exactly this and it is not "hide the
28
- * reason": `GateReasonScopeContext` / `<PaneGate>` pool identical reasons and
29
- * render each ONCE for everything inside the scope, with every control's
30
- * `aria-describedby` still pointing at that single copy. **A container drawing
31
- * a feed should wrap `<FeedGrid>` in a `<PaneGate>`.** Unscoped, the reason
32
- * still renders — over the photo, visible, never behind a hover — because a
33
- * reason a person cannot read is the one outcome the doctrine forbids.
27
+ * The answer this card ships with is the INTERACTION DISCLOSURE the grid card
28
+ * and the SERP card were given in the desktop and mobile fix packs, and it is
29
+ * this card's DEFAULT rather than an opt-in a two-column tile is the one
30
+ * surface in the pair with no line to put a sentence on, so a standing volume
31
+ * here has to overprint the photograph to exist at all.
32
+ *
33
+ * That default is a fix, not a preference. Measured on a live home feed: this
34
+ * was the last surface still printing "Sign in to do this" as standing copy,
35
+ * over the picture, under every tile — and the heart under it was html-
36
+ * `disabled`, so the one gesture that could have replaced the caption was
37
+ * swallowed by the control. Both halves are gone: nothing stands in the tile,
38
+ * and a tap on the heart discloses the reason and the container's sign-in
39
+ * door (`signIn`) while a visually-hidden copy keeps the refusal in the
40
+ * accessibility tree. A host that genuinely wants the standing sentence back
41
+ * asks for it by name with `blockedReason="text"`.
42
+ *
43
+ * `GateReasonScopeContext` / `<PaneGate>` remain the answer for the STANDING
44
+ * arm: they pool identical reasons and render each ONCE for everything inside
45
+ * the scope, with every control's `aria-describedby` still pointing at that
46
+ * single copy. A container drawing a feed in that arm should wrap
47
+ * `<FeedGrid>` in a `<PaneGate>`.
34
48
  *
35
49
  * ── Two lines of title, and then it stops ─────────────────────────────────
36
50
  *
@@ -43,14 +57,18 @@
43
57
  import type { CSSProperties, ReactElement, ReactNode } from "react";
44
58
  import { Flex, Typography } from "antd";
45
59
  import { SkinTheme } from "@stapel/tokens-antd/skin";
60
+ import type { SignInCta } from "@stapel/core";
46
61
  import { useT } from "@stapel/core";
47
62
  import { radii, spacing } from "@stapel/tokens";
48
63
  import type { ListingCard as ListingCardData } from "../api/types.js";
49
64
  import { lifecycleCaption } from "../model/status.js";
65
+ import { isListingViewed } from "../model/engagement.js";
66
+ import { useEngagedListing } from "../headless/Engagement.js";
50
67
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
51
68
  import { FavoriteHeart } from "./favorite.js";
52
69
  import {
53
70
  CARD_TARGET_STYLE_HREF,
71
+ CARD_VIEWED_CLASS,
54
72
  CardTarget,
55
73
  cardTargetCss,
56
74
  } from "./ListingCard.js";
@@ -118,18 +136,32 @@ export interface ListingFeedCardBaseProps extends ThemeModeProp {
118
136
  /** Hide the favourite entirely — for a surface where it makes no sense.
119
137
  * NOT a way to hide it from visitors. */
120
138
  readonly showFavorite?: boolean;
139
+ /**
140
+ * How loudly a blocked heart states its refusal. **`"popover"` by default
141
+ * on this card, unlike the other two** — a feed tile has no line to print a
142
+ * sentence on, so the standing arm can only overprint the photograph. See
143
+ * the file header for the measurement that made it the default.
144
+ */
145
+ readonly blockedReason?: "text" | "popover";
146
+ /** The container's sign-in door, rendered INSIDE the disclosure. Absent:
147
+ * the disclosure holds the reason alone. */
148
+ readonly signIn?: SignInCta;
121
149
  }
122
150
 
123
151
  export type ListingFeedCardProps = ListingFeedCardBaseProps & ListingCardOpenProps;
124
152
 
125
153
  export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
126
154
  const t = useT();
127
- const { listing } = props;
155
+ // See `<ListingCard>`: the scope's overlay over the row, or the row. This
156
+ // is the surface it matters most on — a home feed is drawn from search.
157
+ const listing = useEngagedListing(props.listing);
128
158
  const status =
129
159
  listing.status === undefined ? undefined : lifecycleCaption(listing.status);
130
160
  const title = listing.title ?? "";
131
161
  const targetLabel =
132
162
  title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
163
+ // Already seen — `false` for every response that carries no such field.
164
+ const viewed = isListingViewed(listing);
133
165
 
134
166
  return (
135
167
  <SkinTheme
@@ -149,6 +181,9 @@ export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
149
181
  {...(status !== undefined
150
182
  ? { "data-listing-status": status.status }
151
183
  : {})}
184
+ {...(viewed
185
+ ? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
186
+ : {})}
152
187
  >
153
188
  {/* One anchor over the whole tile. The photo is a still `<img>`, so
154
189
  unlike the SERP card's swipeable strip it is safe inside a link —
@@ -211,6 +246,8 @@ export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
211
246
  listingId={listing.id}
212
247
  favorited={listing.is_favorited}
213
248
  testId="listings-feed-favorite"
249
+ blockedReason={props.blockedReason ?? "popover"}
250
+ {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
214
251
  style={HEART}
215
252
  />
216
253
  )}
@@ -37,8 +37,19 @@ import {
37
37
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
38
38
 
39
39
  export interface ListingPriceProps {
40
- /** The amount as the wire spells it — a decimal string. */
41
- readonly amount: string | undefined;
40
+ /**
41
+ * The amount as the wire spells it — a decimal string, or `null` for a
42
+ * listing that carries no price at all.
43
+ *
44
+ * `null` is in the TYPE since stapel-listings 0.17 spelled it in the
45
+ * schema (`price?: string | null`, D51: a blank price stays null
46
+ * server-side). It was always in the DATA — `hasAmount` below has guarded
47
+ * for it from the start, with a comment saying the serializer hands it
48
+ * through an unguarded `string | undefined`. Now the type says what the
49
+ * runtime already knew, and the guard stops being a defence against its
50
+ * own signature.
51
+ */
52
+ readonly amount: string | null | undefined;
42
53
  /** The code the amount is quoted in (`Listing.currency`). */
43
54
  readonly currency?: string | undefined;
44
55
  }
@@ -88,6 +88,8 @@ import {
88
88
  } from "../model/features.js";
89
89
  import type { FeatureCopySource } from "../model/features.js";
90
90
  import { lifecycleCaption } from "../model/status.js";
91
+ import { isListingViewed } from "../model/engagement.js";
92
+ import { useEngagedListing } from "../headless/Engagement.js";
91
93
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
92
94
  import { PriceTrendIcon } from "./icons.js";
93
95
  import { FavoriteHeart } from "./favorite.js";
@@ -97,6 +99,7 @@ import {
97
99
  CARD_MEDIA_CLASS,
98
100
  CARD_QUERY_CLASS,
99
101
  CARD_TARGET_STYLE_HREF,
102
+ CARD_VIEWED_CLASS,
100
103
  CardTarget,
101
104
  cardTargetCss,
102
105
  } from "./ListingCard.js";
@@ -172,7 +175,9 @@ const BODY: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
172
175
 
173
176
  export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
174
177
  const t = useT();
175
- const { listing, priceTrend } = props;
178
+ const { priceTrend } = props;
179
+ // See `<ListingCard>`: the scope's overlay over the row, or the row.
180
+ const listing = useEngagedListing(props.listing);
176
181
  const { token } = antdTheme.useToken();
177
182
 
178
183
  const badgeDaos = asFeatureDaoList(listing.features_badges);
@@ -200,6 +205,8 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
200
205
  const photos = listing.images ?? [];
201
206
  const currency =
202
207
  listing.currency !== undefined ? { currency: listing.currency } : {};
208
+ // Already seen — `false` for every response that carries no such field.
209
+ const viewed = isListingViewed(listing);
203
210
 
204
211
  const rail =
205
212
  props.actionsRail !== undefined || props.showFavorite !== false ? (
@@ -240,6 +247,9 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
240
247
  {...(status !== undefined
241
248
  ? { "data-listing-status": status.status }
242
249
  : {})}
250
+ {...(viewed
251
+ ? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
252
+ : {})}
243
253
  styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
244
254
  style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
245
255
  >
@@ -26,8 +26,9 @@
26
26
  * list or a grid of these cards should wrap it in one.
27
27
  */
28
28
  import type { CSSProperties, ReactElement } from "react";
29
+ import { theme as antdTheme } from "antd";
29
30
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
30
- import { GatedControl } from "@stapel/tokens-antd/skin";
31
+ import { ErrorAlert, GatedControl } from "@stapel/tokens-antd/skin";
31
32
  import type { SignInCta } from "@stapel/core";
32
33
  import { useActionGate, useT } from "@stapel/core";
33
34
  import { useFavoriteToggle } from "../headless/Favorites.js";
@@ -70,22 +71,49 @@ export interface FavoriteHeartProps {
70
71
  * (`signIn`) is the surface's business, not this control's — a disabled antd
71
72
  * button fires no pointer events, so a tooltip here would be a reason nobody
72
73
  * could read on any device (`stapel/no-tooltip-in-skin`).
74
+ *
75
+ * ── It is never html-`disabled`, in either volume ─────────────────────────
76
+ *
77
+ * `aria-disabled` plus a live handler, always. The refusal happens on
78
+ * ACTIVATION — `useFavoriteToggle().toggle` is a no-op while the gate is
79
+ * blocked, so the click cannot write anything — and that is the only shape of
80
+ * refusal a person can interrogate. A `disabled` DOM button is inert: it
81
+ * takes no focus, receives no pointer events, and swallows the very tap that
82
+ * was supposed to explain it. Measured on a phone: a signed-out visitor's
83
+ * heart produced no toast, no reason and no navigation, and on a touch device
84
+ * there is no hover to fall back on.
73
85
  */
74
86
  export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
75
87
  const t = useT();
88
+ const { token } = antdTheme.useToken();
76
89
  const favorite = useFavoriteToggle(props.listingId, props.favorited);
77
90
  const label = t(
78
91
  favorite.favorited
79
92
  ? LISTINGS_I18N_KEYS.cardFavoriteRemove
80
93
  : LISTINGS_I18N_KEYS.cardFavoriteAdd
81
94
  );
95
+ // Saved is a SOLID accent shape, not-saved is the outline. `is_favorited:
96
+ // null` — an anonymous read's "nobody asked" — resolves to not-saved in the
97
+ // bag, so it draws the outline and never a third look of its own.
98
+ const icon = (
99
+ <HeartIcon
100
+ filled={favorite.favorited}
101
+ {...(favorite.favorited ? { color: token.colorPrimary } : {})}
102
+ />
103
+ );
82
104
  // The RESOLVED sentence, not the gate's key — `useActionGate` is the one
83
105
  // place a blocked reason becomes words in this fleet.
84
106
  const reason = useActionGate(favorite.gate).reason;
107
+ // A failed save, stated where the heart is. The rollback already put the
108
+ // icon back; this says why it went back.
109
+ const failure = (
110
+ <ErrorAlert
111
+ testId={`${props.testId}-error`}
112
+ thrown={favorite.error}
113
+ variant="inline"
114
+ />
115
+ );
85
116
  if (props.blockedReason === "popover" && reason !== undefined) {
86
- // `aria-disabled`, never `disabled`: the gate already refuses the action
87
- // (`toggle` is a no-op while blocked), and an html-disabled button
88
- // swallows the hover, the focus and the tap the disclosure opens on.
89
117
  return (
90
118
  <GateReasonPopover
91
119
  reason={reason}
@@ -105,7 +133,7 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
105
133
  data-analytics="none"
106
134
  data-analytics-reason="business action — host app wraps with its own tracked()"
107
135
  onClick={favorite.toggle}
108
- icon={<HeartIcon filled={favorite.favorited} />}
136
+ icon={icon}
109
137
  {...(props.style !== undefined ? { style: props.style } : {})}
110
138
  />
111
139
  )}
@@ -120,22 +148,22 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
120
148
  {...(props.style !== undefined ? { style: props.style } : {})}
121
149
  >
122
150
  {(bind) => (
123
- <Button
124
- shape="circle"
125
- disabled={bind.disabled}
126
- data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
127
- {...(bind["aria-describedby"] !== undefined
128
- ? { "aria-describedby": bind["aria-describedby"] }
129
- : {})}
130
- aria-label={label}
131
- aria-pressed={favorite.favorited}
132
- data-testid={props.testId}
133
- data-favorited={String(favorite.favorited)}
134
- data-analytics="none"
135
- data-analytics-reason="business action — host app wraps with its own tracked()"
136
- onClick={favorite.toggle}
137
- icon={<HeartIcon filled={favorite.favorited} />}
138
- />
151
+ <>
152
+ <Button
153
+ shape="circle"
154
+ // See `<ListingCard>`: the substrate's binding, spread whole.
155
+ {...bind}
156
+ aria-label={label}
157
+ aria-pressed={favorite.favorited}
158
+ data-testid={props.testId}
159
+ data-favorited={String(favorite.favorited)}
160
+ data-analytics="none"
161
+ data-analytics-reason="business action — host app wraps with its own tracked()"
162
+ onClick={favorite.toggle}
163
+ icon={icon}
164
+ />
165
+ {failure}
166
+ </>
139
167
  )}
140
168
  </GatedControl>
141
169
  );
@@ -13,14 +13,32 @@
13
13
  */
14
14
  import type { ReactElement } from "react";
15
15
 
16
- export function HeartIcon(props: { filled: boolean }): ReactElement {
16
+ export interface HeartIconProps {
17
+ /** Saved: the glyph is a solid shape rather than an outline. THE state a
18
+ * person reads off a card, so it is the one prop this icon has. */
19
+ readonly filled: boolean;
20
+ /**
21
+ * The accent a SAVED heart is painted in — an antd theme token the caller
22
+ * resolved (`token.colorPrimary`), never a literal.
23
+ *
24
+ * The icon stays `currentColor` by default, which is what keeps it
25
+ * theme-neutral in every other state; this is the one place a colour is
26
+ * warranted, because "filled" and "outline" of the same neutral grey is a
27
+ * difference a person reads at a glance on a mock-up and misses entirely on
28
+ * a 170px feed tile in daylight.
29
+ */
30
+ readonly color?: string;
31
+ }
32
+
33
+ export function HeartIcon(props: HeartIconProps): ReactElement {
34
+ const paint = props.color ?? "currentColor";
17
35
  return (
18
36
  <svg
19
37
  width="16"
20
38
  height="16"
21
39
  viewBox="0 0 24 24"
22
- fill={props.filled ? "currentColor" : "none"}
23
- stroke="currentColor"
40
+ fill={props.filled ? paint : "none"}
41
+ stroke={paint}
24
42
  strokeWidth="2"
25
43
  strokeLinecap="round"
26
44
  strokeLinejoin="round"
@@ -39,6 +39,10 @@ export {
39
39
  // threshold (and for a test that measures it). See `<ListingCard>`'s header.
40
40
  LISTING_CARD_ROW_MIN,
41
41
  LISTING_CARD_ROW_MEDIA,
42
+ // The already-seen mark, for a container that wants to dim its own chrome
43
+ // on the same rule (and for a test that reads it). All three cards take it.
44
+ CARD_VIEWED_CLASS,
45
+ LISTING_VIEWED_OPACITY,
42
46
  } from "./ListingCard.js";
43
47
  export type {
44
48
  ListingCardProps,