@stapel/listings-react 0.25.10 → 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 (133) hide show
  1. package/CHANGELOG.md +208 -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/MyListings.d.ts +39 -18
  60. package/dist/headless/MyListings.d.ts.map +1 -1
  61. package/dist/headless/MyListings.js +41 -19
  62. package/dist/headless/MyListings.js.map +1 -1
  63. package/dist/headless/Share.d.ts +103 -0
  64. package/dist/headless/Share.d.ts.map +1 -0
  65. package/dist/headless/Share.js +190 -0
  66. package/dist/headless/Share.js.map +1 -0
  67. package/dist/i18n/es.d.ts.map +1 -1
  68. package/dist/i18n/es.js +21 -0
  69. package/dist/i18n/es.js.map +1 -1
  70. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  71. package/dist/i18n/generated/errors.es.gen.js +1 -1
  72. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  73. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  74. package/dist/i18n/generated/errors.gen.js +9 -0
  75. package/dist/i18n/generated/errors.gen.js.map +1 -1
  76. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  77. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  78. package/dist/i18n/keys.d.ts +45 -0
  79. package/dist/i18n/keys.d.ts.map +1 -1
  80. package/dist/i18n/keys.js +60 -0
  81. package/dist/i18n/keys.js.map +1 -1
  82. package/dist/i18n/ru.d.ts.map +1 -1
  83. package/dist/i18n/ru.js +21 -0
  84. package/dist/i18n/ru.js.map +1 -1
  85. package/dist/index.d.ts +5 -3
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +2 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/model/cardBadges.d.ts +52 -3
  90. package/dist/model/cardBadges.d.ts.map +1 -1
  91. package/dist/model/cardBadges.js +74 -12
  92. package/dist/model/cardBadges.js.map +1 -1
  93. package/dist/model/mineSource.d.ts +9 -3
  94. package/dist/model/mineSource.d.ts.map +1 -1
  95. package/dist/model/mineSource.js.map +1 -1
  96. package/dist/model/status.d.ts +62 -13
  97. package/dist/model/status.d.ts.map +1 -1
  98. package/dist/model/status.js +69 -15
  99. package/dist/model/status.js.map +1 -1
  100. package/llms.txt +8 -3
  101. package/manifest.json +96 -4
  102. package/nav-manifest.json +1 -1
  103. package/package.json +10 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/api/generated/schema.ts +2 -2
  106. package/src/default/CardBadges.tsx +3 -1
  107. package/src/default/ListingActions.tsx +196 -0
  108. package/src/default/ListingCard.tsx +79 -43
  109. package/src/default/ListingDetailPane.tsx +268 -81
  110. package/src/default/ListingFeedCard.tsx +2 -0
  111. package/src/default/ListingPhoto.tsx +28 -0
  112. package/src/default/ListingSerpCard.tsx +39 -12
  113. package/src/default/MyListingsPane.tsx +77 -44
  114. package/src/default/ShareAction.tsx +260 -0
  115. package/src/default/actionRow.ts +131 -0
  116. package/src/default/cardGallery.ts +31 -0
  117. package/src/default/favorite.tsx +68 -1
  118. package/src/default/icons.tsx +60 -0
  119. package/src/default/index.ts +23 -0
  120. package/src/default/notice.ts +68 -0
  121. package/src/headless/MyListings.tsx +89 -38
  122. package/src/headless/Share.tsx +278 -0
  123. package/src/i18n/es.ts +28 -0
  124. package/src/i18n/generated/errors.es.gen.ts +1 -1
  125. package/src/i18n/generated/errors.gen.ts +9 -0
  126. package/src/i18n/generated/errors.json +31 -0
  127. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  128. package/src/i18n/keys.ts +63 -0
  129. package/src/i18n/ru.ts +28 -0
  130. package/src/index.ts +22 -0
  131. package/src/model/cardBadges.ts +124 -12
  132. package/src/model/mineSource.ts +9 -3
  133. package/src/model/status.ts +85 -18
@@ -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,19 @@
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, counted from their own read — see
27
+ * `model/status.ts` for why a fourth tab and not the archive. The block
28
+ * above the tabs stays as one LINE, without the rows: a takedown must not
29
+ * need a click to be discovered, and printing the same row twice on one
30
+ * screen is not the way to say so.
24
31
  * 3. **An empty tab says which emptiness it is.** "No drafts" and "nothing
25
32
  * sold yet" are different sentences and one generic "nothing here" is
26
33
  * neither.
@@ -85,6 +92,7 @@ const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
85
92
  active: LISTINGS_I18N_KEYS.mineTabActive,
86
93
  drafts: LISTINGS_I18N_KEYS.mineTabDrafts,
87
94
  archived: LISTINGS_I18N_KEYS.mineTabArchived,
95
+ removed: LISTINGS_I18N_KEYS.mineTabRemoved,
88
96
  };
89
97
 
90
98
  /** One empty sentence per tab — see the header, point 3. */
@@ -92,6 +100,7 @@ const TAB_EMPTY: Readonly<Record<MyListingsTab, string>> = {
92
100
  active: LISTINGS_I18N_KEYS.mineEmptyActive,
93
101
  drafts: LISTINGS_I18N_KEYS.mineEmptyDrafts,
94
102
  archived: LISTINGS_I18N_KEYS.mineEmptyArchived,
103
+ removed: LISTINGS_I18N_KEYS.mineEmptyRemoved,
95
104
  };
96
105
 
97
106
  /** The thumbnail column. A photo marketplace whose seller dashboard is a
@@ -452,12 +461,19 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
452
461
  // ONE confirmation for the whole list, keyed by the row that asked — not one
453
462
  // mounted dialog per row.
454
463
  const [removingId, setRemovingId] = useState<number | null>(null);
455
- const removal = useListingActions(
456
- removingId ?? 0,
464
+ const removingRow =
457
465
  bag.rows.status === "ready"
458
- ? bag.rows.data.find((row) => row.id === removingId)?.status
459
- : undefined
460
- );
466
+ ? bag.rows.data.find((row) => row.id === removingId)
467
+ : undefined;
468
+ const removal = useListingActions(removingId ?? 0, removingRow?.status, {
469
+ // The SERVER's answer for THIS row, exactly as the row's own controls
470
+ // take it. Without it this hook fell back to the mirror, and the mirror
471
+ // is a table about a status rather than about a listing — which is the
472
+ // difference between "a sold listing may be archived" and "this one may".
473
+ ...(removingRow?.available_transitions !== undefined
474
+ ? { available: removingRow.available_transitions }
475
+ : {}),
476
+ });
461
477
 
462
478
  const paged = bag.prevPage.available || bag.nextPage.available;
463
479
 
@@ -501,35 +517,37 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
501
517
  />
502
518
  ) : (
503
519
  <>
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
- })}
520
+ {/* The takedowns, announced above the tabs — see the header, point 2.
521
+ The LINE only: the rows themselves are the fourth tab, and this
522
+ says how many there are without waiting for a click. Rendered only
523
+ when there are some (an empty takedown section is a scare) and not
524
+ while that tab is open (nobody needs telling twice); a failure to
525
+ CHECK is not the same as "none", so that says so. */}
526
+ {bag.tab === "removed"
527
+ ? null
528
+ : matchList(bag.blockedRows, {
529
+ loading: () => null,
530
+ failed: () => (
531
+ <Typography.Text
532
+ type="secondary"
533
+ data-testid="listings-mine-takedowns-failed"
534
+ >
535
+ {t(LISTINGS_I18N_KEYS.mineBlockedLoadFailed)}
536
+ </Typography.Text>
537
+ ),
538
+ empty: () => null,
539
+ ready: (rows) => (
540
+ <Typography.Text
541
+ type="warning"
542
+ strong
543
+ data-testid="listings-mine-takedowns"
544
+ >
545
+ {tPlural(LISTINGS_I18N_KEYS.mineBlockedTitle, {
546
+ count: rows.length,
547
+ })}
548
+ </Typography.Text>
549
+ ),
550
+ })}
533
551
 
534
552
  <Tabs
535
553
  activeKey={bag.tab}
@@ -553,11 +571,13 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
553
571
  // phone instead of collapsing into an overflow menu.
554
572
  //
555
573
  // `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`).
574
+ // read lower than the rows underneath it, and the fourth tab
575
+ // has no server counter at all (D407 — a moderator-rejected
576
+ // listing sat in Drafts under a `0`, and a taken-down one
577
+ // under no number whatever).
558
578
  ready: (counts) => (
559
579
  <Typography.Text
560
- type="secondary"
580
+ type={tab === "removed" ? "warning" : "secondary"}
561
581
  data-testid={`listings-mine-count-${tab}`}
562
582
  >
563
583
  {` ${String(counts[tab])}`}
@@ -680,11 +700,24 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
680
700
  </>
681
701
  )}
682
702
 
703
+ {/* THE PROMISE IS ONLY MADE WHERE IT CAN BE KEPT.
704
+ "Archiving keeps it" is a real alternative to offer somebody about
705
+ to delete a live listing, and an insult to somebody deleting from
706
+ the archive — measured on the phone walk: the archive tab's own
707
+ delete dialog promised the archive to a person already standing in
708
+ it. So the sentence follows the ROW's state rather than the
709
+ wording, and the state that decides it is the one the seller would
710
+ have to act on: whether `archived` is a move this listing still
711
+ has. Archived, sold and taken-down rows have spent it. */}
683
712
  <SkinConfirm
684
713
  open={removingId !== null}
685
714
  danger
686
715
  title={t(LISTINGS_I18N_KEYS.mineDeleteConfirmTitle)}
687
- body={t(LISTINGS_I18N_KEYS.mineDeleteConfirmBody)}
716
+ body={t(
717
+ removal.moves.some((move) => move.to === "archived")
718
+ ? LISTINGS_I18N_KEYS.mineDeleteConfirmBody
719
+ : LISTINGS_I18N_KEYS.mineDeleteConfirmBodyFinal
720
+ )}
688
721
  confirmLabel={t(LISTINGS_I18N_KEYS.mineDelete)}
689
722
  confirming={removal.inFlight}
690
723
  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
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The geometry of a listing's action row — the heart, the share button, and
3
+ * the rules that make both of them reachable with a thumb.
4
+ *
5
+ * Split out of `<ListingActions>` so `<ShareAction>` can carry the same class
6
+ * without the two modules importing each other, and so the numbers below are
7
+ * something a test can READ rather than something a rendered button implies.
8
+ *
9
+ * ── Why a class and not an inline style ───────────────────────────────────
10
+ *
11
+ * Three of the four rules here cannot be written inline at all: a media query
12
+ * (the label that disappears on a phone), a descendant selector (the hit area
13
+ * of whatever element the skin registry substituted for antd's button), and
14
+ * `:focus-visible`. The fourth — the 44px floor — is written here with them so
15
+ * that "how big is this control" has ONE answer in this package instead of one
16
+ * per surface, which is how the SERP heart ended up 32px while the feed
17
+ * heart was 40 and neither was the number the platform guidelines ask for.
18
+ *
19
+ * ── 44 px, and where the number comes from ────────────────────────────────
20
+ *
21
+ * `controls["height-phone"]` — the token dictionary's own touch floor, the
22
+ * same value `SkinTheme` feeds antd as `controlHeight` on a phone (WCAG 2.5.8
23
+ * and both platform HIGs land on 44 CSS px). It is applied here as a MINIMUM
24
+ * on every viewport rather than only on a phone: these two controls are small
25
+ * glyphs pinned to the corner of a photograph, and a 32px circle at the edge
26
+ * of an image is a miss on a touchscreen laptop as surely as on a phone.
27
+ */
28
+ import { breakpoints, controls, radii, spacing } from "@stapel/tokens";
29
+
30
+ /** The class every control in the PAGE cluster carries: the 44px floor. */
31
+ export const LISTING_ACTION_CLASS = "stapel-listing-action";
32
+ /**
33
+ * The class a CARD's control carries — the same floor, one tier smaller on a
34
+ * pointer device.
35
+ *
36
+ * A card heart is drawn forty to a screen on a desktop grid, where the
37
+ * reference classified uses a 36px target and a cursor hits it every time;
38
+ * the same glyph on a phone is a thumb target and goes back to 44. Two tiers
39
+ * rather than one number, because a 44px circle in the corner of a 267px
40
+ * photograph is a fifth of the picture's height on a desktop — measured
41
+ * against the reference (§23), which is why this is not simply
42
+ * {@link LISTING_ACTION_CLASS}.
43
+ *
44
+ * The GLYPH does not change size in either tier; only the box around it does.
45
+ */
46
+ export const LISTING_CARD_ACTION_CLASS = "stapel-listing-card-action";
47
+ /** The class the cluster's row carries. */
48
+ export const LISTING_ACTIONS_CLASS = "stapel-listing-actions";
49
+ /**
50
+ * The class that pins the cluster to the trailing TOP corner of the media it
51
+ * is drawn over.
52
+ *
53
+ * Top-trailing is the one corner of a card gallery that is free: the dots own
54
+ * the bottom centre (`SkinCarousel`) and the "3 of 16" counter owns the
55
+ * bottom trailing corner (`cardGalleryCss`). Nothing here may move into
56
+ * either, which is why this is a named constant with a comment rather than
57
+ * two numbers picked per surface.
58
+ */
59
+ export const LISTING_ACTIONS_OVERLAY_CLASS = "stapel-listing-actions-over";
60
+ /** The class on the WORD beside a glyph — painted on a desktop, dropped on a
61
+ * phone, where the same word is still the control's accessible name. */
62
+ export const LISTING_ACTION_LABEL_CLASS = "stapel-listing-action-label";
63
+ /** The `href` the hoisted action-row stylesheet is deduplicated by. */
64
+ export const LISTING_ACTIONS_STYLE_HREF = "stapel-listings-action-row";
65
+
66
+ /**
67
+ * The minimum touch target for every control in the cluster, in CSS pixels.
68
+ *
69
+ * Exported so a host laying out beside the cluster measures against the same
70
+ * number — and so `test/shareAction.test.tsx` asserts the rule carries THIS
71
+ * value rather than a literal that could drift from the token.
72
+ */
73
+ export const LISTING_ACTION_HIT: number = controls["height-phone"];
74
+
75
+ /**
76
+ * A CARD control's minimum target on a pointer device, in CSS pixels.
77
+ *
78
+ * The reference classified's own desktop measurement (§23). It is not on the
79
+ * token scale, and it is not meant to be: the scale carries a control HEIGHT
80
+ * (32) and a touch FLOOR (44), and this is the third thing — the smallest box
81
+ * a cursor reliably hits around a 16px glyph. Named, with the measurement,
82
+ * rather than typed into a stylesheet as `36`.
83
+ *
84
+ * On a phone the card control goes back to {@link LISTING_ACTION_HIT}: there
85
+ * is no such thing as a small touch target.
86
+ */
87
+ export const LISTING_CARD_ACTION_HIT = 36;
88
+
89
+ export function actionRowCss(): string {
90
+ const action = `.${LISTING_ACTION_CLASS}`;
91
+ const cardAction = `.${LISTING_CARD_ACTION_CLASS}`;
92
+ const row = `.${LISTING_ACTIONS_CLASS}`;
93
+ const over = `.${LISTING_ACTIONS_OVERLAY_CLASS}`;
94
+ const label = `.${LISTING_ACTION_LABEL_CLASS}`;
95
+ const hit = String(LISTING_ACTION_HIT);
96
+ const phone = `(max-width:${String(breakpoints.tablet - 1)}px)`;
97
+ return [
98
+ // The floor. `min-*` rather than `width`/`height`: a share button with a
99
+ // word in it is wider than 44px and must stay so, and antd's own
100
+ // `controlHeight` already reaches the height on a phone — this is the
101
+ // guarantee for every OTHER viewport and for a host-registered button
102
+ // that never read the antd token at all.
103
+ `${action}{min-inline-size:${hit}px;min-block-size:${hit}px;` +
104
+ `display:inline-flex;align-items:center;justify-content:center}`,
105
+ // A card's control: one tier smaller where there is a cursor, the same
106
+ // 44px where there is a thumb. See LISTING_CARD_ACTION_HIT.
107
+ `${cardAction}{min-inline-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
108
+ `min-block-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
109
+ `display:inline-flex;align-items:center;justify-content:center}`,
110
+ `@media ${phone}{${cardAction}{min-inline-size:${hit}px;min-block-size:${hit}px}}`,
111
+ // The cluster. `align-items:flex-end` so a blocked heart's reason — the
112
+ // one thing here that can be two lines — stacks against the same edge
113
+ // instead of pushing the controls inwards (the arrangement
114
+ // `<ListingFeedCard>` already ships).
115
+ `${row}{display:flex;align-items:flex-end;gap:${String(spacing[2])}px}`,
116
+ // Over a photograph. `z-index:2` puts it above the counter's `1`; the two
117
+ // never meet anyway (top-trailing against bottom-trailing) and the layer
118
+ // is stated so a future third overlay has an order to join.
119
+ `${over}{position:absolute;inset-block-start:${String(spacing[2])}px;` +
120
+ `inset-inline-end:${String(spacing[2])}px;z-index:2;` +
121
+ `flex-direction:column;align-items:flex-end;` +
122
+ `border-radius:${String(radii.full)}px}`,
123
+ // THE WORD DISAPPEARS ON A PHONE, THE NAME NEVER DOES. Both controls
124
+ // carry their `aria-label` in every arm, so what a screen reader
125
+ // announces is identical at 390px and at 1440px; what changes is whether
126
+ // there is room to paint the word as well. A viewport query rather than a
127
+ // container one: this is chrome, not a layout that has to fit a track,
128
+ // and the question really is "is this a phone".
129
+ `@media ${phone}{${label}{display:none}}`,
130
+ ].join("");
131
+ }