@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.
- package/CHANGELOG.md +259 -0
- package/MODULE.md +3 -1
- package/README.md +122 -9
- package/dist/api/generated/schema.d.ts +22 -12
- 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 +170 -62
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +284 -28
- 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 +49 -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/detailGallery.d.ts +77 -0
- package/dist/default/detailGallery.d.ts.map +1 -0
- package/dist/default/detailGallery.js +91 -0
- package/dist/default/detailGallery.js.map +1 -0
- 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 +11 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +10 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/movableCluster.d.ts +18 -0
- package/dist/default/movableCluster.d.ts.map +1 -0
- package/dist/default/movableCluster.js +110 -0
- package/dist/default/movableCluster.js.map +1 -0
- 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 +61 -19
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +58 -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 +79 -15
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +86 -17
- 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 +22 -12
- 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 +520 -89
- 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 +81 -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/detailGallery.ts +97 -0
- package/src/default/favorite.tsx +68 -1
- package/src/default/icons.tsx +60 -0
- package/src/default/index.ts +37 -1
- package/src/default/movableCluster.tsx +137 -0
- package/src/default/notice.ts +68 -0
- package/src/headless/MyListings.tsx +130 -39
- 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 +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
|
|
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. **
|
|
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. 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
|
|
456
|
-
removingId ?? 0,
|
|
467
|
+
const removingRow =
|
|
457
468
|
bag.rows.status === "ready"
|
|
458
|
-
? bag.rows.data.find((row) => row.id === removingId)
|
|
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
|
|
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
|
-
|
|
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
|
|
557
|
-
//
|
|
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(
|
|
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
|
+
}
|