@stapel/listings-react 0.18.2 → 0.20.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 +59 -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 +92 -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 +18 -9
- 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.map +1 -1
- package/dist/default/ListingSerpCard.js +10 -2
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/favorite.d.ts +11 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +24 -9
- 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 +8 -8
- 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 +114 -7
- package/src/default/ListingCard.tsx +84 -10
- package/src/default/ListingDetailPane.tsx +77 -20
- package/src/default/ListingFeedCard.tsx +45 -8
- package/src/default/ListingPrice.tsx +13 -2
- package/src/default/ListingSerpCard.tsx +11 -1
- package/src/default/favorite.tsx +49 -21
- 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
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
* and the default `"column"` renders exactly what existing hosts already get.
|
|
52
52
|
*/
|
|
53
53
|
import type { ReactElement, ReactNode } from "react";
|
|
54
|
-
import { Descriptions, Divider, Flex, Typography } from "antd";
|
|
54
|
+
import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
|
|
55
55
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
56
56
|
import {
|
|
57
57
|
ErrorAlert,
|
|
@@ -175,6 +175,7 @@ export interface ListingDetailPaneProps
|
|
|
175
175
|
export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
176
176
|
const t = useT();
|
|
177
177
|
const { locale } = useI18n();
|
|
178
|
+
const { token } = antdTheme.useToken();
|
|
178
179
|
const bag = useListingDetail(props.id, {
|
|
179
180
|
...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
|
|
180
181
|
...(props.categoryFeatures !== undefined
|
|
@@ -190,10 +191,19 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
190
191
|
const split = props.layout === "split";
|
|
191
192
|
|
|
192
193
|
const favoriteLabel = t(
|
|
193
|
-
bag.isFavorited
|
|
194
|
+
bag.isFavorited
|
|
194
195
|
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
195
196
|
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
196
197
|
);
|
|
198
|
+
// Saved is a SOLID accent shape; not-saved (and `is_favorited: null`, which
|
|
199
|
+
// is "nobody asked", not "no") is the outline. The bag flips it on the
|
|
200
|
+
// gesture and rolls it back if the write fails — see `useListingDetail`.
|
|
201
|
+
const heartIcon = (
|
|
202
|
+
<HeartIcon
|
|
203
|
+
filled={bag.isFavorited}
|
|
204
|
+
{...(bag.isFavorited ? { color: token.colorPrimary } : {})}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
197
207
|
|
|
198
208
|
return (
|
|
199
209
|
<SkinTheme
|
|
@@ -334,6 +344,33 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
334
344
|
.join(" · ")}
|
|
335
345
|
</Typography.Text>
|
|
336
346
|
) : null}
|
|
347
|
+
|
|
348
|
+
{/* How many people opened it — the listing's own meta line,
|
|
349
|
+
under the title, where a reader looks for how much company
|
|
350
|
+
they have. It spent one release as a row of the
|
|
351
|
+
`<Descriptions>` below (walker D106): wedged between the
|
|
352
|
+
colour attribute and the location row, two screens down,
|
|
353
|
+
read as a PROPERTY OF THE GOODS rather than a fact about
|
|
354
|
+
the page. A view count is not a characteristic of a phone.
|
|
355
|
+
|
|
356
|
+
`bag.viewCount` is `undefined` when the response carries
|
|
357
|
+
no such field, and then there is no line at all — never a
|
|
358
|
+
zero standing in for an absence. There is no favourite
|
|
359
|
+
count beside it because the wire has none: the schema
|
|
360
|
+
carries `is_favorited`, a per-reader boolean, and no
|
|
361
|
+
aggregate anywhere (asserted in test/engagementState). */}
|
|
362
|
+
{bag.viewCount !== undefined ? (
|
|
363
|
+
<Typography.Text
|
|
364
|
+
type="secondary"
|
|
365
|
+
data-testid="listings-detail-meta"
|
|
366
|
+
style={{ display: "block" }}
|
|
367
|
+
>
|
|
368
|
+
{t(LISTINGS_I18N_KEYS.detailViews)}:{" "}
|
|
369
|
+
<span data-testid="listings-detail-views">
|
|
370
|
+
{bag.viewCount}
|
|
371
|
+
</span>
|
|
372
|
+
</Typography.Text>
|
|
373
|
+
) : null}
|
|
337
374
|
</>
|
|
338
375
|
);
|
|
339
376
|
|
|
@@ -416,9 +453,10 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
416
453
|
aria-disabled
|
|
417
454
|
{...bind}
|
|
418
455
|
aria-label={favoriteLabel}
|
|
419
|
-
aria-pressed={bag.isFavorited
|
|
420
|
-
icon={
|
|
456
|
+
aria-pressed={bag.isFavorited}
|
|
457
|
+
icon={heartIcon}
|
|
421
458
|
data-testid="listings-detail-favorite"
|
|
459
|
+
data-favorited={String(bag.isFavorited)}
|
|
422
460
|
data-analytics="none"
|
|
423
461
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
424
462
|
onClick={bag.toggleFavorite}
|
|
@@ -435,15 +473,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
435
473
|
>
|
|
436
474
|
{(bind) => (
|
|
437
475
|
<Button
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
{...(bind["aria-describedby"] !== undefined
|
|
441
|
-
? { "aria-describedby": bind["aria-describedby"] }
|
|
442
|
-
: {})}
|
|
476
|
+
// See `<ListingCard>`: the binding, spread whole.
|
|
477
|
+
{...bind}
|
|
443
478
|
aria-label={favoriteLabel}
|
|
444
|
-
aria-pressed={bag.isFavorited
|
|
445
|
-
icon={
|
|
479
|
+
aria-pressed={bag.isFavorited}
|
|
480
|
+
icon={heartIcon}
|
|
446
481
|
data-testid="listings-detail-favorite"
|
|
482
|
+
data-favorited={String(bag.isFavorited)}
|
|
447
483
|
data-analytics="none"
|
|
448
484
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
449
485
|
onClick={bag.toggleFavorite}
|
|
@@ -470,14 +506,26 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
470
506
|
</Flex>
|
|
471
507
|
);
|
|
472
508
|
|
|
473
|
-
const actionError =
|
|
474
|
-
|
|
509
|
+
const actionError = (
|
|
510
|
+
<>
|
|
511
|
+
{actions.error !== undefined && actions.error !== null ? (
|
|
512
|
+
<ErrorAlert
|
|
513
|
+
testId="listings-detail-action-error"
|
|
514
|
+
thrown={actions.error}
|
|
515
|
+
variant="inline"
|
|
516
|
+
/>
|
|
517
|
+
) : null}
|
|
518
|
+
{/* A save that did not save. The heart has already rolled
|
|
519
|
+
back to the state the tap started from — that is the
|
|
520
|
+
honest picture and a silent one, so the sentence goes
|
|
521
|
+
beside it. `ErrorAlert` renders nothing for nothing. */}
|
|
475
522
|
<ErrorAlert
|
|
476
|
-
testId="listings-detail-
|
|
477
|
-
thrown={
|
|
523
|
+
testId="listings-detail-favorite-error"
|
|
524
|
+
thrown={bag.favoriteError}
|
|
478
525
|
variant="inline"
|
|
479
526
|
/>
|
|
480
|
-
|
|
527
|
+
</>
|
|
528
|
+
);
|
|
481
529
|
|
|
482
530
|
const description = (
|
|
483
531
|
<>
|
|
@@ -565,26 +613,35 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
565
613
|
</>
|
|
566
614
|
);
|
|
567
615
|
|
|
568
|
-
const
|
|
616
|
+
const hasStock = listing.stock_quantity != null;
|
|
617
|
+
const hasPlace =
|
|
618
|
+
listing.location_label !== undefined &&
|
|
619
|
+
listing.location_label.length > 0;
|
|
620
|
+
|
|
621
|
+
// Both rows absent draws an empty table, which the view count used
|
|
622
|
+
// to hide by almost always being there. Nothing is nothing.
|
|
623
|
+
const meta = !hasStock && !hasPlace ? null : (
|
|
569
624
|
<Descriptions size="small" column={1}>
|
|
570
625
|
{/* Label cell and value cell, which is what a `<Descriptions>`
|
|
571
626
|
row IS: the label key carries no `{count}` (it did, and the
|
|
572
627
|
page printed the placeholder), the quantity is the value. */}
|
|
573
|
-
{
|
|
628
|
+
{hasStock ? (
|
|
574
629
|
<Descriptions.Item label={t(LISTINGS_I18N_KEYS.detailStock)}>
|
|
575
630
|
<span data-testid="listings-detail-stock">
|
|
576
631
|
{listing.stock_quantity}
|
|
577
632
|
</span>
|
|
578
633
|
</Descriptions.Item>
|
|
579
634
|
) : null}
|
|
580
|
-
{
|
|
581
|
-
listing.location_label.length > 0 ? (
|
|
635
|
+
{hasPlace ? (
|
|
582
636
|
<Descriptions.Item
|
|
583
637
|
label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
|
|
584
638
|
>
|
|
585
639
|
{listing.location_label}
|
|
586
640
|
</Descriptions.Item>
|
|
587
641
|
) : null}
|
|
642
|
+
{/* The view count used to be a third row here. It is a fact
|
|
643
|
+
about the PAGE, not a property of the goods, so it now
|
|
644
|
+
reads on the meta line under the title — see `heading`. */}
|
|
588
645
|
</Descriptions>
|
|
589
646
|
);
|
|
590
647
|
|
|
@@ -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
|
}
|
|
@@ -88,6 +88,8 @@ import {
|
|
|
88
88
|
} from "../model/features.js";
|
|
89
89
|
import type { FeatureCopySource } from "../model/features.js";
|
|
90
90
|
import { lifecycleCaption } from "../model/status.js";
|
|
91
|
+
import { isListingViewed } from "../model/engagement.js";
|
|
92
|
+
import { useEngagedListing } from "../headless/Engagement.js";
|
|
91
93
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
92
94
|
import { PriceTrendIcon } from "./icons.js";
|
|
93
95
|
import { FavoriteHeart } from "./favorite.js";
|
|
@@ -97,6 +99,7 @@ import {
|
|
|
97
99
|
CARD_MEDIA_CLASS,
|
|
98
100
|
CARD_QUERY_CLASS,
|
|
99
101
|
CARD_TARGET_STYLE_HREF,
|
|
102
|
+
CARD_VIEWED_CLASS,
|
|
100
103
|
CardTarget,
|
|
101
104
|
cardTargetCss,
|
|
102
105
|
} from "./ListingCard.js";
|
|
@@ -172,7 +175,9 @@ const BODY: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
|
|
|
172
175
|
|
|
173
176
|
export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
174
177
|
const t = useT();
|
|
175
|
-
const {
|
|
178
|
+
const { priceTrend } = props;
|
|
179
|
+
// See `<ListingCard>`: the scope's overlay over the row, or the row.
|
|
180
|
+
const listing = useEngagedListing(props.listing);
|
|
176
181
|
const { token } = antdTheme.useToken();
|
|
177
182
|
|
|
178
183
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
@@ -200,6 +205,8 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
200
205
|
const photos = listing.images ?? [];
|
|
201
206
|
const currency =
|
|
202
207
|
listing.currency !== undefined ? { currency: listing.currency } : {};
|
|
208
|
+
// Already seen — `false` for every response that carries no such field.
|
|
209
|
+
const viewed = isListingViewed(listing);
|
|
203
210
|
|
|
204
211
|
const rail =
|
|
205
212
|
props.actionsRail !== undefined || props.showFavorite !== false ? (
|
|
@@ -240,6 +247,9 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
240
247
|
{...(status !== undefined
|
|
241
248
|
? { "data-listing-status": status.status }
|
|
242
249
|
: {})}
|
|
250
|
+
{...(viewed
|
|
251
|
+
? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
|
|
252
|
+
: {})}
|
|
243
253
|
styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
|
|
244
254
|
style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
|
|
245
255
|
>
|
package/src/default/favorite.tsx
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
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 { ErrorAlert, GatedControl } from "@stapel/tokens-antd/skin";
|
|
31
32
|
import type { SignInCta } from "@stapel/core";
|
|
32
33
|
import { useActionGate, useT } from "@stapel/core";
|
|
33
34
|
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
@@ -70,22 +71,49 @@ export interface FavoriteHeartProps {
|
|
|
70
71
|
* (`signIn`) is the surface's business, not this control's — a disabled antd
|
|
71
72
|
* button fires no pointer events, so a tooltip here would be a reason nobody
|
|
72
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.
|
|
73
85
|
*/
|
|
74
86
|
export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
75
87
|
const t = useT();
|
|
88
|
+
const { token } = antdTheme.useToken();
|
|
76
89
|
const favorite = useFavoriteToggle(props.listingId, props.favorited);
|
|
77
90
|
const label = t(
|
|
78
91
|
favorite.favorited
|
|
79
92
|
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
80
93
|
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
81
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
|
+
);
|
|
82
104
|
// The RESOLVED sentence, not the gate's key — `useActionGate` is the one
|
|
83
105
|
// place a blocked reason becomes words in this fleet.
|
|
84
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
|
+
);
|
|
85
116
|
if (props.blockedReason === "popover" && reason !== undefined) {
|
|
86
|
-
// `aria-disabled`, never `disabled`: the gate already refuses the action
|
|
87
|
-
// (`toggle` is a no-op while blocked), and an html-disabled button
|
|
88
|
-
// swallows the hover, the focus and the tap the disclosure opens on.
|
|
89
117
|
return (
|
|
90
118
|
<GateReasonPopover
|
|
91
119
|
reason={reason}
|
|
@@ -105,7 +133,7 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
105
133
|
data-analytics="none"
|
|
106
134
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
107
135
|
onClick={favorite.toggle}
|
|
108
|
-
icon={
|
|
136
|
+
icon={icon}
|
|
109
137
|
{...(props.style !== undefined ? { style: props.style } : {})}
|
|
110
138
|
/>
|
|
111
139
|
)}
|
|
@@ -120,22 +148,22 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
120
148
|
{...(props.style !== undefined ? { style: props.style } : {})}
|
|
121
149
|
>
|
|
122
150
|
{(bind) => (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
</>
|
|
139
167
|
)}
|
|
140
168
|
</GatedControl>
|
|
141
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,
|