@stapel/listings-react 0.25.9 → 0.26.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 (138) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +77 -9
  3. package/dist/api/generated/schema.d.ts +2 -2
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/CardBadges.d.ts.map +1 -1
  6. package/dist/default/CardBadges.js +3 -1
  7. package/dist/default/CardBadges.js.map +1 -1
  8. package/dist/default/ListingActions.d.ts +108 -0
  9. package/dist/default/ListingActions.d.ts.map +1 -0
  10. package/dist/default/ListingActions.js +29 -0
  11. package/dist/default/ListingActions.js.map +1 -0
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +15 -8
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingDetailPane.d.ts +76 -56
  16. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  17. package/dist/default/ListingDetailPane.js +164 -18
  18. package/dist/default/ListingDetailPane.js.map +1 -1
  19. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  20. package/dist/default/ListingFeedCard.js +2 -1
  21. package/dist/default/ListingFeedCard.js.map +1 -1
  22. package/dist/default/ListingPhoto.d.ts.map +1 -1
  23. package/dist/default/ListingPhoto.js +5 -2
  24. package/dist/default/ListingPhoto.js.map +1 -1
  25. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  26. package/dist/default/ListingSerpCard.js +18 -4
  27. package/dist/default/ListingSerpCard.js.map +1 -1
  28. package/dist/default/MyListingsPane.d.ts.map +1 -1
  29. package/dist/default/MyListingsPane.js +45 -21
  30. package/dist/default/MyListingsPane.js.map +1 -1
  31. package/dist/default/ShareAction.d.ts +32 -0
  32. package/dist/default/ShareAction.d.ts.map +1 -0
  33. package/dist/default/ShareAction.js +116 -0
  34. package/dist/default/ShareAction.js.map +1 -0
  35. package/dist/default/actionRow.d.ts +58 -0
  36. package/dist/default/actionRow.d.ts.map +1 -0
  37. package/dist/default/actionRow.js +128 -0
  38. package/dist/default/actionRow.js.map +1 -0
  39. package/dist/default/cardGallery.d.ts +2 -0
  40. package/dist/default/cardGallery.d.ts.map +1 -1
  41. package/dist/default/cardGallery.js +31 -0
  42. package/dist/default/cardGallery.js.map +1 -1
  43. package/dist/default/favorite.d.ts +25 -0
  44. package/dist/default/favorite.d.ts.map +1 -1
  45. package/dist/default/favorite.js +29 -3
  46. package/dist/default/favorite.js.map +1 -1
  47. package/dist/default/icons.d.ts +19 -0
  48. package/dist/default/icons.d.ts.map +1 -1
  49. package/dist/default/icons.js +23 -0
  50. package/dist/default/icons.js.map +1 -1
  51. package/dist/default/index.d.ts +8 -1
  52. package/dist/default/index.d.ts.map +1 -1
  53. package/dist/default/index.js +6 -1
  54. package/dist/default/index.js.map +1 -1
  55. package/dist/default/notice.d.ts +14 -0
  56. package/dist/default/notice.d.ts.map +1 -0
  57. package/dist/default/notice.js +62 -0
  58. package/dist/default/notice.js.map +1 -0
  59. package/dist/headless/ListingComposer.d.ts +36 -2
  60. package/dist/headless/ListingComposer.d.ts.map +1 -1
  61. package/dist/headless/ListingComposer.js +15 -2
  62. package/dist/headless/ListingComposer.js.map +1 -1
  63. package/dist/headless/MyListings.d.ts +39 -18
  64. package/dist/headless/MyListings.d.ts.map +1 -1
  65. package/dist/headless/MyListings.js +41 -19
  66. package/dist/headless/MyListings.js.map +1 -1
  67. package/dist/headless/Share.d.ts +103 -0
  68. package/dist/headless/Share.d.ts.map +1 -0
  69. package/dist/headless/Share.js +190 -0
  70. package/dist/headless/Share.js.map +1 -0
  71. package/dist/i18n/es.d.ts.map +1 -1
  72. package/dist/i18n/es.js +21 -0
  73. package/dist/i18n/es.js.map +1 -1
  74. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  75. package/dist/i18n/generated/errors.es.gen.js +1 -1
  76. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  77. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  78. package/dist/i18n/generated/errors.gen.js +9 -0
  79. package/dist/i18n/generated/errors.gen.js.map +1 -1
  80. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  81. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  82. package/dist/i18n/keys.d.ts +45 -0
  83. package/dist/i18n/keys.d.ts.map +1 -1
  84. package/dist/i18n/keys.js +60 -0
  85. package/dist/i18n/keys.js.map +1 -1
  86. package/dist/i18n/ru.d.ts.map +1 -1
  87. package/dist/i18n/ru.js +21 -0
  88. package/dist/i18n/ru.js.map +1 -1
  89. package/dist/index.d.ts +5 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +2 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/model/cardBadges.d.ts +52 -3
  94. package/dist/model/cardBadges.d.ts.map +1 -1
  95. package/dist/model/cardBadges.js +74 -12
  96. package/dist/model/cardBadges.js.map +1 -1
  97. package/dist/model/mineSource.d.ts +9 -3
  98. package/dist/model/mineSource.d.ts.map +1 -1
  99. package/dist/model/mineSource.js.map +1 -1
  100. package/dist/model/status.d.ts +62 -13
  101. package/dist/model/status.d.ts.map +1 -1
  102. package/dist/model/status.js +69 -15
  103. package/dist/model/status.js.map +1 -1
  104. package/llms.txt +8 -3
  105. package/manifest.json +96 -4
  106. package/nav-manifest.json +1 -1
  107. package/package.json +11 -11
  108. package/src/analytics/generated/events.json +1 -1
  109. package/src/api/generated/schema.ts +2 -2
  110. package/src/default/CardBadges.tsx +3 -1
  111. package/src/default/ListingActions.tsx +196 -0
  112. package/src/default/ListingCard.tsx +79 -43
  113. package/src/default/ListingDetailPane.tsx +268 -81
  114. package/src/default/ListingFeedCard.tsx +2 -0
  115. package/src/default/ListingPhoto.tsx +28 -0
  116. package/src/default/ListingSerpCard.tsx +39 -12
  117. package/src/default/MyListingsPane.tsx +77 -44
  118. package/src/default/ShareAction.tsx +260 -0
  119. package/src/default/actionRow.ts +131 -0
  120. package/src/default/cardGallery.ts +31 -0
  121. package/src/default/favorite.tsx +68 -1
  122. package/src/default/icons.tsx +60 -0
  123. package/src/default/index.ts +23 -0
  124. package/src/default/notice.ts +68 -0
  125. package/src/headless/ListingComposer.tsx +68 -4
  126. package/src/headless/MyListings.tsx +89 -38
  127. package/src/headless/Share.tsx +278 -0
  128. package/src/i18n/es.ts +28 -0
  129. package/src/i18n/generated/errors.es.gen.ts +1 -1
  130. package/src/i18n/generated/errors.gen.ts +9 -0
  131. package/src/i18n/generated/errors.json +31 -0
  132. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  133. package/src/i18n/keys.ts +63 -0
  134. package/src/i18n/ru.ts +28 -0
  135. package/src/index.ts +22 -0
  136. package/src/model/cardBadges.ts +124 -12
  137. package/src/model/mineSource.ts +9 -3
  138. package/src/model/status.ts +85 -18
@@ -118,6 +118,11 @@ import { isListingViewed } from "../model/engagement.js";
118
118
  import { useEngagedListing } from "../headless/Engagement.js";
119
119
  import { useFavoriteToggle } from "../headless/Favorites.js";
120
120
  import { FavoriteHeart } from "./favorite.js";
121
+ import {
122
+ LISTING_ACTIONS_CLASS,
123
+ LISTING_ACTIONS_OVERLAY_CLASS,
124
+ LISTING_CARD_ACTION_CLASS,
125
+ } from "./actionRow.js";
121
126
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
122
127
  import { CardBadges, CardSpecLine } from "./CardBadges.js";
123
128
  import { SignInLink } from "./SignInLink.js";
@@ -330,7 +335,10 @@ export function cardTargetCss(): string {
330
335
  // of pictures with a torn right edge. One radius, stated by the card,
331
336
  // applied to the box that holds the pictures; the slides inside it are
332
337
  // square because the well is what has the shape.
333
- `${media}{min-inline-size:0;overflow:hidden;` +
338
+ // `position:relative` is the containing block the favourite overlay is
339
+ // pinned inside (see `LISTING_ACTIONS_OVERLAY_CLASS`). A `relative` with
340
+ // no offsets moves no pixel of what is already in the well.
341
+ `${media}{position:relative;min-inline-size:0;overflow:hidden;` +
334
342
  `border-start-start-radius:var(--listing-card-radius);` +
335
343
  `border-start-end-radius:var(--listing-card-radius)}`,
336
344
  `${media} .${SKIN_CAROUSEL_SLIDE_CLASS}{border-radius:0}`,
@@ -670,53 +678,84 @@ export function ListingCard(props: ListingCardProps): ReactElement {
670
678
  ? { linkComponent: props.linkComponent }
671
679
  : {})}
672
680
  />
673
- </div>
674
-
675
- <div className={CARD_MAIN_CLASS}>
676
- <CardTarget {...props} listingId={listing.id} label={targetLabel}>
677
- {content}
678
- </CardTarget>
679
681
 
680
- {/* The heart, and only the heart, is a separate CONTROL outside the
681
- anchor: a button inside a link is neither valid HTML nor operable.
682
- Its refusal gets a line of its own here, which is the whole reason
683
- it is a row under the card rather than a glyph floating on the
684
- photograph. */}
682
+ {/* THE HEART IS ON THE PHOTOGRAPH (owner, 2026-09-06).
683
+ It used to be a row UNDER the card, on the argument that a
684
+ blocked visitor's reason needs a line of text to live on and
685
+ there is nowhere to put one on top of a picture. Half of
686
+ that survived: the reason and the door still stand in their
687
+ own row below (see the block after this one), and only the
688
+ CONTROL moved. What it buys is the ergonomics every
689
+ reference classified has and this card did not — the heart
690
+ in the corner of the picture, where a thumb already is,
691
+ reachable without opening the listing and without the eye
692
+ travelling past a price, a title, a spec line and a place to
693
+ find it.
694
+
695
+ Top-trailing is the one free corner: the strip's dots own
696
+ the bottom centre and the "3 of 16" counter owns the bottom
697
+ trailing corner (`cardGalleryCss`).
698
+
699
+ `stopPropagation` on the bubble phase, not the capture: the
700
+ heart is already a SIBLING of the slide anchors rather than
701
+ a child, so nothing should reach the card from here — but a
702
+ press that did would open the listing instead of saving it,
703
+ and a capture-phase stop would swallow the disclosure's own
704
+ activation on the way in. */}
685
705
  {props.showFavorite === false ? null : (
686
706
  <div
687
- style={{
688
- paddingInline: token.paddingSM,
689
- paddingBlockEnd: token.paddingSM,
707
+ className={`${LISTING_ACTIONS_CLASS} ${LISTING_ACTIONS_OVERLAY_CLASS}`}
708
+ data-testid="listings-card-favorite-overlay"
709
+ onClick={(event) => {
710
+ event.stopPropagation();
690
711
  }}
691
712
  >
692
713
  {/* THE SHARED HEART, not a second copy of it.
693
714
  This card drew its own — a `<Button aria-disabled>` in
694
715
  the popover arm with `onClick={favorite.toggle}` behind
695
716
  it — and that copy stopped tracking `<FavoriteHeart>` the
696
- day the door landed (D431): on the desktop GRID a
697
- visitor's press reached a no-op toggle, announced itself
698
- as unavailable, and went nowhere, while the same press on
699
- the SERP row and the feed card opened the sign-in door.
700
- One control, one refusal shape, one door.
717
+ day the door landed (D431). One control, one refusal
718
+ shape, one door.
701
719
 
702
720
  The two derived ids are pinned to what this card has
703
721
  always published, because a rename is a breaking change
704
722
  dressed as a refactor. */}
705
- <Flex justify="flex-end" style={{ width: "100%" }}>
706
- <FavoriteHeart
707
- listingId={listing.id}
708
- favorited={listing.is_favorited}
709
- testId="listings-card-favorite"
710
- gateTestId="listings-card-actions"
711
- signInTestId="listings-card-sign-in"
712
- style={{ width: "100%" }}
713
- {...(blockedReason === "popover"
714
- ? { blockedReason: "popover" as const }
715
- : {})}
716
- {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
717
- />
718
- </Flex>
723
+ <FavoriteHeart
724
+ listingId={listing.id}
725
+ favorited={listing.is_favorited}
726
+ testId="listings-card-favorite"
727
+ gateTestId="listings-card-actions"
728
+ signInTestId="listings-card-sign-in"
729
+ className={LISTING_CARD_ACTION_CLASS}
730
+ {...(blockedReason === "popover"
731
+ ? { blockedReason: "popover" as const }
732
+ : {})}
733
+ {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
734
+ />
735
+ </div>
736
+ )}
737
+ </div>
719
738
 
739
+ <div className={CARD_MAIN_CLASS}>
740
+ <CardTarget {...props} listingId={listing.id} label={targetLabel}>
741
+ {content}
742
+ </CardTarget>
743
+
744
+ {/* THE REFUSAL'S OWN LINE, which is all that is left down here.
745
+ The control moved onto the photograph; the sentence a
746
+ blocked visitor needs cannot follow it there, so it keeps
747
+ the row it has always had — and the row is drawn only when
748
+ there is something to put in it, rather than as an empty
749
+ strip of padding under every card on the page. */}
750
+ {props.showFavorite === false ||
751
+ favoriteGate.reason === undefined ||
752
+ blockedReason !== "text" ? null : (
753
+ <div
754
+ style={{
755
+ paddingInline: token.paddingSM,
756
+ paddingBlockEnd: token.paddingSM,
757
+ }}
758
+ >
720
759
  {/* The DOOR AS A STANDING LINE, which is this card's own
721
760
  decision and not the heart's: `blockedReason="line"`
722
761
  drops it (twenty-four doors to one place is not
@@ -724,15 +763,12 @@ export function ListingCard(props: ListingCardProps): ReactElement {
724
763
  put it inside the disclosure. The press itself routes
725
764
  through the door in every arm — that part is the heart's.
726
765
  */}
727
- {favoriteGate.reason === undefined ||
728
- blockedReason !== "text" ? null : (
729
- <Typography.Text
730
- type="secondary"
731
- data-testid="listings-card-favorite-blocked"
732
- >
733
- <SignInLink cta={props.signIn} testId="listings-card-sign-in" />
734
- </Typography.Text>
735
- )}
766
+ <Typography.Text
767
+ type="secondary"
768
+ data-testid="listings-card-favorite-blocked"
769
+ >
770
+ <SignInLink cta={props.signIn} testId="listings-card-sign-in" />
771
+ </Typography.Text>
736
772
  </div>
737
773
  )}
738
774
  </div>
@@ -50,6 +50,7 @@
50
50
  * that knows the viewport it granted, never a media query guessed in a leaf —
51
51
  * and the default `"column"` renders exactly what existing hosts already get.
52
52
  */
53
+ import { isValidElement } from "react";
53
54
  import type { ReactElement, ReactNode } from "react";
54
55
  import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
55
56
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
@@ -68,14 +69,19 @@ import {
68
69
  useT,
69
70
  } from "@stapel/core";
70
71
  import type { SignInCta } from "@stapel/core";
71
- import { spacing } from "@stapel/tokens";
72
+ import { cssVar, spacing } from "@stapel/tokens";
72
73
  import { isRedactedValue } from "@stapel/attributes-react";
73
74
  import { useListingDetail } from "../headless/ListingDetail.js";
74
75
  import { useListingActions } from "../headless/ListingActions.js";
75
76
  import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
76
77
  import { formatSpecValue } from "../model/featureText.js";
77
78
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
79
+ import type { ShareChannel } from "../headless/Share.js";
78
80
  import { GateReasonPopover } from "./GateReasonPopover.js";
81
+ import { ListingActions } from "./ListingActions.js";
82
+ import type { ListingActionsConfig } from "./ListingActions.js";
83
+ import { LISTING_ACTION_CLASS } from "./actionRow.js";
84
+ import { useNotice } from "./notice.js";
79
85
  import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
80
86
  import { SignInLink } from "./SignInLink.js";
81
87
  import { HeartIcon } from "./icons.js";
@@ -114,6 +120,28 @@ export const DETAIL_SPLIT_ASIDE = "380px";
114
120
  * postage stamp on the other. */
115
121
  export const DETAIL_PHOTO_MIN = "14rem";
116
122
 
123
+ /**
124
+ * THE GUTTER BETWEEN TWO PHOTOGRAPHS, and it is the page's own (D418).
125
+ *
126
+ * The gallery painted a flat `spacing[3]` — 12px on a 390px phone and 12px on
127
+ * a 1280px desktop — while the page around it had already decided that its
128
+ * edge is 4px on a phone and 24px on a desktop. Measured on the live listing:
129
+ * `getComputedStyle(gallery).gap` answered `12px` at both widths, so neither
130
+ * of the two declared numbers was ever on screen and the tiles sat closer
131
+ * together than the page edge on a desktop and three times further apart than
132
+ * it on a phone.
133
+ *
134
+ * `--stapel-page-gutter` is a RESPONSIVE token role (`@stapel/tokens`: 4px
135
+ * phone, 8px tablet, 24px desktop, declared once with its own media arms), and
136
+ * reading it as a VAR rather than computing a number is the load-bearing half:
137
+ * a value picked in JS is applied at render, so a window resized between
138
+ * renders keeps the gutter it was drawn with, where a var reflows. Written
139
+ * through `cssVar` so a renamed role fails to compile instead of silently
140
+ * resolving to nothing, with the flat value this grid used before as the
141
+ * fallback for a host that loads no token stylesheet.
142
+ */
143
+ export const DETAIL_GALLERY_GUTTER: string = `${cssVar("page-gutter").slice(0, -1)}, ${String(spacing[3])}px)`;
144
+
117
145
  export interface ListingDetailPaneProps
118
146
  extends ThemeModeProp,
119
147
  CategoryFeaturesProp {
@@ -184,10 +212,59 @@ export interface ListingDetailPaneProps
184
212
  /** Open the composer on this listing — the owner's primary. Absent is a real
185
213
  * answer: the button then states that this app has no editing screen. */
186
214
  readonly onEdit?: (id: number) => void;
187
- /** Extra chrome beside the primary (the seller's profile link, a share
188
- * button). Cross-pair navigation is the container's job (spec §6.2 item 5),
189
- * so this pair takes nodes rather than routes. */
190
- readonly actions?: ReactNode;
215
+ /**
216
+ * TWO THINGS UNDER ONE NAME, and the type tells them apart.
217
+ *
218
+ * - a NODE — extra chrome beside the primary (the seller's profile link, a
219
+ * control from another pair). What this prop has always been, unchanged,
220
+ * and still rendered at the end of the buy box. Cross-pair navigation is
221
+ * the container's job (spec §6.2 item 5), so this pair takes nodes
222
+ * rather than routes.
223
+ * - a CONFIG — `{ share: false }` / `{ favorite: false }`, switching off
224
+ * one of the page's own two reader actions.
225
+ *
226
+ * A union rather than a second prop, because they are the same question
227
+ * ("what is in the action row") asked from two sides, and it is
228
+ * unambiguous at runtime: a plain object that is not a React element was
229
+ * never a legal `ReactNode` in the first place — React refuses to render
230
+ * one — so `{ share: false }` cannot be a node that somebody meant.
231
+ */
232
+ readonly actions?: ReactNode | ListingActionsConfig;
233
+ /**
234
+ * The listing's CANONICAL address, for the share sheet — the route the
235
+ * container built (`/l/7`, or an absolute URL), not the address bar.
236
+ *
237
+ * Absent, sharing falls back to `window.location.href`, which is honest for
238
+ * a bare mount and wrong for a real app: the address a visitor is standing
239
+ * on carries the SERP query they arrived from, the page anchor and whatever
240
+ * tracking parameters came with them, and none of that belongs in a link
241
+ * somebody sends to a friend. See `useShare`.
242
+ */
243
+ readonly shareUrl?: string;
244
+ /** Analytics: which channel a completed share went through. */
245
+ readonly onShared?: (channel: ShareChannel) => void;
246
+ /**
247
+ * How many people saved this listing, when the host was told by something
248
+ * else. The listings wire carries `is_favorited` — a per-reader boolean —
249
+ * and no aggregate at all, so this pair never invents the number and never
250
+ * draws a zero in place of "nobody counted".
251
+ */
252
+ readonly favoriteCount?: number;
253
+ /**
254
+ * WHERE THE READER'S TWO ACTIONS SIT.
255
+ *
256
+ * - `"header"` (default) — beside the title, at the trailing edge of the
257
+ * heading row, which is where the reference classified puts them and
258
+ * where a person looks for them on both a phone and a desktop;
259
+ * - `"gallery"` — pinned over the photographs' trailing top corner, for a
260
+ * phone-first host that wants them on the picture. The corner is chosen
261
+ * rather than free: the dots own the bottom centre of the strip and the
262
+ * photo counter owns the bottom trailing corner;
263
+ * - `"buy-box"` — inside `listings-detail-actions`, beside "message the
264
+ * seller", which is where the favourite alone used to live. The escape
265
+ * hatch for a host whose page was laid out around it.
266
+ */
267
+ readonly actionsPlacement?: "header" | "gallery" | "buy-box";
191
268
  /**
192
269
  * The container's sign-in door, rendered beside the favourite's refusal —
193
270
  * the same `SignInCta` seam the three card skins already take. The pane was
@@ -221,8 +298,28 @@ export interface ListingDetailPaneProps
221
298
  readonly footer?: ReactNode;
222
299
  }
223
300
 
301
+ /**
302
+ * Which arm of `actions` this is.
303
+ *
304
+ * A plain object that is not a React element and not an array was never a
305
+ * legal `ReactNode` — React throws on rendering one — so there is no value a
306
+ * caller could have meant as chrome that lands here. `null` and `undefined`
307
+ * are nodes (the empty ones) and stay on the node side.
308
+ */
309
+ function isActionsConfig(
310
+ value: ReactNode | ListingActionsConfig
311
+ ): value is ListingActionsConfig {
312
+ return (
313
+ typeof value === "object" &&
314
+ value !== null &&
315
+ !Array.isArray(value) &&
316
+ !isValidElement(value)
317
+ );
318
+ }
319
+
224
320
  export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
225
321
  const t = useT();
322
+ const notice = useNotice();
226
323
  const { locale } = useI18n();
227
324
  const { token } = antdTheme.useToken();
228
325
  const bag = useListingDetail(props.id, {
@@ -238,6 +335,16 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
238
335
  // leaves rendering the reason to `<GatedControl>`, which computes its own.
239
336
  const favoriteView = useActionGate(bag.favoriteGate);
240
337
  const split = props.layout === "split";
338
+ const placement = props.actionsPlacement ?? "header";
339
+ // The two arms of `actions` — see `isActionsConfig`.
340
+ const actionsConfig: ListingActionsConfig | undefined = isActionsConfig(
341
+ props.actions
342
+ )
343
+ ? props.actions
344
+ : undefined;
345
+ const actionsNode: ReactNode = isActionsConfig(props.actions)
346
+ ? null
347
+ : props.actions;
241
348
 
242
349
  const favoriteLabel = t(
243
350
  bag.isFavorited
@@ -253,6 +360,29 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
253
360
  {...(bag.isFavorited ? { color: token.colorPrimary } : {})}
254
361
  />
255
362
  );
363
+ /**
364
+ * The press, and the sentence it earns.
365
+ *
366
+ * The heart on this page is icon-only (§23: the reference draws a 44×44
367
+ * glyph where this pane drew a 152px button with a word in it), so the
368
+ * only thing a person reads back off the gesture is a fill changing colour
369
+ * in the corner of a row. That is enough to SEE and not enough to be sure
370
+ * of, which is what the toast is for — raised from the state the icon is
371
+ * about to draw, so both arrive together. A write that then fails rolls the
372
+ * icon back and says so through `listings-detail-favorite-error`.
373
+ */
374
+ const pressFavorite = (): void => {
375
+ const next = !bag.isFavorited;
376
+ bag.toggleFavorite();
377
+ if (!bag.favoriteGate.available) return;
378
+ notice(
379
+ t(
380
+ next
381
+ ? LISTINGS_I18N_KEYS.favoriteAdded
382
+ : LISTINGS_I18N_KEYS.favoriteRemoved
383
+ )
384
+ );
385
+ };
256
386
 
257
387
  return (
258
388
  <SkinTheme
@@ -338,6 +468,108 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
338
468
  </>
339
469
  );
340
470
 
471
+ /* THE READER'S TWO ACTIONS, as one cluster — see
472
+ `<ListingActions>` and `actionsPlacement`.
473
+
474
+ The heart is handed IN rather than mounted by the cluster:
475
+ this page's favourite is driven by `useListingDetail`'s own
476
+ optimistic bag (it holds the whole listing and flips the row it
477
+ already has), not by `useFavoriteToggle` against a card row.
478
+ One control, two hooks, one geometry.
479
+
480
+ The OWNER gets the share button and no heart: favouriting your
481
+ own listing is not a thing anyone does, and sending somebody
482
+ your own listing is the first thing a seller does. */
483
+ const favoriteControl =
484
+ owner ? null : props.blockedReason === "popover" &&
485
+ favoriteView.reason !== undefined ? (
486
+ /* The cards' third volume, verbatim: nothing standing, the
487
+ reason and the door disclosed on the heart. `aria-disabled`
488
+ rather than `disabled`, so the disclosure's hover, focus
489
+ and tap all arrive — and the click is a safe no-op, because
490
+ `toggleFavorite` refuses while the gate is blocked. */
491
+ <GateReasonPopover
492
+ reason={favoriteView.reason}
493
+ cta={props.signIn}
494
+ testId="listings-detail-favorite-reason"
495
+ signInTestId="listings-detail-sign-in"
496
+ >
497
+ {(bind) => (
498
+ <Button
499
+ shape="circle"
500
+ aria-disabled
501
+ {...bind}
502
+ className={LISTING_ACTION_CLASS}
503
+ aria-label={favoriteLabel}
504
+ aria-pressed={bag.isFavorited}
505
+ icon={heartIcon}
506
+ data-testid="listings-detail-favorite"
507
+ data-favorited={String(bag.isFavorited)}
508
+ data-analytics="none"
509
+ data-analytics-reason="business action — host app wraps with its own tracked()"
510
+ onClick={pressFavorite}
511
+ />
512
+ )}
513
+ </GateReasonPopover>
514
+ ) : (
515
+ <Flex vertical gap={spacing[1]}>
516
+ <GatedControl
517
+ gate={bag.favoriteGate}
518
+ testId="listings-detail-favorite-gate"
519
+ >
520
+ {(bind) => (
521
+ <Button
522
+ shape="circle"
523
+ // See `<ListingCard>`: the binding, spread whole.
524
+ {...bind}
525
+ className={LISTING_ACTION_CLASS}
526
+ aria-label={favoriteLabel}
527
+ aria-pressed={bag.isFavorited}
528
+ icon={heartIcon}
529
+ data-testid="listings-detail-favorite"
530
+ data-favorited={String(bag.isFavorited)}
531
+ data-analytics="none"
532
+ data-analytics-reason="business action — host app wraps with its own tracked()"
533
+ onClick={pressFavorite}
534
+ />
535
+ )}
536
+ </GatedControl>
537
+ {/* The door. `GatedControl` prints the reason; where a
538
+ visitor signs in is the container's, and arrives as
539
+ `signIn` — the cards' own pattern, verbatim. */}
540
+ {bag.favoriteGate.available ? null : (
541
+ <Typography.Text
542
+ type="secondary"
543
+ data-testid="listings-detail-favorite-blocked"
544
+ >
545
+ <SignInLink cta={props.signIn} testId="listings-detail-sign-in" />
546
+ </Typography.Text>
547
+ )}
548
+ </Flex>
549
+ );
550
+
551
+ const readerActions = (
552
+ <ListingActions
553
+ listingId={props.id}
554
+ favorite={favoriteControl}
555
+ testId="listings-detail-reader-actions"
556
+ placement={placement === "gallery" ? "overlay" : "inline"}
557
+ actions={{
558
+ ...actionsConfig,
559
+ // The owner keeps the share button and loses the heart.
560
+ ...(owner ? { favorite: false } : {}),
561
+ }}
562
+ {...(props.favoriteCount !== undefined && !owner
563
+ ? { favoriteCount: props.favoriteCount }
564
+ : {})}
565
+ {...(props.shareUrl !== undefined ? { shareUrl: props.shareUrl } : {})}
566
+ {...(listing.title !== undefined && listing.title !== null
567
+ ? { shareTitle: listing.title }
568
+ : {})}
569
+ {...(props.onShared !== undefined ? { onShared: props.onShared } : {})}
570
+ />
571
+ );
572
+
341
573
  /* Element-width tiles: the grid decides how many fit, the
342
574
  photos fill them. */
343
575
  const gallery = (
@@ -346,7 +578,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
346
578
  style={{
347
579
  display: "grid",
348
580
  gridTemplateColumns: `repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr))`,
349
- gap: spacing[3],
581
+ // The page's own edge, per breakpoint — see
582
+ // `DETAIL_GALLERY_GUTTER` (D418).
583
+ gap: DETAIL_GALLERY_GUTTER,
584
+ // The containing block the overlay arm is pinned to. A
585
+ // `relative` with no offsets moves no pixel of what is
586
+ // already in it — the same trick `cardGalleryCss` uses.
587
+ position: "relative",
350
588
  }}
351
589
  >
352
590
  {bag.images.length === 0 ? (
@@ -366,17 +604,30 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
366
604
  />
367
605
  ))
368
606
  )}
607
+ {placement === "gallery" ? readerActions : null}
369
608
  </div>
370
609
  );
371
610
 
372
611
  const heading = (
373
612
  <>
374
- <Typography.Title
375
- level={props.headingLevel ?? 3}
376
- data-testid="listings-detail-title"
377
- >
378
- {listing.title ?? ""}
379
- </Typography.Title>
613
+ {/* THE TITLE AND THE TWO ACTIONS SHARE A LINE (§23).
614
+ The reference classified puts save-and-share at the
615
+ trailing edge of the heading, on a phone and on a desktop
616
+ alike, and that is the only place on this page where a
617
+ person looks for them. `align-items:flex-start` so a
618
+ two-line title does not drag the glyphs down its second
619
+ line; `minWidth:0` so a long unbroken word wraps instead
620
+ of pushing them off the pane. */}
621
+ <Flex align="flex-start" justify="space-between" gap={spacing[3]}>
622
+ <Typography.Title
623
+ level={props.headingLevel ?? 3}
624
+ data-testid="listings-detail-title"
625
+ style={{ minWidth: 0, flex: "1 1 auto" }}
626
+ >
627
+ {listing.title ?? ""}
628
+ </Typography.Title>
629
+ {placement === "header" ? readerActions : null}
630
+ </Flex>
380
631
 
381
632
  {/* The `show_at_title` projection, formatted from the stored
382
633
  DAOs — no category read needed (see model/features.ts). */}
@@ -487,76 +738,12 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
487
738
  </div>
488
739
  )}
489
740
 
490
- {/* Favouriting your own listing is not a thing anyone does;
491
- for everyone else it is the secondary it always was. */}
492
- {owner ? null : props.blockedReason === "popover" &&
493
- favoriteView.reason !== undefined ? (
494
- /* The cards' third volume, verbatim: nothing standing, the
495
- reason and the door disclosed on the heart. `aria-disabled`
496
- rather than `disabled`, so the disclosure's hover, focus
497
- and tap all arrive — and the click is a safe no-op, because
498
- `toggleFavorite` refuses while the gate is blocked. */
499
- <GateReasonPopover
500
- reason={favoriteView.reason}
501
- cta={props.signIn}
502
- testId="listings-detail-favorite-reason"
503
- signInTestId="listings-detail-sign-in"
504
- >
505
- {(bind) => (
506
- <Button
507
- aria-disabled
508
- {...bind}
509
- aria-label={favoriteLabel}
510
- aria-pressed={bag.isFavorited}
511
- icon={heartIcon}
512
- data-testid="listings-detail-favorite"
513
- data-favorited={String(bag.isFavorited)}
514
- data-analytics="none"
515
- data-analytics-reason="business action — host app wraps with its own tracked()"
516
- onClick={bag.toggleFavorite}
517
- >
518
- {favoriteLabel}
519
- </Button>
520
- )}
521
- </GateReasonPopover>
522
- ) : (
523
- <Flex vertical gap={spacing[1]}>
524
- <GatedControl
525
- gate={bag.favoriteGate}
526
- testId="listings-detail-favorite-gate"
527
- >
528
- {(bind) => (
529
- <Button
530
- // See `<ListingCard>`: the binding, spread whole.
531
- {...bind}
532
- aria-label={favoriteLabel}
533
- aria-pressed={bag.isFavorited}
534
- icon={heartIcon}
535
- data-testid="listings-detail-favorite"
536
- data-favorited={String(bag.isFavorited)}
537
- data-analytics="none"
538
- data-analytics-reason="business action — host app wraps with its own tracked()"
539
- onClick={bag.toggleFavorite}
540
- >
541
- {favoriteLabel}
542
- </Button>
543
- )}
544
- </GatedControl>
545
- {/* The door. `GatedControl` prints the reason; where a
546
- visitor signs in is the container's, and arrives as
547
- `signIn` — the cards' own pattern, verbatim. */}
548
- {bag.favoriteGate.available ? null : (
549
- <Typography.Text
550
- type="secondary"
551
- data-testid="listings-detail-favorite-blocked"
552
- >
553
- <SignInLink cta={props.signIn} testId="listings-detail-sign-in" />
554
- </Typography.Text>
555
- )}
556
- </Flex>
557
- )}
741
+ {/* The reader's two actions live in the cluster now (see
742
+ `actionsPlacement`); the buy box keeps them only when a
743
+ host asks for the layout this page used to have. */}
744
+ {placement === "buy-box" ? readerActions : null}
558
745
 
559
- {props.actions}
746
+ {actionsNode}
560
747
  </Flex>
561
748
  );
562
749
 
@@ -66,6 +66,7 @@ import { isListingViewed } from "../model/engagement.js";
66
66
  import { useEngagedListing } from "../headless/Engagement.js";
67
67
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
68
68
  import { FavoriteHeart } from "./favorite.js";
69
+ import { LISTING_CARD_ACTION_CLASS } from "./actionRow.js";
69
70
  import {
70
71
  CARD_TARGET_STYLE_HREF,
71
72
  CARD_VIEWED_CLASS,
@@ -246,6 +247,7 @@ export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
246
247
  listingId={listing.id}
247
248
  favorited={listing.is_favorited}
248
249
  testId="listings-feed-favorite"
250
+ className={LISTING_CARD_ACTION_CLASS}
249
251
  blockedReason={props.blockedReason ?? "popover"}
250
252
  {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
251
253
  style={HEART}
@@ -32,6 +32,7 @@ import { radii, spacing } from "@stapel/tokens";
32
32
  import { useListingsRuntime } from "../model/context.js";
33
33
  import {
34
34
  CARD_GALLERY_CLASS,
35
+ CARD_GALLERY_COUNTER_CLASS,
35
36
  CARD_GALLERY_STYLE_HREF,
36
37
  cardGalleryCss,
37
38
  useCardGallery,
@@ -312,6 +313,33 @@ export function ListingPhotoStrip(props: {
312
313
  )
313
314
  )}
314
315
  </SkinCarousel>
316
+
317
+ {/* HOW MANY PHOTOGRAPHS THERE ARE, and which one this is.
318
+
319
+ The dots say WHERE in the strip a reader is and stop being countable
320
+ at about five; only a number says there are sixteen. The mobile walk
321
+ measured the card with dots and no counter beside a reference that
322
+ leads with "1 of 16", and on a phone — where the whole strip is one
323
+ photograph wide — the count is the only thing that says a swipe is
324
+ worth making.
325
+
326
+ `aria-live="polite"` because it changes without the reader doing
327
+ anything a screen reader would otherwise report: a swipe scrolls the
328
+ strip natively and this text is the only announcement of the move.
329
+ One photograph gets none of it, exactly as it gets no dots and no
330
+ peek — there is nothing to count. */}
331
+ {many ? (
332
+ <span
333
+ className={CARD_GALLERY_COUNTER_CLASS}
334
+ data-testid={`${props.testId}-counter`}
335
+ aria-live="polite"
336
+ >
337
+ {t(LISTINGS_I18N_KEYS.cardPhotoCounter, {
338
+ index: gallery.active + 1,
339
+ total: images.length,
340
+ })}
341
+ </span>
342
+ ) : null}
315
343
  </div>
316
344
  );
317
345
  }