@stapel/listings-react 0.18.1 → 0.19.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 +61 -0
- package/dist/api/generated/schema.d.ts +101 -13
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +26 -1
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +9 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +91 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +32 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/GateReasonPopover.d.ts.map +1 -1
- package/dist/default/GateReasonPopover.js +47 -2
- package/dist/default/GateReasonPopover.js.map +1 -1
- package/dist/default/ListingCard.d.ts +32 -1
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +65 -14
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +12 -7
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts +32 -7
- package/dist/default/ListingFeedCard.d.ts.map +1 -1
- package/dist/default/ListingFeedCard.js +11 -3
- package/dist/default/ListingFeedCard.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -2
- package/dist/default/ListingPrice.d.ts.map +1 -1
- package/dist/default/ListingPrice.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts +9 -0
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +13 -3
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/favorite.d.ts +28 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +31 -6
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/icons.d.ts +17 -3
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +2 -1
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Engagement.d.ts +62 -0
- package/dist/headless/Engagement.d.ts.map +1 -0
- package/dist/headless/Engagement.js +116 -0
- package/dist/headless/Engagement.js.map +1 -0
- package/dist/headless/Favorites.d.ts +50 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +63 -3
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts +21 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +39 -3
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -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 +12 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +6 -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 +7 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +8 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +6 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/model/engagement.d.ts +85 -0
- package/dist/model/engagement.d.ts.map +1 -0
- package/dist/model/engagement.js +59 -0
- package/dist/model/engagement.js.map +1 -0
- package/dist/model/queries.d.ts +28 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +36 -0
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +12 -13
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +15 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/llms.txt +6 -3
- package/manifest.json +71 -3
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +101 -13
- package/src/api/listingsApi.ts +39 -0
- package/src/api/types.ts +115 -6
- package/src/default/GateReasonPopover.tsx +62 -3
- package/src/default/ListingCard.tsx +84 -10
- package/src/default/ListingDetailPane.tsx +48 -16
- package/src/default/ListingFeedCard.tsx +45 -8
- package/src/default/ListingPrice.tsx +13 -2
- package/src/default/ListingSerpCard.tsx +24 -1
- package/src/default/favorite.tsx +97 -18
- package/src/default/icons.tsx +21 -3
- package/src/default/index.ts +4 -0
- package/src/headless/Engagement.tsx +177 -0
- package/src/headless/Favorites.tsx +93 -3
- package/src/headless/ListingDetail.tsx +64 -4
- package/src/i18n/es.ts +7 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +6 -0
- package/src/i18n/generated/errors.json +14 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +8 -0
- package/src/i18n/ru.ts +9 -0
- package/src/index.ts +27 -0
- package/src/model/engagement.ts +118 -0
- package/src/model/queries.ts +41 -0
- package/src/model/queryKeys.ts +14 -0
|
@@ -24,13 +24,27 @@
|
|
|
24
24
|
* has no line to spare. A full row of "Sign in to save this" under every one
|
|
25
25
|
* of twenty tiles is not twenty pieces of help — it is the feed.
|
|
26
26
|
*
|
|
27
|
-
* The
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* The answer this card ships with is the INTERACTION DISCLOSURE the grid card
|
|
28
|
+
* and the SERP card were given in the desktop and mobile fix packs, and it is
|
|
29
|
+
* this card's DEFAULT rather than an opt-in — a two-column tile is the one
|
|
30
|
+
* surface in the pair with no line to put a sentence on, so a standing volume
|
|
31
|
+
* here has to overprint the photograph to exist at all.
|
|
32
|
+
*
|
|
33
|
+
* That default is a fix, not a preference. Measured on a live home feed: this
|
|
34
|
+
* was the last surface still printing "Sign in to do this" as standing copy,
|
|
35
|
+
* over the picture, under every tile — and the heart under it was html-
|
|
36
|
+
* `disabled`, so the one gesture that could have replaced the caption was
|
|
37
|
+
* swallowed by the control. Both halves are gone: nothing stands in the tile,
|
|
38
|
+
* and a tap on the heart discloses the reason and the container's sign-in
|
|
39
|
+
* door (`signIn`) while a visually-hidden copy keeps the refusal in the
|
|
40
|
+
* accessibility tree. A host that genuinely wants the standing sentence back
|
|
41
|
+
* asks for it by name with `blockedReason="text"`.
|
|
42
|
+
*
|
|
43
|
+
* `GateReasonScopeContext` / `<PaneGate>` remain the answer for the STANDING
|
|
44
|
+
* arm: they pool identical reasons and render each ONCE for everything inside
|
|
45
|
+
* the scope, with every control's `aria-describedby` still pointing at that
|
|
46
|
+
* single copy. A container drawing a feed in that arm should wrap
|
|
47
|
+
* `<FeedGrid>` in a `<PaneGate>`.
|
|
34
48
|
*
|
|
35
49
|
* ── Two lines of title, and then it stops ─────────────────────────────────
|
|
36
50
|
*
|
|
@@ -43,14 +57,18 @@
|
|
|
43
57
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
44
58
|
import { Flex, Typography } from "antd";
|
|
45
59
|
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
60
|
+
import type { SignInCta } from "@stapel/core";
|
|
46
61
|
import { useT } from "@stapel/core";
|
|
47
62
|
import { radii, spacing } from "@stapel/tokens";
|
|
48
63
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
49
64
|
import { lifecycleCaption } from "../model/status.js";
|
|
65
|
+
import { isListingViewed } from "../model/engagement.js";
|
|
66
|
+
import { useEngagedListing } from "../headless/Engagement.js";
|
|
50
67
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
51
68
|
import { FavoriteHeart } from "./favorite.js";
|
|
52
69
|
import {
|
|
53
70
|
CARD_TARGET_STYLE_HREF,
|
|
71
|
+
CARD_VIEWED_CLASS,
|
|
54
72
|
CardTarget,
|
|
55
73
|
cardTargetCss,
|
|
56
74
|
} from "./ListingCard.js";
|
|
@@ -118,18 +136,32 @@ export interface ListingFeedCardBaseProps extends ThemeModeProp {
|
|
|
118
136
|
/** Hide the favourite entirely — for a surface where it makes no sense.
|
|
119
137
|
* NOT a way to hide it from visitors. */
|
|
120
138
|
readonly showFavorite?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* How loudly a blocked heart states its refusal. **`"popover"` by default
|
|
141
|
+
* on this card, unlike the other two** — a feed tile has no line to print a
|
|
142
|
+
* sentence on, so the standing arm can only overprint the photograph. See
|
|
143
|
+
* the file header for the measurement that made it the default.
|
|
144
|
+
*/
|
|
145
|
+
readonly blockedReason?: "text" | "popover";
|
|
146
|
+
/** The container's sign-in door, rendered INSIDE the disclosure. Absent:
|
|
147
|
+
* the disclosure holds the reason alone. */
|
|
148
|
+
readonly signIn?: SignInCta;
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
export type ListingFeedCardProps = ListingFeedCardBaseProps & ListingCardOpenProps;
|
|
124
152
|
|
|
125
153
|
export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
|
|
126
154
|
const t = useT();
|
|
127
|
-
|
|
155
|
+
// See `<ListingCard>`: the scope's overlay over the row, or the row. This
|
|
156
|
+
// is the surface it matters most on — a home feed is drawn from search.
|
|
157
|
+
const listing = useEngagedListing(props.listing);
|
|
128
158
|
const status =
|
|
129
159
|
listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
130
160
|
const title = listing.title ?? "";
|
|
131
161
|
const targetLabel =
|
|
132
162
|
title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
|
|
163
|
+
// Already seen — `false` for every response that carries no such field.
|
|
164
|
+
const viewed = isListingViewed(listing);
|
|
133
165
|
|
|
134
166
|
return (
|
|
135
167
|
<SkinTheme
|
|
@@ -149,6 +181,9 @@ export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
|
|
|
149
181
|
{...(status !== undefined
|
|
150
182
|
? { "data-listing-status": status.status }
|
|
151
183
|
: {})}
|
|
184
|
+
{...(viewed
|
|
185
|
+
? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
|
|
186
|
+
: {})}
|
|
152
187
|
>
|
|
153
188
|
{/* One anchor over the whole tile. The photo is a still `<img>`, so
|
|
154
189
|
unlike the SERP card's swipeable strip it is safe inside a link —
|
|
@@ -211,6 +246,8 @@ export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
|
|
|
211
246
|
listingId={listing.id}
|
|
212
247
|
favorited={listing.is_favorited}
|
|
213
248
|
testId="listings-feed-favorite"
|
|
249
|
+
blockedReason={props.blockedReason ?? "popover"}
|
|
250
|
+
{...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
|
|
214
251
|
style={HEART}
|
|
215
252
|
/>
|
|
216
253
|
)}
|
|
@@ -37,8 +37,19 @@ import {
|
|
|
37
37
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
38
38
|
|
|
39
39
|
export interface ListingPriceProps {
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The amount as the wire spells it — a decimal string, or `null` for a
|
|
42
|
+
* listing that carries no price at all.
|
|
43
|
+
*
|
|
44
|
+
* `null` is in the TYPE since stapel-listings 0.17 spelled it in the
|
|
45
|
+
* schema (`price?: string | null`, D51: a blank price stays null
|
|
46
|
+
* server-side). It was always in the DATA — `hasAmount` below has guarded
|
|
47
|
+
* for it from the start, with a comment saying the serializer hands it
|
|
48
|
+
* through an unguarded `string | undefined`. Now the type says what the
|
|
49
|
+
* runtime already knew, and the guard stops being a defence against its
|
|
50
|
+
* own signature.
|
|
51
|
+
*/
|
|
52
|
+
readonly amount: string | null | undefined;
|
|
42
53
|
/** The code the amount is quoted in (`Listing.currency`). */
|
|
43
54
|
readonly currency?: string | undefined;
|
|
44
55
|
}
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
77
77
|
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
78
78
|
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
79
|
+
import type { SignInCta } from "@stapel/core";
|
|
79
80
|
import { useT } from "@stapel/core";
|
|
80
81
|
import { fontSize, spacing } from "@stapel/tokens";
|
|
81
82
|
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
@@ -87,6 +88,8 @@ import {
|
|
|
87
88
|
} from "../model/features.js";
|
|
88
89
|
import type { FeatureCopySource } from "../model/features.js";
|
|
89
90
|
import { lifecycleCaption } from "../model/status.js";
|
|
91
|
+
import { isListingViewed } from "../model/engagement.js";
|
|
92
|
+
import { useEngagedListing } from "../headless/Engagement.js";
|
|
90
93
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
91
94
|
import { PriceTrendIcon } from "./icons.js";
|
|
92
95
|
import { FavoriteHeart } from "./favorite.js";
|
|
@@ -96,6 +99,7 @@ import {
|
|
|
96
99
|
CARD_MEDIA_CLASS,
|
|
97
100
|
CARD_QUERY_CLASS,
|
|
98
101
|
CARD_TARGET_STYLE_HREF,
|
|
102
|
+
CARD_VIEWED_CLASS,
|
|
99
103
|
CardTarget,
|
|
100
104
|
cardTargetCss,
|
|
101
105
|
} from "./ListingCard.js";
|
|
@@ -149,6 +153,14 @@ export interface ListingSerpCardBaseProps
|
|
|
149
153
|
/** Hide the favourite entirely — for a surface where it makes no sense (the
|
|
150
154
|
* owner's own listings). NOT a way to hide it from visitors. */
|
|
151
155
|
readonly showFavorite?: boolean;
|
|
156
|
+
/** How loudly the blocked heart states its refusal (D45) — `"text"`
|
|
157
|
+
* (default) keeps the standing sentence, `"popover"` moves it onto the
|
|
158
|
+
* gesture. A phone SERP is the surface that needed the choice: fourteen
|
|
159
|
+
* cards printed fourteen copies of one sentence. */
|
|
160
|
+
readonly blockedReason?: "text" | "popover";
|
|
161
|
+
/** The container's sign-in door, shown inside the disclosure when the
|
|
162
|
+
* refusal is a `"popover"`. */
|
|
163
|
+
readonly signIn?: SignInCta;
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
export type ListingSerpCardProps = ListingSerpCardBaseProps & ListingCardOpenProps;
|
|
@@ -163,7 +175,9 @@ const BODY: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
|
|
|
163
175
|
|
|
164
176
|
export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
165
177
|
const t = useT();
|
|
166
|
-
const {
|
|
178
|
+
const { priceTrend } = props;
|
|
179
|
+
// See `<ListingCard>`: the scope's overlay over the row, or the row.
|
|
180
|
+
const listing = useEngagedListing(props.listing);
|
|
167
181
|
const { token } = antdTheme.useToken();
|
|
168
182
|
|
|
169
183
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
@@ -191,6 +205,8 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
191
205
|
const photos = listing.images ?? [];
|
|
192
206
|
const currency =
|
|
193
207
|
listing.currency !== undefined ? { currency: listing.currency } : {};
|
|
208
|
+
// Already seen — `false` for every response that carries no such field.
|
|
209
|
+
const viewed = isListingViewed(listing);
|
|
194
210
|
|
|
195
211
|
const rail =
|
|
196
212
|
props.actionsRail !== undefined || props.showFavorite !== false ? (
|
|
@@ -207,6 +223,10 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
207
223
|
listingId={listing.id}
|
|
208
224
|
favorited={listing.is_favorited}
|
|
209
225
|
testId="listings-serp-favorite"
|
|
226
|
+
{...(props.blockedReason !== undefined
|
|
227
|
+
? { blockedReason: props.blockedReason }
|
|
228
|
+
: {})}
|
|
229
|
+
{...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
|
|
210
230
|
/>
|
|
211
231
|
)}
|
|
212
232
|
</Flex>
|
|
@@ -227,6 +247,9 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
227
247
|
{...(status !== undefined
|
|
228
248
|
? { "data-listing-status": status.status }
|
|
229
249
|
: {})}
|
|
250
|
+
{...(viewed
|
|
251
|
+
? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
|
|
252
|
+
: {})}
|
|
230
253
|
styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
|
|
231
254
|
style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
|
|
232
255
|
>
|
package/src/default/favorite.tsx
CHANGED
|
@@ -26,11 +26,14 @@
|
|
|
26
26
|
* list or a grid of these cards should wrap it in one.
|
|
27
27
|
*/
|
|
28
28
|
import type { CSSProperties, ReactElement } from "react";
|
|
29
|
+
import { theme as antdTheme } from "antd";
|
|
29
30
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
30
|
-
import { GatedControl } from "@stapel/tokens-antd/skin";
|
|
31
|
-
import {
|
|
31
|
+
import { ErrorAlert, GatedControl } from "@stapel/tokens-antd/skin";
|
|
32
|
+
import type { SignInCta } from "@stapel/core";
|
|
33
|
+
import { useActionGate, useT } from "@stapel/core";
|
|
32
34
|
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
33
35
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
36
|
+
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
34
37
|
import { HeartIcon } from "./icons.js";
|
|
35
38
|
|
|
36
39
|
export interface FavoriteHeartProps {
|
|
@@ -43,6 +46,22 @@ export interface FavoriteHeartProps {
|
|
|
43
46
|
/** `"inline"` puts the reason beside the heart, `"stack"` (default) under
|
|
44
47
|
* it — the choice belongs to the surface, which knows its own geometry. */
|
|
45
48
|
readonly layout?: "stack" | "inline";
|
|
49
|
+
/**
|
|
50
|
+
* How loudly the refusal is stated on THIS surface (D45).
|
|
51
|
+
*
|
|
52
|
+
* `"text"` (default) keeps the standing sentence beside the heart — what
|
|
53
|
+
* this control has always done, and the right answer where one card is the
|
|
54
|
+
* screen. `"popover"` moves it onto the gesture: a signed-out phone SERP
|
|
55
|
+
* printed "sign in to do this" once under every one of fourteen cards, in
|
|
56
|
+
* the line where a price belongs, and fourteen copies of one sentence is
|
|
57
|
+
* not fourteen pieces of help. The reason never leaves the accessibility
|
|
58
|
+
* tree either way — see {@link GateReasonPopover}.
|
|
59
|
+
*/
|
|
60
|
+
readonly blockedReason?: "text" | "popover";
|
|
61
|
+
/** The surface's sign-in door, rendered INSIDE the disclosure. Absent: the
|
|
62
|
+
* disclosure holds the reason alone. Only read in the `"popover"` arm; the
|
|
63
|
+
* standing arm's door is the container's, as it always was. */
|
|
64
|
+
readonly signIn?: SignInCta;
|
|
46
65
|
readonly style?: CSSProperties;
|
|
47
66
|
}
|
|
48
67
|
|
|
@@ -52,15 +71,75 @@ export interface FavoriteHeartProps {
|
|
|
52
71
|
* (`signIn`) is the surface's business, not this control's — a disabled antd
|
|
53
72
|
* button fires no pointer events, so a tooltip here would be a reason nobody
|
|
54
73
|
* could read on any device (`stapel/no-tooltip-in-skin`).
|
|
74
|
+
*
|
|
75
|
+
* ── It is never html-`disabled`, in either volume ─────────────────────────
|
|
76
|
+
*
|
|
77
|
+
* `aria-disabled` plus a live handler, always. The refusal happens on
|
|
78
|
+
* ACTIVATION — `useFavoriteToggle().toggle` is a no-op while the gate is
|
|
79
|
+
* blocked, so the click cannot write anything — and that is the only shape of
|
|
80
|
+
* refusal a person can interrogate. A `disabled` DOM button is inert: it
|
|
81
|
+
* takes no focus, receives no pointer events, and swallows the very tap that
|
|
82
|
+
* was supposed to explain it. Measured on a phone: a signed-out visitor's
|
|
83
|
+
* heart produced no toast, no reason and no navigation, and on a touch device
|
|
84
|
+
* there is no hover to fall back on.
|
|
55
85
|
*/
|
|
56
86
|
export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
57
87
|
const t = useT();
|
|
88
|
+
const { token } = antdTheme.useToken();
|
|
58
89
|
const favorite = useFavoriteToggle(props.listingId, props.favorited);
|
|
59
90
|
const label = t(
|
|
60
91
|
favorite.favorited
|
|
61
92
|
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
62
93
|
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
63
94
|
);
|
|
95
|
+
// Saved is a SOLID accent shape, not-saved is the outline. `is_favorited:
|
|
96
|
+
// null` — an anonymous read's "nobody asked" — resolves to not-saved in the
|
|
97
|
+
// bag, so it draws the outline and never a third look of its own.
|
|
98
|
+
const icon = (
|
|
99
|
+
<HeartIcon
|
|
100
|
+
filled={favorite.favorited}
|
|
101
|
+
{...(favorite.favorited ? { color: token.colorPrimary } : {})}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
// The RESOLVED sentence, not the gate's key — `useActionGate` is the one
|
|
105
|
+
// place a blocked reason becomes words in this fleet.
|
|
106
|
+
const reason = useActionGate(favorite.gate).reason;
|
|
107
|
+
// A failed save, stated where the heart is. The rollback already put the
|
|
108
|
+
// icon back; this says why it went back.
|
|
109
|
+
const failure = (
|
|
110
|
+
<ErrorAlert
|
|
111
|
+
testId={`${props.testId}-error`}
|
|
112
|
+
thrown={favorite.error}
|
|
113
|
+
variant="inline"
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
if (props.blockedReason === "popover" && reason !== undefined) {
|
|
117
|
+
return (
|
|
118
|
+
<GateReasonPopover
|
|
119
|
+
reason={reason}
|
|
120
|
+
cta={props.signIn}
|
|
121
|
+
testId={`${props.testId}-reason`}
|
|
122
|
+
signInTestId={`${props.testId}-sign-in`}
|
|
123
|
+
>
|
|
124
|
+
{(bind) => (
|
|
125
|
+
<Button
|
|
126
|
+
shape="circle"
|
|
127
|
+
aria-disabled
|
|
128
|
+
{...bind}
|
|
129
|
+
aria-label={label}
|
|
130
|
+
aria-pressed={favorite.favorited}
|
|
131
|
+
data-testid={props.testId}
|
|
132
|
+
data-favorited={String(favorite.favorited)}
|
|
133
|
+
data-analytics="none"
|
|
134
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
135
|
+
onClick={favorite.toggle}
|
|
136
|
+
icon={icon}
|
|
137
|
+
{...(props.style !== undefined ? { style: props.style } : {})}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
</GateReasonPopover>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
64
143
|
return (
|
|
65
144
|
<GatedControl
|
|
66
145
|
gate={favorite.gate}
|
|
@@ -69,22 +148,22 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
69
148
|
{...(props.style !== undefined ? { style: props.style } : {})}
|
|
70
149
|
>
|
|
71
150
|
{(bind) => (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
151
|
+
<>
|
|
152
|
+
<Button
|
|
153
|
+
shape="circle"
|
|
154
|
+
// See `<ListingCard>`: the substrate's binding, spread whole.
|
|
155
|
+
{...bind}
|
|
156
|
+
aria-label={label}
|
|
157
|
+
aria-pressed={favorite.favorited}
|
|
158
|
+
data-testid={props.testId}
|
|
159
|
+
data-favorited={String(favorite.favorited)}
|
|
160
|
+
data-analytics="none"
|
|
161
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
162
|
+
onClick={favorite.toggle}
|
|
163
|
+
icon={icon}
|
|
164
|
+
/>
|
|
165
|
+
{failure}
|
|
166
|
+
</>
|
|
88
167
|
)}
|
|
89
168
|
</GatedControl>
|
|
90
169
|
);
|
package/src/default/icons.tsx
CHANGED
|
@@ -13,14 +13,32 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { ReactElement } from "react";
|
|
15
15
|
|
|
16
|
-
export
|
|
16
|
+
export interface HeartIconProps {
|
|
17
|
+
/** Saved: the glyph is a solid shape rather than an outline. THE state a
|
|
18
|
+
* person reads off a card, so it is the one prop this icon has. */
|
|
19
|
+
readonly filled: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The accent a SAVED heart is painted in — an antd theme token the caller
|
|
22
|
+
* resolved (`token.colorPrimary`), never a literal.
|
|
23
|
+
*
|
|
24
|
+
* The icon stays `currentColor` by default, which is what keeps it
|
|
25
|
+
* theme-neutral in every other state; this is the one place a colour is
|
|
26
|
+
* warranted, because "filled" and "outline" of the same neutral grey is a
|
|
27
|
+
* difference a person reads at a glance on a mock-up and misses entirely on
|
|
28
|
+
* a 170px feed tile in daylight.
|
|
29
|
+
*/
|
|
30
|
+
readonly color?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function HeartIcon(props: HeartIconProps): ReactElement {
|
|
34
|
+
const paint = props.color ?? "currentColor";
|
|
17
35
|
return (
|
|
18
36
|
<svg
|
|
19
37
|
width="16"
|
|
20
38
|
height="16"
|
|
21
39
|
viewBox="0 0 24 24"
|
|
22
|
-
fill={props.filled ?
|
|
23
|
-
stroke=
|
|
40
|
+
fill={props.filled ? paint : "none"}
|
|
41
|
+
stroke={paint}
|
|
24
42
|
strokeWidth="2"
|
|
25
43
|
strokeLinecap="round"
|
|
26
44
|
strokeLinejoin="round"
|
package/src/default/index.ts
CHANGED
|
@@ -39,6 +39,10 @@ export {
|
|
|
39
39
|
// threshold (and for a test that measures it). See `<ListingCard>`'s header.
|
|
40
40
|
LISTING_CARD_ROW_MIN,
|
|
41
41
|
LISTING_CARD_ROW_MEDIA,
|
|
42
|
+
// The already-seen mark, for a container that wants to dim its own chrome
|
|
43
|
+
// on the same rule (and for a test that reads it). All three cards take it.
|
|
44
|
+
CARD_VIEWED_CLASS,
|
|
45
|
+
LISTING_VIEWED_OPACITY,
|
|
42
46
|
} from "./ListingCard.js";
|
|
43
47
|
export type {
|
|
44
48
|
ListingCardProps,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ENGAGEMENT OVERLAY as a scope: one request for a page of cards, read by
|
|
3
|
+
* every card in it without a single extra prop.
|
|
4
|
+
*
|
|
5
|
+
* ── The problem this shape exists to solve ────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* `viewed`, `view_count` and `is_favorited` are on stapel-listings' own card
|
|
8
|
+
* and detail serializers, so a listing PAGE and this module's own card list
|
|
9
|
+
* carry them and need nothing here. The two surfaces a buyer actually
|
|
10
|
+
* scrolls do not: a storefront's home feed and its SERP are drawn by
|
|
11
|
+
* `@stapel/search-react` from the search index, whose stored document can
|
|
12
|
+
* hold neither a flag that differs per reader nor a counter that moves faster
|
|
13
|
+
* than a re-index. Those rows reach `<ListingCard>` with no engagement fields
|
|
14
|
+
* at all — so without this, every card on the two screens the feature was
|
|
15
|
+
* built for renders undimmed with an outline heart no matter what the person
|
|
16
|
+
* has done, and nothing anywhere reports a fault.
|
|
17
|
+
*
|
|
18
|
+
* ── Why a SCOPE and not a prop ────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Because the cost is per PAGE and the reader is per CARD, and a design that
|
|
21
|
+
* puts those in the same place gets one of them wrong. A `engagement` prop on
|
|
22
|
+
* the card would make each card responsible for its own answer, and the
|
|
23
|
+
* shortest way to satisfy that is one request per card — forty requests for a
|
|
24
|
+
* decoration, which is the exact N+1 the batch endpoint exists to prevent.
|
|
25
|
+
* The container knows the page; the card knows its id. So the container opens
|
|
26
|
+
* a scope with the ids it just rendered, ONE query runs, and each card looks
|
|
27
|
+
* itself up. It is the arrangement `GateReasonScopeContext` / `<PaneGate>`
|
|
28
|
+
* already use in this fleet for the same shape of problem.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <ListingEngagementScope ids={items.map((item) => item.id)}>
|
|
32
|
+
* <FeedGrid>
|
|
33
|
+
* {items.map((item) => <ListingFeedCard key={item.id} listing={item.card} … />)}
|
|
34
|
+
* </FeedGrid>
|
|
35
|
+
* </ListingEngagementScope>
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ── No scope is a first-class answer ──────────────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* A card outside a scope fires NOTHING and draws from its row exactly as it
|
|
41
|
+
* did before this module existed — which is right for this module's own card
|
|
42
|
+
* list and for the listing page, whose rows already carry the fields. Absent,
|
|
43
|
+
* empty, in-flight and FAILED are all the same silent no-op: no dimming, no
|
|
44
|
+
* count, no banner over a grid that is otherwise working. A grid that renders
|
|
45
|
+
* is worth more than a flag.
|
|
46
|
+
*/
|
|
47
|
+
import { createContext, useContext, useMemo } from "react";
|
|
48
|
+
import type { Context, ReactElement, ReactNode } from "react";
|
|
49
|
+
import { loadFailed, loadLoading, loadReady } from "@stapel/core";
|
|
50
|
+
import type { LoadState } from "@stapel/core";
|
|
51
|
+
import type {
|
|
52
|
+
ListingCard,
|
|
53
|
+
ListingEngagement,
|
|
54
|
+
ListingEngagementBatch,
|
|
55
|
+
} from "../api/types.js";
|
|
56
|
+
import { engagementIds } from "../api/types.js";
|
|
57
|
+
import { useListingEngagement } from "../model/queries.js";
|
|
58
|
+
import { engagementFor, withEngagement } from "../model/engagement.js";
|
|
59
|
+
|
|
60
|
+
/** What the scope publishes: the batch, however it is doing. */
|
|
61
|
+
export interface ListingEngagementBag {
|
|
62
|
+
/** This listing's entry, or `undefined` for an id the answer did not carry
|
|
63
|
+
* — which includes "the read has not landed" and "the read failed". */
|
|
64
|
+
overlayFor(id: number): ListingEngagement | undefined;
|
|
65
|
+
/** The read's own state, for a host that wants to know. NOTHING in this
|
|
66
|
+
* pair renders the failed arm: see the file header. */
|
|
67
|
+
readonly state: LoadState<ListingEngagementBatch>;
|
|
68
|
+
/** The normalized ids actually asked for — after the sort, the de-dupe and
|
|
69
|
+
* the server's own cap. A caller can compare it with what it handed in to
|
|
70
|
+
* see that a hundred-and-forty-card page was truncated. */
|
|
71
|
+
readonly ids: readonly number[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const NO_OVERLAY: ListingEngagementBag = {
|
|
75
|
+
overlayFor: () => undefined,
|
|
76
|
+
state: loadLoading(),
|
|
77
|
+
ids: [],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The scope. `null` outside a provider — and `null` is not an error, it is a
|
|
82
|
+
* container that never opted in.
|
|
83
|
+
*/
|
|
84
|
+
export const ListingEngagementContext: Context<ListingEngagementBag | null> =
|
|
85
|
+
createContext<ListingEngagementBag | null>(null);
|
|
86
|
+
|
|
87
|
+
export interface UseListingEngagementOverlayOptions {
|
|
88
|
+
/** Hold the request (a grid that has not resolved its rows yet). */
|
|
89
|
+
readonly enabled?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run the batch read for a page of ids and get the bag back.
|
|
94
|
+
*
|
|
95
|
+
* The headless half: a host rendering its own visuals over its own grid calls
|
|
96
|
+
* this directly. A host using this pair's cards wraps them in
|
|
97
|
+
* {@link ListingEngagementScope} instead, which calls this once and puts the
|
|
98
|
+
* bag where the cards can find it.
|
|
99
|
+
*/
|
|
100
|
+
export function useListingEngagementOverlay(
|
|
101
|
+
ids: readonly number[],
|
|
102
|
+
options: UseListingEngagementOverlayOptions = {}
|
|
103
|
+
): ListingEngagementBag {
|
|
104
|
+
const query = useListingEngagement(
|
|
105
|
+
ids,
|
|
106
|
+
options.enabled !== undefined ? { enabled: options.enabled } : {}
|
|
107
|
+
);
|
|
108
|
+
const wanted = useMemo(() => engagementIds(ids), [ids]);
|
|
109
|
+
const batch = query.data;
|
|
110
|
+
return useMemo(
|
|
111
|
+
() => ({
|
|
112
|
+
overlayFor: (id: number) => engagementFor(batch, id),
|
|
113
|
+
state:
|
|
114
|
+
query.status === "error"
|
|
115
|
+
? loadFailed(query.error)
|
|
116
|
+
: batch !== undefined
|
|
117
|
+
? loadReady(batch)
|
|
118
|
+
: loadLoading(),
|
|
119
|
+
ids: wanted,
|
|
120
|
+
}),
|
|
121
|
+
[batch, query.status, query.error, wanted]
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ListingEngagementScopeProps
|
|
126
|
+
extends UseListingEngagementOverlayOptions {
|
|
127
|
+
/** The listing ids on this page — normalized before the request, so the
|
|
128
|
+
* caller may hand them in any order and need not de-duplicate. */
|
|
129
|
+
readonly ids: readonly number[];
|
|
130
|
+
readonly children: ReactNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One request for the page, one lookup for every card inside it.
|
|
135
|
+
*
|
|
136
|
+
* Renderless: it draws no element of its own, so it can be wrapped around a
|
|
137
|
+
* grid without entering its layout.
|
|
138
|
+
*/
|
|
139
|
+
export function ListingEngagementScope(
|
|
140
|
+
props: ListingEngagementScopeProps
|
|
141
|
+
): ReactElement {
|
|
142
|
+
const bag = useListingEngagementOverlay(
|
|
143
|
+
props.ids,
|
|
144
|
+
props.enabled !== undefined ? { enabled: props.enabled } : {}
|
|
145
|
+
);
|
|
146
|
+
return (
|
|
147
|
+
<ListingEngagementContext.Provider value={bag}>
|
|
148
|
+
{props.children}
|
|
149
|
+
</ListingEngagementContext.Provider>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The scope's bag, or a permanently-empty one outside a scope. Never throws:
|
|
154
|
+
* a card must render everywhere, wired or not. */
|
|
155
|
+
export function useListingEngagementScope(): ListingEngagementBag {
|
|
156
|
+
return useContext(ListingEngagementContext) ?? NO_OVERLAY;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One listing's overlay entry, or `undefined`. */
|
|
160
|
+
export function useEngagementOverlay(
|
|
161
|
+
id: number
|
|
162
|
+
): ListingEngagement | undefined {
|
|
163
|
+
return useListingEngagementScope().overlayFor(id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A row with the scope's answer laid over it — what every card in this pair
|
|
168
|
+
* actually reads.
|
|
169
|
+
*
|
|
170
|
+
* Outside a scope, or for an id the batch did not carry, this returns the
|
|
171
|
+
* SAME object it was given, so a card that never opted in re-renders exactly
|
|
172
|
+
* as often as it used to.
|
|
173
|
+
*/
|
|
174
|
+
export function useEngagedListing(row: ListingCard): ListingCard {
|
|
175
|
+
const overlay = useEngagementOverlay(row.id);
|
|
176
|
+
return useMemo(() => withEngagement(row, overlay), [row, overlay]);
|
|
177
|
+
}
|