@stapel/listings-react 0.25.10 → 0.27.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 (144) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/MODULE.md +3 -1
  3. package/README.md +122 -9
  4. package/dist/api/generated/schema.d.ts +22 -12
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/CardBadges.d.ts.map +1 -1
  7. package/dist/default/CardBadges.js +3 -1
  8. package/dist/default/CardBadges.js.map +1 -1
  9. package/dist/default/ListingActions.d.ts +108 -0
  10. package/dist/default/ListingActions.d.ts.map +1 -0
  11. package/dist/default/ListingActions.js +29 -0
  12. package/dist/default/ListingActions.js.map +1 -0
  13. package/dist/default/ListingCard.d.ts.map +1 -1
  14. package/dist/default/ListingCard.js +15 -8
  15. package/dist/default/ListingCard.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +170 -62
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +284 -28
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  21. package/dist/default/ListingFeedCard.js +2 -1
  22. package/dist/default/ListingFeedCard.js.map +1 -1
  23. package/dist/default/ListingPhoto.d.ts.map +1 -1
  24. package/dist/default/ListingPhoto.js +5 -2
  25. package/dist/default/ListingPhoto.js.map +1 -1
  26. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  27. package/dist/default/ListingSerpCard.js +18 -4
  28. package/dist/default/ListingSerpCard.js.map +1 -1
  29. package/dist/default/MyListingsPane.d.ts.map +1 -1
  30. package/dist/default/MyListingsPane.js +49 -21
  31. package/dist/default/MyListingsPane.js.map +1 -1
  32. package/dist/default/ShareAction.d.ts +32 -0
  33. package/dist/default/ShareAction.d.ts.map +1 -0
  34. package/dist/default/ShareAction.js +116 -0
  35. package/dist/default/ShareAction.js.map +1 -0
  36. package/dist/default/actionRow.d.ts +58 -0
  37. package/dist/default/actionRow.d.ts.map +1 -0
  38. package/dist/default/actionRow.js +128 -0
  39. package/dist/default/actionRow.js.map +1 -0
  40. package/dist/default/cardGallery.d.ts +2 -0
  41. package/dist/default/cardGallery.d.ts.map +1 -1
  42. package/dist/default/cardGallery.js +31 -0
  43. package/dist/default/cardGallery.js.map +1 -1
  44. package/dist/default/detailGallery.d.ts +77 -0
  45. package/dist/default/detailGallery.d.ts.map +1 -0
  46. package/dist/default/detailGallery.js +91 -0
  47. package/dist/default/detailGallery.js.map +1 -0
  48. package/dist/default/favorite.d.ts +25 -0
  49. package/dist/default/favorite.d.ts.map +1 -1
  50. package/dist/default/favorite.js +29 -3
  51. package/dist/default/favorite.js.map +1 -1
  52. package/dist/default/icons.d.ts +19 -0
  53. package/dist/default/icons.d.ts.map +1 -1
  54. package/dist/default/icons.js +23 -0
  55. package/dist/default/icons.js.map +1 -1
  56. package/dist/default/index.d.ts +11 -2
  57. package/dist/default/index.d.ts.map +1 -1
  58. package/dist/default/index.js +10 -1
  59. package/dist/default/index.js.map +1 -1
  60. package/dist/default/movableCluster.d.ts +18 -0
  61. package/dist/default/movableCluster.d.ts.map +1 -0
  62. package/dist/default/movableCluster.js +110 -0
  63. package/dist/default/movableCluster.js.map +1 -0
  64. package/dist/default/notice.d.ts +14 -0
  65. package/dist/default/notice.d.ts.map +1 -0
  66. package/dist/default/notice.js +62 -0
  67. package/dist/default/notice.js.map +1 -0
  68. package/dist/headless/MyListings.d.ts +61 -19
  69. package/dist/headless/MyListings.d.ts.map +1 -1
  70. package/dist/headless/MyListings.js +58 -19
  71. package/dist/headless/MyListings.js.map +1 -1
  72. package/dist/headless/Share.d.ts +103 -0
  73. package/dist/headless/Share.d.ts.map +1 -0
  74. package/dist/headless/Share.js +190 -0
  75. package/dist/headless/Share.js.map +1 -0
  76. package/dist/i18n/es.d.ts.map +1 -1
  77. package/dist/i18n/es.js +21 -0
  78. package/dist/i18n/es.js.map +1 -1
  79. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  80. package/dist/i18n/generated/errors.es.gen.js +1 -1
  81. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  82. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  83. package/dist/i18n/generated/errors.gen.js +9 -0
  84. package/dist/i18n/generated/errors.gen.js.map +1 -1
  85. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  86. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  87. package/dist/i18n/keys.d.ts +45 -0
  88. package/dist/i18n/keys.d.ts.map +1 -1
  89. package/dist/i18n/keys.js +60 -0
  90. package/dist/i18n/keys.js.map +1 -1
  91. package/dist/i18n/ru.d.ts.map +1 -1
  92. package/dist/i18n/ru.js +21 -0
  93. package/dist/i18n/ru.js.map +1 -1
  94. package/dist/index.d.ts +5 -3
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +2 -1
  97. package/dist/index.js.map +1 -1
  98. package/dist/model/cardBadges.d.ts +52 -3
  99. package/dist/model/cardBadges.d.ts.map +1 -1
  100. package/dist/model/cardBadges.js +74 -12
  101. package/dist/model/cardBadges.js.map +1 -1
  102. package/dist/model/mineSource.d.ts +9 -3
  103. package/dist/model/mineSource.d.ts.map +1 -1
  104. package/dist/model/mineSource.js.map +1 -1
  105. package/dist/model/status.d.ts +79 -15
  106. package/dist/model/status.d.ts.map +1 -1
  107. package/dist/model/status.js +86 -17
  108. package/dist/model/status.js.map +1 -1
  109. package/llms.txt +8 -3
  110. package/manifest.json +96 -4
  111. package/nav-manifest.json +1 -1
  112. package/package.json +10 -10
  113. package/src/analytics/generated/events.json +1 -1
  114. package/src/api/generated/schema.ts +22 -12
  115. package/src/default/CardBadges.tsx +3 -1
  116. package/src/default/ListingActions.tsx +196 -0
  117. package/src/default/ListingCard.tsx +79 -43
  118. package/src/default/ListingDetailPane.tsx +520 -89
  119. package/src/default/ListingFeedCard.tsx +2 -0
  120. package/src/default/ListingPhoto.tsx +28 -0
  121. package/src/default/ListingSerpCard.tsx +39 -12
  122. package/src/default/MyListingsPane.tsx +81 -44
  123. package/src/default/ShareAction.tsx +260 -0
  124. package/src/default/actionRow.ts +131 -0
  125. package/src/default/cardGallery.ts +31 -0
  126. package/src/default/detailGallery.ts +97 -0
  127. package/src/default/favorite.tsx +68 -1
  128. package/src/default/icons.tsx +60 -0
  129. package/src/default/index.ts +37 -1
  130. package/src/default/movableCluster.tsx +137 -0
  131. package/src/default/notice.ts +68 -0
  132. package/src/headless/MyListings.tsx +130 -39
  133. package/src/headless/Share.tsx +278 -0
  134. package/src/i18n/es.ts +28 -0
  135. package/src/i18n/generated/errors.es.gen.ts +1 -1
  136. package/src/i18n/generated/errors.gen.ts +9 -0
  137. package/src/i18n/generated/errors.json +31 -0
  138. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  139. package/src/i18n/keys.ts +63 -0
  140. package/src/i18n/ru.ts +28 -0
  141. package/src/index.ts +22 -0
  142. package/src/model/cardBadges.ts +124 -12
  143. package/src/model/mineSource.ts +9 -3
  144. package/src/model/status.ts +102 -20
@@ -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
  }
@@ -89,6 +89,11 @@ import { useEngagedListing } from "../headless/Engagement.js";
89
89
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
90
90
  import { PriceTrendIcon } from "./icons.js";
91
91
  import { FavoriteHeart } from "./favorite.js";
92
+ import {
93
+ LISTING_ACTIONS_CLASS,
94
+ LISTING_ACTIONS_OVERLAY_CLASS,
95
+ LISTING_CARD_ACTION_CLASS,
96
+ } from "./actionRow.js";
92
97
  import { CardBadges, CardSpecLine } from "./CardBadges.js";
93
98
  import {
94
99
  CARD_FRAME_CLASS,
@@ -220,8 +225,13 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
220
225
  // Already seen — `false` for every response that carries no such field.
221
226
  const viewed = isListingViewed(listing);
222
227
 
228
+ /* The rail is what the CONTAINER put there — "call", "write". The heart
229
+ left it in this wave and now sits on the photograph (see `heartOverlay`),
230
+ which is where every reference classified's phone SERP has it and where a
231
+ thumb already is; the rail is at the far end of a line of text, three
232
+ glances away from the picture a person is actually looking at. */
223
233
  const rail =
224
- props.actionsRail !== undefined || props.showFavorite !== false ? (
234
+ props.actionsRail !== undefined ? (
225
235
  <Flex
226
236
  vertical
227
237
  align="flex-end"
@@ -230,20 +240,36 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
230
240
  data-testid="listings-serp-actions"
231
241
  >
232
242
  {props.actionsRail}
233
- {props.showFavorite === false ? null : (
234
- <FavoriteHeart
235
- listingId={listing.id}
236
- favorited={listing.is_favorited}
237
- testId="listings-serp-favorite"
238
- {...(props.blockedReason !== undefined
239
- ? { blockedReason: props.blockedReason }
240
- : {})}
241
- {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
242
- />
243
- )}
244
243
  </Flex>
245
244
  ) : null;
246
245
 
246
+ /* THE HEART, ON THE PHOTOGRAPH. Top-trailing: the strip's dots own the
247
+ bottom centre and its "3 of 16" counter owns the bottom trailing corner.
248
+ Outside every anchor, as it has always been — a link may not contain a
249
+ control — and `stopPropagation` on the bubble phase so a press that
250
+ somehow reached the card would still save rather than navigate. */
251
+ const heartOverlay =
252
+ props.showFavorite === false ? null : (
253
+ <div
254
+ className={`${LISTING_ACTIONS_CLASS} ${LISTING_ACTIONS_OVERLAY_CLASS}`}
255
+ data-testid="listings-serp-favorite-overlay"
256
+ onClick={(event) => {
257
+ event.stopPropagation();
258
+ }}
259
+ >
260
+ <FavoriteHeart
261
+ listingId={listing.id}
262
+ favorited={listing.is_favorited}
263
+ testId="listings-serp-favorite"
264
+ className={LISTING_CARD_ACTION_CLASS}
265
+ {...(props.blockedReason !== undefined
266
+ ? { blockedReason: props.blockedReason }
267
+ : {})}
268
+ {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
269
+ />
270
+ </div>
271
+ );
272
+
247
273
  return (
248
274
  <SkinTheme
249
275
  surface="bare"
@@ -289,6 +315,7 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
289
315
  title={title.length > 0 ? title : String(listing.id)}
290
316
  testId="listings-serp-photos"
291
317
  />
318
+ {heartOverlay}
292
319
  </div>
293
320
 
294
321
  <div className={CARD_MAIN_CLASS}>
@@ -15,12 +15,22 @@
15
15
  * listing is offline, or never tell them their edit is being screened.
16
16
  * `moderation_status` is on the owner card, so the row reads the real
17
17
  * value rather than the `"approved"` stand-in it used before 0.7.0.
18
- * 2. **Takedowns are not in a tab.** The three tabs are the SERVER's status
19
- * groupings and `blocked` is in none of them, because `my/counters` counts
20
- * it in none of them. Folding it into one would make a tab's rows and its
21
- * badge describe different sets; leaving it out entirely would hide the
22
- * one listing whose owner most needs to know. So it sits above the tabs,
23
- * where it cannot be missed.
18
+ * 2. **A takedown gets a tab of its own, and a count (D407).** The three
19
+ * tabs are the SERVER's status groupings and `blocked` is in none of them,
20
+ * because `my/counters` counts it in none of them so a cabinet holding a
21
+ * listing a moderator had pulled read "Active 0 · Drafts 0 · Archived 0"
22
+ * over it, and said beside that that nothing of the seller's was live. The
23
+ * row was on the page (in a block above the tabs) and in no tab and in no
24
+ * number, and a person reads the numbers.
25
+ *
26
+ * So the takedowns are the fourth tab. It was counted from its own read
27
+ * while the counter had three integers; since stapel-listings 0.22.4 the
28
+ * counter has four and the badge is the SERVER's, so the tab appears and
29
+ * is right without the takedown page having landed — see `model/status.ts`
30
+ * for why a fourth tab and not the archive. The block
31
+ * above the tabs stays as one LINE, without the rows: a takedown must not
32
+ * need a click to be discovered, and printing the same row twice on one
33
+ * screen is not the way to say so.
24
34
  * 3. **An empty tab says which emptiness it is.** "No drafts" and "nothing
25
35
  * sold yet" are different sentences and one generic "nothing here" is
26
36
  * neither.
@@ -85,6 +95,7 @@ const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
85
95
  active: LISTINGS_I18N_KEYS.mineTabActive,
86
96
  drafts: LISTINGS_I18N_KEYS.mineTabDrafts,
87
97
  archived: LISTINGS_I18N_KEYS.mineTabArchived,
98
+ removed: LISTINGS_I18N_KEYS.mineTabRemoved,
88
99
  };
89
100
 
90
101
  /** One empty sentence per tab — see the header, point 3. */
@@ -92,6 +103,7 @@ const TAB_EMPTY: Readonly<Record<MyListingsTab, string>> = {
92
103
  active: LISTINGS_I18N_KEYS.mineEmptyActive,
93
104
  drafts: LISTINGS_I18N_KEYS.mineEmptyDrafts,
94
105
  archived: LISTINGS_I18N_KEYS.mineEmptyArchived,
106
+ removed: LISTINGS_I18N_KEYS.mineEmptyRemoved,
95
107
  };
96
108
 
97
109
  /** The thumbnail column. A photo marketplace whose seller dashboard is a
@@ -452,12 +464,19 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
452
464
  // ONE confirmation for the whole list, keyed by the row that asked — not one
453
465
  // mounted dialog per row.
454
466
  const [removingId, setRemovingId] = useState<number | null>(null);
455
- const removal = useListingActions(
456
- removingId ?? 0,
467
+ const removingRow =
457
468
  bag.rows.status === "ready"
458
- ? bag.rows.data.find((row) => row.id === removingId)?.status
459
- : undefined
460
- );
469
+ ? bag.rows.data.find((row) => row.id === removingId)
470
+ : undefined;
471
+ const removal = useListingActions(removingId ?? 0, removingRow?.status, {
472
+ // The SERVER's answer for THIS row, exactly as the row's own controls
473
+ // take it. Without it this hook fell back to the mirror, and the mirror
474
+ // is a table about a status rather than about a listing — which is the
475
+ // difference between "a sold listing may be archived" and "this one may".
476
+ ...(removingRow?.available_transitions !== undefined
477
+ ? { available: removingRow.available_transitions }
478
+ : {}),
479
+ });
461
480
 
462
481
  const paged = bag.prevPage.available || bag.nextPage.available;
463
482
 
@@ -501,35 +520,37 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
501
520
  />
502
521
  ) : (
503
522
  <>
504
- {/* The rows no tab folds in — see the header, point 2. Rendered
505
- only when there are some: an empty takedown section is a scare,
506
- and a failure to CHECK is not the same as "none", so it says so. */}
507
- {matchList(bag.blockedRows, {
508
- loading: () => null,
509
- failed: () => (
510
- <Typography.Text
511
- type="secondary"
512
- data-testid="listings-mine-takedowns-failed"
513
- >
514
- {t(LISTINGS_I18N_KEYS.mineBlockedLoadFailed)}
515
- </Typography.Text>
516
- ),
517
- empty: () => null,
518
- ready: (rows) => (
519
- <Flex vertical gap={spacing[2]} data-testid="listings-mine-takedowns">
520
- <Typography.Text type="warning" strong>
521
- {tPlural(LISTINGS_I18N_KEYS.mineBlockedTitle, {
522
- count: rows.length,
523
- })}
524
- </Typography.Text>
525
- <List
526
- dataSource={[...rows]}
527
- rowKey={(row) => row.id}
528
- renderItem={renderRow}
529
- />
530
- </Flex>
531
- ),
532
- })}
523
+ {/* The takedowns, announced above the tabs — see the header, point 2.
524
+ The LINE only: the rows themselves are the fourth tab, and this
525
+ says how many there are without waiting for a click. Rendered only
526
+ when there are some (an empty takedown section is a scare) and not
527
+ while that tab is open (nobody needs telling twice); a failure to
528
+ CHECK is not the same as "none", so that says so. */}
529
+ {bag.tab === "removed"
530
+ ? null
531
+ : matchList(bag.blockedRows, {
532
+ loading: () => null,
533
+ failed: () => (
534
+ <Typography.Text
535
+ type="secondary"
536
+ data-testid="listings-mine-takedowns-failed"
537
+ >
538
+ {t(LISTINGS_I18N_KEYS.mineBlockedLoadFailed)}
539
+ </Typography.Text>
540
+ ),
541
+ empty: () => null,
542
+ ready: (rows) => (
543
+ <Typography.Text
544
+ type="warning"
545
+ strong
546
+ data-testid="listings-mine-takedowns"
547
+ >
548
+ {tPlural(LISTINGS_I18N_KEYS.mineBlockedTitle, {
549
+ count: rows.length,
550
+ })}
551
+ </Typography.Text>
552
+ ),
553
+ })}
533
554
 
534
555
  <Tabs
535
556
  activeKey={bag.tab}
@@ -553,11 +574,14 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
553
574
  // phone instead of collapsing into an overflow menu.
554
575
  //
555
576
  // `tabCounts`, not `counters`: the badge is never allowed to
556
- // read lower than the rows underneath it (D407 a
557
- // moderator-rejected listing sat in Drafts under a `0`).
577
+ // read lower than the rows underneath it, and the fourth
578
+ // tab's number degrades to its own rows on a server older
579
+ // than 0.22.4 (D407 — a moderator-rejected listing sat in
580
+ // Drafts under a `0`, and a taken-down one under no number
581
+ // whatever).
558
582
  ready: (counts) => (
559
583
  <Typography.Text
560
- type="secondary"
584
+ type={tab === "removed" ? "warning" : "secondary"}
561
585
  data-testid={`listings-mine-count-${tab}`}
562
586
  >
563
587
  {` ${String(counts[tab])}`}
@@ -680,11 +704,24 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
680
704
  </>
681
705
  )}
682
706
 
707
+ {/* THE PROMISE IS ONLY MADE WHERE IT CAN BE KEPT.
708
+ "Archiving keeps it" is a real alternative to offer somebody about
709
+ to delete a live listing, and an insult to somebody deleting from
710
+ the archive — measured on the phone walk: the archive tab's own
711
+ delete dialog promised the archive to a person already standing in
712
+ it. So the sentence follows the ROW's state rather than the
713
+ wording, and the state that decides it is the one the seller would
714
+ have to act on: whether `archived` is a move this listing still
715
+ has. Archived, sold and taken-down rows have spent it. */}
683
716
  <SkinConfirm
684
717
  open={removingId !== null}
685
718
  danger
686
719
  title={t(LISTINGS_I18N_KEYS.mineDeleteConfirmTitle)}
687
- body={t(LISTINGS_I18N_KEYS.mineDeleteConfirmBody)}
720
+ body={t(
721
+ removal.moves.some((move) => move.to === "archived")
722
+ ? LISTINGS_I18N_KEYS.mineDeleteConfirmBody
723
+ : LISTINGS_I18N_KEYS.mineDeleteConfirmBodyFinal
724
+ )}
688
725
  confirmLabel={t(LISTINGS_I18N_KEYS.mineDelete)}
689
726
  confirming={removal.inFlight}
690
727
  data-testid="listings-mine-delete-confirm"
@@ -0,0 +1,260 @@
1
+ /**
2
+ * `<ShareAction>` — "share", the verb the storefront did not have.
3
+ *
4
+ * Measured by the owner on the live deployment (2026-09-06): **no share
5
+ * control anywhere in the product.** Not a badly placed one, not one behind a
6
+ * menu — none. The only way to send somebody a listing was the address bar,
7
+ * which on a phone is the hardest thing on the screen to reach and which
8
+ * carries the SERP's query string, the page anchor and whatever tracking
9
+ * parameters the visitor arrived with.
10
+ *
11
+ * ── One control, two renderings, and the DEVICE picks ─────────────────────
12
+ *
13
+ * Where `navigator.share` exists — which is every phone and almost no desktop
14
+ * — the press opens the PLATFORM's own sheet: the person's own apps, in their
15
+ * own order, including the ones we have never heard of. A library that drew
16
+ * its own list of four networks on a phone would be offering a worse version
17
+ * of something the operating system already does better.
18
+ *
19
+ * Where it does not, the press opens a small menu: copy the link, and the
20
+ * three networks a Russian-speaking marketplace actually receives traffic
21
+ * from. This is the DESKTOP rendering, not a fallback for old browsers, which
22
+ * is why it is built rather than apologised for.
23
+ *
24
+ * ── The menu is a Popover, and that is an exception with an argument ──────
25
+ *
26
+ * `stapel/no-tooltip-in-skin` bans `Popover` because of what a hover-triggered
27
+ * one does: it hides text a touch device can never reveal, and it anchors that
28
+ * text to a disabled control that swallows the events it needs. Neither
29
+ * applies here and both are structurally impossible:
30
+ *
31
+ * - the trigger is `"click"` ONLY — no hover arm at all, so a thumb and a
32
+ * cursor reach it by exactly the same gesture;
33
+ * - the anchor is a live, enabled, focusable button;
34
+ * - nothing is EXPLAINED in the overlay. It holds four controls. A menu is
35
+ * the shape this content has had since menus existed, and the alternative
36
+ * — four buttons standing permanently beside the title — is the "24 copies
37
+ * of one sentence" defect that `GateReasonPopover` was written to end,
38
+ * wearing a different hat.
39
+ *
40
+ * ── The word disappears on a phone; the NAME never does ───────────────────
41
+ *
42
+ * `aria-label` carries the verb in every arm and at every width. What the
43
+ * media query drops is the painted word beside the glyph, because at 390px
44
+ * the action row shares a line with a heart and a price. A screen reader
45
+ * announces the same verb on both.
46
+ *
47
+ * ── Every outbound link is `noopener noreferrer` ──────────────────────────
48
+ *
49
+ * `target="_blank"` without `rel="noopener"` hands the opened page a live
50
+ * `window.opener` handle to the storefront's tab, which is a one-line
51
+ * navigation hijack; `noreferrer` keeps the visitor's exact listing URL out of
52
+ * the network's referer log. Both, on all three, without exception.
53
+ */
54
+ import { useCallback, useEffect, useState } from "react";
55
+ import type { CSSProperties, ReactElement } from "react";
56
+ import { Flex, Typography } from "antd";
57
+ // eslint-disable-next-line stapel/no-tooltip-in-skin -- a MENU, not a hover explanation: trigger is click-only (a thumb and a cursor use one gesture), the anchor is a live enabled button, and the overlay holds four controls rather than a sentence. See this file's header.
58
+ import { Popover } from "antd";
59
+ import { SkinButton as Button, ErrorAlert } from "@stapel/tokens-antd/skin";
60
+ import { useT } from "@stapel/core";
61
+ import { spacing } from "@stapel/tokens";
62
+ import { useShare } from "../headless/Share.js";
63
+ import type { ShareChannel, ShareNetwork } from "../headless/Share.js";
64
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
65
+ import {
66
+ LISTING_ACTIONS_STYLE_HREF,
67
+ LISTING_ACTION_CLASS,
68
+ LISTING_ACTION_LABEL_CLASS,
69
+ actionRowCss,
70
+ } from "./actionRow.js";
71
+ import { LinkIcon, ShareIcon } from "./icons.js";
72
+ import { useNotice } from "./notice.js";
73
+
74
+ /** Which sentence names each network row. */
75
+ const NETWORK_LABEL: Readonly<Record<ShareNetwork, string>> = {
76
+ telegram: LISTINGS_I18N_KEYS.shareTelegram,
77
+ whatsapp: LISTINGS_I18N_KEYS.shareWhatsapp,
78
+ vk: LISTINGS_I18N_KEYS.shareVk,
79
+ };
80
+
81
+ export interface ShareActionProps {
82
+ /**
83
+ * The listing's CANONICAL address — the route the container built, absolute
84
+ * or a path. Given, it is what every arm shares and `window.location` is
85
+ * never consulted; see `useShare` for why that distinction is the whole
86
+ * point of the prop.
87
+ */
88
+ readonly url?: string;
89
+ /** The listing's title: the platform sheet's heading and the networks'
90
+ * text. */
91
+ readonly title?: string;
92
+ /** A line under the title in the platform sheet — the price, typically. */
93
+ readonly text?: string;
94
+ /** Analytics, once per completed share. `"native"` never names the app the
95
+ * person chose, because the platform sheet does not tell the page. */
96
+ readonly onShared?: (channel: ShareChannel) => void;
97
+ /** This surface's own test id, so a screen holding a card and a listing
98
+ * page hands a test one element per name. Default `listings-share`. */
99
+ readonly testId?: string;
100
+ /**
101
+ * `"circle"` for the glyph pinned to the corner of a photograph (no room
102
+ * for a word, and the heart beside it is a circle); `"default"` (the
103
+ * default) draws the word beside the glyph wherever the viewport has room
104
+ * for it.
105
+ */
106
+ readonly shape?: "default" | "circle";
107
+ readonly style?: CSSProperties;
108
+ }
109
+
110
+ export function ShareAction(props: ShareActionProps): ReactElement {
111
+ const t = useT();
112
+ const testId = props.testId ?? "listings-share";
113
+ const [open, setOpen] = useState(false);
114
+ const share = useShare({
115
+ url: props.url,
116
+ title: props.title,
117
+ text: props.text,
118
+ ...(props.onShared !== undefined ? { onShared: props.onShared } : {}),
119
+ });
120
+ const label = t(LISTINGS_I18N_KEYS.shareAction);
121
+ const circle = props.shape === "circle";
122
+ const notice = useNotice();
123
+ const copied = share.copied;
124
+
125
+ // The toast, and it is the AMPLIFIER of the sentence already standing in
126
+ // the menu — never the only copy of it. See `notice.ts`.
127
+ useEffect(() => {
128
+ if (copied) notice(t(LISTINGS_I18N_KEYS.shareCopied));
129
+ }, [copied, notice, t]);
130
+
131
+ const onNetwork = useCallback(
132
+ (channel: ShareNetwork): void => {
133
+ share.report(channel);
134
+ setOpen(false);
135
+ },
136
+ [share]
137
+ );
138
+
139
+ /**
140
+ * The button itself.
141
+ *
142
+ * In the MENU arm it carries no `onClick` of its own: the `Popover` clones
143
+ * its own click handler onto this child, and a second handler toggling the
144
+ * same controlled state would open the menu and close it again inside one
145
+ * gesture — the defect `GateReasonPopover` documents at length, reached
146
+ * from the other direction.
147
+ */
148
+ const trigger = (
149
+ <Button
150
+ {...(circle ? { shape: "circle" as const } : {})}
151
+ className={LISTING_ACTION_CLASS}
152
+ aria-label={label}
153
+ data-testid={testId}
154
+ // WHICH ARM IS ON SCREEN, published rather than inferred. A walker
155
+ // reading a live phone has no other way to tell a native sheet (which
156
+ // opens outside the page and leaves no DOM behind) from a menu that
157
+ // failed to open.
158
+ data-share-mode={share.native ? "native" : "menu"}
159
+ data-analytics="none"
160
+ data-analytics-reason="business action — host app wraps with its own tracked()"
161
+ icon={<ShareIcon />}
162
+ {...(props.style !== undefined ? { style: props.style } : {})}
163
+ {...(share.native ? { onClick: share.shareNatively } : {})}
164
+ >
165
+ {circle ? null : (
166
+ <span className={LISTING_ACTION_LABEL_CLASS}>{label}</span>
167
+ )}
168
+ </Button>
169
+ );
170
+
171
+ const sheet = (
172
+ <>
173
+ <style href={LISTING_ACTIONS_STYLE_HREF} precedence="default">
174
+ {actionRowCss()}
175
+ </style>
176
+ {trigger}
177
+ </>
178
+ );
179
+
180
+ // The platform's own sheet: no overlay of ours, nothing to lay out.
181
+ if (share.native) return sheet;
182
+
183
+ const menu = (
184
+ <Flex vertical gap={spacing[1]} data-testid={`${testId}-menu`}>
185
+ <Button
186
+ type="text"
187
+ className={LISTING_ACTION_CLASS}
188
+ icon={<LinkIcon />}
189
+ data-testid={`${testId}-copy`}
190
+ data-analytics="none"
191
+ data-analytics-reason="business action — host app wraps with its own tracked()"
192
+ onClick={share.copy}
193
+ >
194
+ {t(LISTINGS_I18N_KEYS.shareCopy)}
195
+ </Button>
196
+
197
+ {/* THE CONFIRMATION LIVES HERE, and the toast is the amplifier.
198
+ A toast is transient and can land under a thumb; this sentence
199
+ stands in the menu the person is looking at. `aria-live` because it
200
+ appears without anything else changing that a screen reader would
201
+ otherwise report. */}
202
+ {share.copied ? (
203
+ <Typography.Text
204
+ type="success"
205
+ aria-live="polite"
206
+ data-testid={`${testId}-copied`}
207
+ >
208
+ {t(LISTINGS_I18N_KEYS.shareCopied)}
209
+ </Typography.Text>
210
+ ) : null}
211
+
212
+ {/* A clipboard that refused — no permission, an insecure origin, a
213
+ browser without the API. A person who pressed "copy" and pastes
214
+ nothing has to be told the press did not work. */}
215
+ {share.copyFailed ? (
216
+ <ErrorAlert
217
+ testId={`${testId}-copy-error`}
218
+ message={t(LISTINGS_I18N_KEYS.shareCopyFailed)}
219
+ variant="inline"
220
+ />
221
+ ) : null}
222
+
223
+ {share.links.map((link) => (
224
+ <a
225
+ key={link.channel}
226
+ href={link.href}
227
+ target="_blank"
228
+ rel="noopener noreferrer"
229
+ className={LISTING_ACTION_CLASS}
230
+ data-testid={`${testId}-${link.channel}`}
231
+ data-analytics="none"
232
+ data-analytics-reason="business action — host app wraps with its own tracked()"
233
+ onClick={() => {
234
+ onNetwork(link.channel);
235
+ }}
236
+ >
237
+ {t(NETWORK_LABEL[link.channel])}
238
+ </a>
239
+ ))}
240
+ </Flex>
241
+ );
242
+
243
+ return (
244
+ <>
245
+ <style href={LISTING_ACTIONS_STYLE_HREF} precedence="default">
246
+ {actionRowCss()}
247
+ </style>
248
+ {/* eslint-disable-next-line stapel/no-tooltip-in-skin -- see the header: click-only trigger, live anchor, four controls in the overlay rather than an explanation */}
249
+ <Popover
250
+ trigger={["click"]}
251
+ open={open}
252
+ onOpenChange={setOpen}
253
+ content={menu}
254
+ data-testid={`${testId}-popover`}
255
+ >
256
+ {trigger}
257
+ </Popover>
258
+ </>
259
+ );
260
+ }