@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.
- package/CHANGELOG.md +208 -0
- package/README.md +77 -9
- package/dist/api/generated/schema.d.ts +2 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +3 -1
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingActions.d.ts +108 -0
- package/dist/default/ListingActions.d.ts.map +1 -0
- package/dist/default/ListingActions.js +29 -0
- package/dist/default/ListingActions.js.map +1 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +15 -8
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +76 -56
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +164 -18
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts.map +1 -1
- package/dist/default/ListingFeedCard.js +2 -1
- package/dist/default/ListingFeedCard.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +5 -2
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +18 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +45 -21
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/ShareAction.d.ts +32 -0
- package/dist/default/ShareAction.d.ts.map +1 -0
- package/dist/default/ShareAction.js +116 -0
- package/dist/default/ShareAction.js.map +1 -0
- package/dist/default/actionRow.d.ts +58 -0
- package/dist/default/actionRow.d.ts.map +1 -0
- package/dist/default/actionRow.js +128 -0
- package/dist/default/actionRow.js.map +1 -0
- package/dist/default/cardGallery.d.ts +2 -0
- package/dist/default/cardGallery.d.ts.map +1 -1
- package/dist/default/cardGallery.js +31 -0
- package/dist/default/cardGallery.js.map +1 -1
- package/dist/default/favorite.d.ts +25 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +29 -3
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/icons.d.ts +19 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +23 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +8 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/notice.d.ts +14 -0
- package/dist/default/notice.d.ts.map +1 -0
- package/dist/default/notice.js +62 -0
- package/dist/default/notice.js.map +1 -0
- package/dist/headless/MyListings.d.ts +39 -18
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +41 -19
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/headless/Share.d.ts +103 -0
- package/dist/headless/Share.d.ts.map +1 -0
- package/dist/headless/Share.js +190 -0
- package/dist/headless/Share.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +21 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +18 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +9 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +45 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +60 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +52 -3
- package/dist/model/cardBadges.d.ts.map +1 -1
- package/dist/model/cardBadges.js +74 -12
- package/dist/model/cardBadges.js.map +1 -1
- package/dist/model/mineSource.d.ts +9 -3
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/status.d.ts +62 -13
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +69 -15
- package/dist/model/status.js.map +1 -1
- package/llms.txt +8 -3
- package/manifest.json +96 -4
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +2 -2
- package/src/default/CardBadges.tsx +3 -1
- package/src/default/ListingActions.tsx +196 -0
- package/src/default/ListingCard.tsx +79 -43
- package/src/default/ListingDetailPane.tsx +268 -81
- package/src/default/ListingFeedCard.tsx +2 -0
- package/src/default/ListingPhoto.tsx +28 -0
- package/src/default/ListingSerpCard.tsx +39 -12
- package/src/default/MyListingsPane.tsx +77 -44
- package/src/default/ShareAction.tsx +260 -0
- package/src/default/actionRow.ts +131 -0
- package/src/default/cardGallery.ts +31 -0
- package/src/default/favorite.tsx +68 -1
- package/src/default/icons.tsx +60 -0
- package/src/default/index.ts +23 -0
- package/src/default/notice.ts +68 -0
- package/src/headless/MyListings.tsx +89 -38
- package/src/headless/Share.tsx +278 -0
- package/src/i18n/es.ts +28 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +9 -0
- package/src/i18n/generated/errors.json +31 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +28 -0
- package/src/index.ts +22 -0
- package/src/model/cardBadges.ts +124 -12
- package/src/model/mineSource.ts +9 -3
- 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
|
|
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. **
|
|
19
|
-
* groupings and `blocked` is in none of them,
|
|
20
|
-
* it in none of them
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
456
|
-
removingId ?? 0,
|
|
464
|
+
const removingRow =
|
|
457
465
|
bag.rows.status === "ready"
|
|
458
|
-
? bag.rows.data.find((row) => row.id === removingId)
|
|
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
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
557
|
-
//
|
|
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(
|
|
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
|
+
}
|