@stapel/listings-react 0.18.2 → 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 +41 -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.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 +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 +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
package/src/api/types.ts
CHANGED
|
@@ -38,11 +38,110 @@ import type { components } from "./generated/schema.js";
|
|
|
38
38
|
/** The generated schema table — the one source of truth for wire shapes. */
|
|
39
39
|
export type Schemas = components["schemas"];
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The engagement fields, named off the GENERATED row so a rename upstream
|
|
43
|
+
* breaks the build here rather than going quiet on a grid.
|
|
44
|
+
*
|
|
45
|
+
* These were hand-written mirrors while this pair's contract pin sat at
|
|
46
|
+
* `>=0.12 <0.13` and the emitted schema could not see stapel-listings
|
|
47
|
+
* 0.16/0.17. The pin has landed, so the mirrors are gone and the names come
|
|
48
|
+
* from `Schemas["ListingCard"]`. What is kept is the OPTIONALITY, and that is
|
|
49
|
+
* not laziness:
|
|
50
|
+
*
|
|
51
|
+
* **A generated type is a promise about the contract, not about the bytes a
|
|
52
|
+
* particular deployment sends.** The schema says every card carries `viewed`
|
|
53
|
+
* and `view_count`; a storefront pointed at a server still running 0.15 gets
|
|
54
|
+
* rows without them, and the two surfaces this feature exists for — a feed
|
|
55
|
+
* and a SERP — are served by the SEARCH index, whose stored document carries
|
|
56
|
+
* neither by construction. `Partial` is what keeps `model/engagement.ts`
|
|
57
|
+
* allowed to ask, and every reader there answers "absent" with the same
|
|
58
|
+
* silence it answers `null` with.
|
|
59
|
+
*/
|
|
60
|
+
export type ListingEngagementFields = Partial<
|
|
61
|
+
Pick<Schemas["ListingCard"], "viewed" | "view_count">
|
|
62
|
+
>;
|
|
63
|
+
|
|
64
|
+
/** A generated row with its engagement fields relaxed to optional — see
|
|
65
|
+
* {@link ListingEngagementFields} for why every row type below is spelled
|
|
66
|
+
* this way rather than taken from `Schemas` whole. */
|
|
67
|
+
type WithOptionalEngagement<Row extends ListingEngagementFields> = Omit<
|
|
68
|
+
Row,
|
|
69
|
+
keyof ListingEngagementFields
|
|
70
|
+
> &
|
|
71
|
+
ListingEngagementFields;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The per-viewer overlay for ONE listing, as the batch read answers it.
|
|
75
|
+
*
|
|
76
|
+
* Note what is REQUIRED here and optional on the row: the overlay always
|
|
77
|
+
* carries all three keys, because it is the answer to a question that was
|
|
78
|
+
* actually asked. A row that carries none of them was serialized by a build
|
|
79
|
+
* that had never heard of them.
|
|
80
|
+
*/
|
|
81
|
+
export type ListingEngagement = Schemas["ListingEngagement"];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `GET /listings/engagement/?ids=…` 200 — `{listing id: overlay}`.
|
|
85
|
+
*
|
|
86
|
+
* The keys are the ids as STRINGS (a DRF `DictField`), and an id with no
|
|
87
|
+
* listing is simply absent rather than present-and-empty. Both facts are why
|
|
88
|
+
* `model/engagement.ts` looks entries up through one function instead of
|
|
89
|
+
* indexing the object at call sites.
|
|
90
|
+
*/
|
|
91
|
+
export type ListingEngagementBatch = Schemas["ListingEngagementBatch"];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The most ids one overlay call may carry — `ENGAGEMENT_BATCH_LIMIT`
|
|
95
|
+
* upstream, which silently TRUNCATES anything longer ("one page of cards, not
|
|
96
|
+
* a crawl of the board").
|
|
97
|
+
*
|
|
98
|
+
* Mirrored here so the truncation happens where it can be seen and named
|
|
99
|
+
* rather than in a response that quietly came back short. A page of more than
|
|
100
|
+
* a hundred cards is not a page; a caller that has one should ask per screen.
|
|
101
|
+
*/
|
|
102
|
+
export const LISTINGS_ENGAGEMENT_BATCH_LIMIT = 100;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Normalize the ids on a page into the exact list the request sends.
|
|
106
|
+
*
|
|
107
|
+
* Sorted, de-duplicated, non-integer values dropped, capped at
|
|
108
|
+
* {@link LISTINGS_ENGAGEMENT_BATCH_LIMIT}. Sorting is what makes the cache
|
|
109
|
+
* key honest: the answer is a MAP keyed by id, so two renders that ask for
|
|
110
|
+
* the same ids in different orders are asking the identical question and must
|
|
111
|
+
* not buy two cache entries and two requests. De-duplication is the same
|
|
112
|
+
* argument for a grid that shows one listing twice (a promoted slot above the
|
|
113
|
+
* organic result it also occupies).
|
|
114
|
+
*
|
|
115
|
+
* `queryKeys.engagement` and `api.engagement` are both built from THIS, so
|
|
116
|
+
* the key cannot drift from the request it stands for.
|
|
117
|
+
*/
|
|
118
|
+
export function engagementIds(ids: readonly number[]): readonly number[] {
|
|
119
|
+
const seen = new Set<number>();
|
|
120
|
+
for (const id of ids) {
|
|
121
|
+
if (Number.isInteger(id)) seen.add(id);
|
|
122
|
+
}
|
|
123
|
+
return [...seen].sort((a, b) => a - b).slice(0, LISTINGS_ENGAGEMENT_BATCH_LIMIT);
|
|
124
|
+
}
|
|
125
|
+
|
|
41
126
|
/** `GET /listings/{pk}/` 200 — everything a detail page reads. */
|
|
42
|
-
export type ListingDetail = Schemas["ListingDetail"]
|
|
127
|
+
export type ListingDetail = WithOptionalEngagement<Schemas["ListingDetail"]>;
|
|
43
128
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
129
|
+
/**
|
|
130
|
+
* One row of a card list (`GET /listings/`, `GET /listings/my/favorites/`) —
|
|
131
|
+
* and the PROP every card component in this pair takes, which is why its
|
|
132
|
+
* engagement fields are optional where the schema makes them required.
|
|
133
|
+
*
|
|
134
|
+
* The schema is right about this module's own responses: `GET /listings/`
|
|
135
|
+
* carries `viewed` and `view_count` on every row. But the card components are
|
|
136
|
+
* handed rows from elsewhere, and the most important elsewhere is the SEARCH
|
|
137
|
+
* index — `@stapel/search-react` fills `renderCard` with a stored document
|
|
138
|
+
* that cannot hold a per-reader flag by construction. Requiring the fields on
|
|
139
|
+
* the prop would make the pair's primary consumer unable to satisfy its own
|
|
140
|
+
* type, for data no one can supply; it is precisely the case
|
|
141
|
+
* `<ListingEngagementScope>` exists to answer. A deployment running a server
|
|
142
|
+
* older than 0.16 is the same shape of fact.
|
|
143
|
+
*/
|
|
144
|
+
export type ListingCard = WithOptionalEngagement<Schemas["ListingCard"]>;
|
|
46
145
|
|
|
47
146
|
/** `POST /listings/` request+response and `POST /{pk}/save-draft/` response —
|
|
48
147
|
* the draft twin. Every user-editable field is a `*_draft` one, promoted onto
|
|
@@ -74,7 +173,12 @@ export type DeleteResponse = Schemas["DeleteResponse"];
|
|
|
74
173
|
|
|
75
174
|
/** The keyset envelope both public card lists come back in
|
|
76
175
|
* (`IDAnchorPagination`). */
|
|
77
|
-
export type PaginatedListingCards =
|
|
176
|
+
export type PaginatedListingCards = Omit<
|
|
177
|
+
Schemas["PaginatedListingCardList"],
|
|
178
|
+
"items"
|
|
179
|
+
> & {
|
|
180
|
+
items: ListingCard[];
|
|
181
|
+
};
|
|
78
182
|
|
|
79
183
|
/**
|
|
80
184
|
* One row of `GET /listings/my/listings/` — the OWNER's card.
|
|
@@ -93,12 +197,17 @@ export type PaginatedListingCards = Schemas["PaginatedListingCardList"];
|
|
|
93
197
|
* tab keyed off them is a column of blank rows. `myListingTitle` /
|
|
94
198
|
* `myListingPrice` (`model/mine.ts`) are the one place the fallback lives.
|
|
95
199
|
*/
|
|
96
|
-
export type MyListingCard = Schemas["MyListingCard"]
|
|
200
|
+
export type MyListingCard = WithOptionalEngagement<Schemas["MyListingCard"]>;
|
|
97
201
|
|
|
98
202
|
/** The keyset envelope `GET /listings/my/listings/` comes back in — the same
|
|
99
203
|
* `IDAnchorPagination` shape as {@link PaginatedListingCards}, over the owner
|
|
100
204
|
* row. */
|
|
101
|
-
export type PaginatedMyListingCards =
|
|
205
|
+
export type PaginatedMyListingCards = Omit<
|
|
206
|
+
Schemas["PaginatedMyListingCardList"],
|
|
207
|
+
"items"
|
|
208
|
+
> & {
|
|
209
|
+
items: MyListingCard[];
|
|
210
|
+
};
|
|
102
211
|
|
|
103
212
|
/**
|
|
104
213
|
* The nine lifecycle states, as `models.ListingStatus` declares them.
|
|
@@ -26,11 +26,33 @@
|
|
|
26
26
|
* screen (see `ListingCardBlockedReason`). The rule's disables below are the
|
|
27
27
|
* documented decision §2.4 asks for, not a hole.
|
|
28
28
|
*
|
|
29
|
+
* ── Why the disclosure is CONTROLLED: hover opened it, the click shut it ───
|
|
30
|
+
*
|
|
31
|
+
* An uncontrolled popover triggered by hover AND click treats the click as a
|
|
32
|
+
* TOGGLE, so the natural pointer gesture — rest on the control, then press it
|
|
33
|
+
* — opens the disclosure on the hover and closes it again on the press. The
|
|
34
|
+
* overlay comes back marked `ant-popover-hidden` and the person has pressed
|
|
35
|
+
* the one control that was supposed to explain itself and watched it go
|
|
36
|
+
* blank. Reproduced in jsdom and gated by `test/favoriteGesture.test.tsx`.
|
|
37
|
+
*
|
|
38
|
+
* It needs the DELAY to reproduce, which is the interesting part: antd opens
|
|
39
|
+
* a hover popover after 0.1s, so two events fired back to back (a thumb's
|
|
40
|
+
* emulated `mouseenter` + `click`) land while nothing is open yet and the
|
|
41
|
+
* click opens it correctly. A cursor that lingers is past the delay, and its
|
|
42
|
+
* click closes what the hover opened. The suite was green throughout because
|
|
43
|
+
* every existing test fired `click` alone — a synthetic click carries no
|
|
44
|
+
* hover in front of it, so it exercised the one ordering that worked.
|
|
45
|
+
*
|
|
46
|
+
* So `open` is this component's own state and activation is MONOTONIC: a
|
|
47
|
+
* click may only open the disclosure, never close it. Hover-out, blur and a
|
|
48
|
+
* click outside still close it, because those arrive as an `onOpenChange`
|
|
49
|
+
* that no activation is claiming.
|
|
50
|
+
*
|
|
29
51
|
* Deliberately NOT exported from `src/default/index.ts`: it is how this
|
|
30
52
|
* pair's own gated hearts speak, not a control a host composes with — the
|
|
31
53
|
* same standing `favorite.tsx` has.
|
|
32
54
|
*/
|
|
33
|
-
import { useId } from "react";
|
|
55
|
+
import { useCallback, useId, useRef, useState } from "react";
|
|
34
56
|
import type { ReactElement, ReactNode } from "react";
|
|
35
57
|
import { Flex, Typography } from "antd";
|
|
36
58
|
// eslint-disable-next-line stapel/no-tooltip-in-skin -- interaction disclosure, not a hover: opens on click/tap and focus too, anchored to a non-disabled control, with the reason kept in the a11y tree via aria-describedby (see this file's header)
|
|
@@ -64,6 +86,28 @@ export interface GateReasonPopoverProps {
|
|
|
64
86
|
|
|
65
87
|
export function GateReasonPopover(props: GateReasonPopoverProps): ReactElement {
|
|
66
88
|
const reasonId = useId();
|
|
89
|
+
const [open, setOpen] = useState(false);
|
|
90
|
+
// Set in the capture phase of a click, i.e. BEFORE the popover's own
|
|
91
|
+
// handler runs on the same event, and dropped once that event is fully
|
|
92
|
+
// dispatched. While it is set, "close" is the tap's own toggle and is
|
|
93
|
+
// refused; see the file header.
|
|
94
|
+
const activating = useRef(false);
|
|
95
|
+
|
|
96
|
+
const handleOpenChange = useCallback((next: boolean): void => {
|
|
97
|
+
if (!next && activating.current) return;
|
|
98
|
+
setOpen(next);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const activate = useCallback((): void => {
|
|
102
|
+
activating.current = true;
|
|
103
|
+
setOpen(true);
|
|
104
|
+
// A microtask runs after the whole event dispatch, so the flag covers
|
|
105
|
+
// exactly this gesture and no later hover-out.
|
|
106
|
+
queueMicrotask(() => {
|
|
107
|
+
activating.current = false;
|
|
108
|
+
});
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
67
111
|
return (
|
|
68
112
|
<>
|
|
69
113
|
{/* The floor: the reason reaches AT without a pointer, popover or no
|
|
@@ -75,6 +119,8 @@ export function GateReasonPopover(props: GateReasonPopoverProps): ReactElement {
|
|
|
75
119
|
{/* eslint-disable-next-line stapel/no-tooltip-in-skin -- interaction disclosure, not a hover: click/tap and focus open it too, and the reason stays in the a11y tree (header) */}
|
|
76
120
|
<Popover
|
|
77
121
|
trigger={["hover", "focus", "click"]}
|
|
122
|
+
open={open}
|
|
123
|
+
onOpenChange={handleOpenChange}
|
|
78
124
|
content={
|
|
79
125
|
<Flex vertical gap={spacing[1]} data-testid={props.testId}>
|
|
80
126
|
<Typography.Text>{props.reason}</Typography.Text>
|
|
@@ -87,8 +133,21 @@ export function GateReasonPopover(props: GateReasonPopoverProps): ReactElement {
|
|
|
87
133
|
OVERWRITES that child's `aria-describedby` in both states. With
|
|
88
134
|
the wrapper in between, the control keeps its own wiring to the
|
|
89
135
|
hidden copy whether the disclosure is open or closed, and the
|
|
90
|
-
events still arrive: hover, focus and click all bubble.
|
|
91
|
-
|
|
136
|
+
events still arrive: hover, focus and click all bubble.
|
|
137
|
+
|
|
138
|
+
The two capture listeners are the tap's half of the fix. `click`
|
|
139
|
+
covers a mouse and a thumb; `keydown` covers Enter and Space,
|
|
140
|
+
which a browser does NOT turn into a click on a button carrying
|
|
141
|
+
`aria-disabled` in every engine. Capture, so they run before the
|
|
142
|
+
popover's own toggle sees the same event. */}
|
|
143
|
+
<div
|
|
144
|
+
onClickCapture={activate}
|
|
145
|
+
onKeyDownCapture={(event) => {
|
|
146
|
+
if (event.key === "Enter" || event.key === " ") activate();
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
{props.children({ "aria-describedby": reasonId })}
|
|
150
|
+
</div>
|
|
92
151
|
</Popover>
|
|
93
152
|
</>
|
|
94
153
|
);
|
|
@@ -93,7 +93,11 @@
|
|
|
93
93
|
* `<GatedControl>`, which renders it as text linked by `aria-describedby` —
|
|
94
94
|
* a disabled antd button receives no pointer events and is not focusable, so
|
|
95
95
|
* the `Tooltip` this component used to offer as a "quieter" volume was a
|
|
96
|
-
* reason nobody could read on any device.
|
|
96
|
+
* reason nobody could read on any device. The heart itself is never
|
|
97
|
+
* html-`disabled` in ANY volume for the same reason: it carries
|
|
98
|
+
* `aria-disabled` and a live handler, and refuses on ACTIVATION (the gate
|
|
99
|
+
* makes `toggle` a no-op). An inert control cannot be tapped, cannot be
|
|
100
|
+
* focused, and cannot disclose anything. That arm is gone; `blockedReason`
|
|
97
101
|
* is "text" (reason + door), "line" (reason alone, for a grid), or
|
|
98
102
|
* "popover" — the reason and the door disclosed on the heart itself, with
|
|
99
103
|
* the reason still in the accessibility tree. The third arm is NOT the old
|
|
@@ -103,7 +107,7 @@
|
|
|
103
107
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
104
108
|
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
105
109
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
106
|
-
import { GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
|
|
110
|
+
import { ErrorAlert, GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
|
|
107
111
|
import { useActionGate, useT } from "@stapel/core";
|
|
108
112
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
109
113
|
import { spacing } from "@stapel/tokens";
|
|
@@ -112,6 +116,8 @@ import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
|
112
116
|
import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
113
117
|
import type { FeatureCopySource } from "../model/features.js";
|
|
114
118
|
import { lifecycleCaption } from "../model/status.js";
|
|
119
|
+
import { isListingViewed } from "../model/engagement.js";
|
|
120
|
+
import { useEngagedListing } from "../headless/Engagement.js";
|
|
115
121
|
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
116
122
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
117
123
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
@@ -211,6 +217,35 @@ export const CARD_MAIN_CLASS = "stapel-listing-card-main";
|
|
|
211
217
|
* (`<ListingCard>`), so the row arm gets the inset the stacked arm does not. */
|
|
212
218
|
export const CARD_BLEED_CLASS = "stapel-listing-card-bleed";
|
|
213
219
|
|
|
220
|
+
/**
|
|
221
|
+
* The class an ALREADY-SEEN card carries — the mark every mature classified
|
|
222
|
+
* puts on a result you have opened before, and the reason a shopper on their
|
|
223
|
+
* third evening of the same search is not re-reading the same twenty offers.
|
|
224
|
+
*
|
|
225
|
+
* All three card surfaces take it, and all three take it on their OUTER box,
|
|
226
|
+
* so the rule below can reach the photo and the reading column while leaving
|
|
227
|
+
* the favourite heart at full strength: a dimmed heart reads as a disabled
|
|
228
|
+
* heart, and the one control on the card must not look switched off because
|
|
229
|
+
* the card behind it is old news.
|
|
230
|
+
*/
|
|
231
|
+
export const CARD_VIEWED_CLASS = "stapel-listing-card-seen";
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* How far a seen card is dimmed.
|
|
235
|
+
*
|
|
236
|
+
* OPACITY and not a colour, which is the whole reason this needs no dark-mode
|
|
237
|
+
* arm: opacity composites the card against whatever ground the theme painted,
|
|
238
|
+
* so the same number is a grey card on a white page and a dim card on a black
|
|
239
|
+
* one. A `color` here would have to be picked twice, and the second pick is
|
|
240
|
+
* the one nobody photographs. 0.55 is far enough to sort seen from unseen at
|
|
241
|
+
* a glance and not so far that the price stops being readable — a seen
|
|
242
|
+
* listing is still a listing, not a disabled control.
|
|
243
|
+
*
|
|
244
|
+
* A host that wants another value sets `--listing-viewed-opacity` on any
|
|
245
|
+
* ancestor; the rule reads the property and falls back to this.
|
|
246
|
+
*/
|
|
247
|
+
export const LISTING_VIEWED_OPACITY = 0.55;
|
|
248
|
+
|
|
214
249
|
/** The `href` the hoisted card stylesheet is deduplicated by. */
|
|
215
250
|
export const CARD_TARGET_STYLE_HREF = "stapel-listings-card-target";
|
|
216
251
|
|
|
@@ -276,6 +311,12 @@ export function cardTargetCss(): string {
|
|
|
276
311
|
`${bleed}{padding-block:var(--listing-card-inset);` +
|
|
277
312
|
`padding-inline-start:var(--listing-card-inset)}` +
|
|
278
313
|
`}`,
|
|
314
|
+
// Already seen. The photo and everything inside the card's anchor dim
|
|
315
|
+
// together; the heart is outside the anchor and stays as it was — see
|
|
316
|
+
// CARD_VIEWED_CLASS.
|
|
317
|
+
`.${CARD_VIEWED_CLASS} .${CARD_TARGET_CLASS},` +
|
|
318
|
+
`.${CARD_VIEWED_CLASS} .${CARD_MEDIA_CLASS}` +
|
|
319
|
+
`{opacity:var(--listing-viewed-opacity,${String(LISTING_VIEWED_OPACITY)})}`,
|
|
279
320
|
].join("");
|
|
280
321
|
}
|
|
281
322
|
|
|
@@ -413,7 +454,10 @@ export function CardTarget(
|
|
|
413
454
|
|
|
414
455
|
export function ListingCard(props: ListingCardProps): ReactElement {
|
|
415
456
|
const t = useT();
|
|
416
|
-
|
|
457
|
+
// The engagement scope's answer laid over the row, when a container opened
|
|
458
|
+
// one — the same object otherwise. A card fed by search carries no
|
|
459
|
+
// `viewed`/`is_favorited` of its own; see `headless/Engagement.tsx`.
|
|
460
|
+
const listing = useEngagedListing(props.listing);
|
|
417
461
|
const favorite = useFavoriteToggle(listing.id, listing.is_favorited);
|
|
418
462
|
const { token } = antdTheme.useToken();
|
|
419
463
|
|
|
@@ -433,6 +477,18 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
433
477
|
|
|
434
478
|
const favoriteGate = useActionGate(favorite.gate);
|
|
435
479
|
const blockedReason = props.blockedReason ?? "text";
|
|
480
|
+
// Absent on every response the fleet answers today, and a plain `false`
|
|
481
|
+
// when it is — no dimming, no attribute, nothing said about it.
|
|
482
|
+
const viewed = isListingViewed(listing);
|
|
483
|
+
// A saved heart is a SOLID accent shape; an unsaved one is the outline it
|
|
484
|
+
// has always been. `is_favorited: null` reads as unsaved (`favorite`
|
|
485
|
+
// resolves the third state), never as a look of its own.
|
|
486
|
+
const heartIcon = (
|
|
487
|
+
<HeartIcon
|
|
488
|
+
filled={favorite.favorited}
|
|
489
|
+
{...(favorite.favorited ? { color: token.colorPrimary } : {})}
|
|
490
|
+
/>
|
|
491
|
+
);
|
|
436
492
|
|
|
437
493
|
const favoriteLabel = t(
|
|
438
494
|
favorite.favorited
|
|
@@ -521,6 +577,11 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
521
577
|
{...(status !== undefined
|
|
522
578
|
? { "data-listing-status": status.status }
|
|
523
579
|
: {})}
|
|
580
|
+
// Both only when the server actually said so: absent and `null` leave
|
|
581
|
+
// the card byte-identical to what it renders today.
|
|
582
|
+
{...(viewed
|
|
583
|
+
? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
|
|
584
|
+
: {})}
|
|
524
585
|
// The body's own padding is zero because the frame fills the card and
|
|
525
586
|
// the photo runs edge to edge when it is stacked: padding here would
|
|
526
587
|
// be a strip of card around a picture. The text block inside the
|
|
@@ -588,7 +649,7 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
588
649
|
data-analytics="none"
|
|
589
650
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
590
651
|
onClick={favorite.toggle}
|
|
591
|
-
icon={
|
|
652
|
+
icon={heartIcon}
|
|
592
653
|
/>
|
|
593
654
|
</Flex>
|
|
594
655
|
)}
|
|
@@ -603,11 +664,12 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
603
664
|
{(bind) => (
|
|
604
665
|
<Flex justify="flex-end" style={{ width: "100%" }}>
|
|
605
666
|
<Button
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
667
|
+
// The substrate's binding, spread whole: it now
|
|
668
|
+
// owns "never inert" for the whole fleet —
|
|
669
|
+
// `disabled` stays false, `aria-disabled` and
|
|
670
|
+
// `tabIndex` come from it, and the reason is
|
|
671
|
+
// wired by `aria-describedby`.
|
|
672
|
+
{...bind}
|
|
611
673
|
aria-label={favoriteLabel}
|
|
612
674
|
aria-pressed={favorite.favorited}
|
|
613
675
|
data-testid="listings-card-favorite"
|
|
@@ -615,7 +677,7 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
615
677
|
data-analytics="none"
|
|
616
678
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
617
679
|
onClick={favorite.toggle}
|
|
618
|
-
icon={
|
|
680
|
+
icon={heartIcon}
|
|
619
681
|
/>
|
|
620
682
|
</Flex>
|
|
621
683
|
)}
|
|
@@ -637,6 +699,18 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
637
699
|
)}
|
|
638
700
|
</>
|
|
639
701
|
)}
|
|
702
|
+
|
|
703
|
+
{/* A save that did not save. The heart has already rolled
|
|
704
|
+
back to what it was, which is the honest picture but a
|
|
705
|
+
silent one — a person who watched the icon flip and flip
|
|
706
|
+
back deserves the sentence too. The pair's one error
|
|
707
|
+
surface, in its inline volume, because a boxed alert
|
|
708
|
+
inside a grid cell would re-lay the whole row. */}
|
|
709
|
+
<ErrorAlert
|
|
710
|
+
testId="listings-card-favorite-error"
|
|
711
|
+
thrown={favorite.error}
|
|
712
|
+
variant="inline"
|
|
713
|
+
/>
|
|
640
714
|
</div>
|
|
641
715
|
)}
|
|
642
716
|
</div>
|
|
@@ -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
|
|
@@ -416,9 +426,10 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
416
426
|
aria-disabled
|
|
417
427
|
{...bind}
|
|
418
428
|
aria-label={favoriteLabel}
|
|
419
|
-
aria-pressed={bag.isFavorited
|
|
420
|
-
icon={
|
|
429
|
+
aria-pressed={bag.isFavorited}
|
|
430
|
+
icon={heartIcon}
|
|
421
431
|
data-testid="listings-detail-favorite"
|
|
432
|
+
data-favorited={String(bag.isFavorited)}
|
|
422
433
|
data-analytics="none"
|
|
423
434
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
424
435
|
onClick={bag.toggleFavorite}
|
|
@@ -435,15 +446,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
435
446
|
>
|
|
436
447
|
{(bind) => (
|
|
437
448
|
<Button
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
{...(bind["aria-describedby"] !== undefined
|
|
441
|
-
? { "aria-describedby": bind["aria-describedby"] }
|
|
442
|
-
: {})}
|
|
449
|
+
// See `<ListingCard>`: the binding, spread whole.
|
|
450
|
+
{...bind}
|
|
443
451
|
aria-label={favoriteLabel}
|
|
444
|
-
aria-pressed={bag.isFavorited
|
|
445
|
-
icon={
|
|
452
|
+
aria-pressed={bag.isFavorited}
|
|
453
|
+
icon={heartIcon}
|
|
446
454
|
data-testid="listings-detail-favorite"
|
|
455
|
+
data-favorited={String(bag.isFavorited)}
|
|
447
456
|
data-analytics="none"
|
|
448
457
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
449
458
|
onClick={bag.toggleFavorite}
|
|
@@ -470,14 +479,26 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
470
479
|
</Flex>
|
|
471
480
|
);
|
|
472
481
|
|
|
473
|
-
const actionError =
|
|
474
|
-
|
|
482
|
+
const actionError = (
|
|
483
|
+
<>
|
|
484
|
+
{actions.error !== undefined && actions.error !== null ? (
|
|
485
|
+
<ErrorAlert
|
|
486
|
+
testId="listings-detail-action-error"
|
|
487
|
+
thrown={actions.error}
|
|
488
|
+
variant="inline"
|
|
489
|
+
/>
|
|
490
|
+
) : null}
|
|
491
|
+
{/* A save that did not save. The heart has already rolled
|
|
492
|
+
back to the state the tap started from — that is the
|
|
493
|
+
honest picture and a silent one, so the sentence goes
|
|
494
|
+
beside it. `ErrorAlert` renders nothing for nothing. */}
|
|
475
495
|
<ErrorAlert
|
|
476
|
-
testId="listings-detail-
|
|
477
|
-
thrown={
|
|
496
|
+
testId="listings-detail-favorite-error"
|
|
497
|
+
thrown={bag.favoriteError}
|
|
478
498
|
variant="inline"
|
|
479
499
|
/>
|
|
480
|
-
|
|
500
|
+
</>
|
|
501
|
+
);
|
|
481
502
|
|
|
482
503
|
const description = (
|
|
483
504
|
<>
|
|
@@ -585,6 +606,17 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
585
606
|
{listing.location_label}
|
|
586
607
|
</Descriptions.Item>
|
|
587
608
|
) : null}
|
|
609
|
+
{/* How many people opened it. `bag.viewCount` is `undefined`
|
|
610
|
+
for a response that carries no such field — which is every
|
|
611
|
+
response today — so this row simply is not there, and no
|
|
612
|
+
zero stands in for the absence. */}
|
|
613
|
+
{bag.viewCount !== undefined ? (
|
|
614
|
+
<Descriptions.Item label={t(LISTINGS_I18N_KEYS.detailViews)}>
|
|
615
|
+
<span data-testid="listings-detail-views">
|
|
616
|
+
{bag.viewCount}
|
|
617
|
+
</span>
|
|
618
|
+
</Descriptions.Item>
|
|
619
|
+
) : null}
|
|
588
620
|
</Descriptions>
|
|
589
621
|
);
|
|
590
622
|
|
|
@@ -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
|
)}
|