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