@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
package/src/api/listingsApi.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
FavoriteToggleResponse,
|
|
5
5
|
ListingActionResponse,
|
|
6
6
|
ListingDetail,
|
|
7
|
+
ListingEngagementBatch,
|
|
7
8
|
ListingDraft,
|
|
8
9
|
ListingDraftPatch,
|
|
9
10
|
ListingPageParams,
|
|
@@ -15,6 +16,7 @@ import type {
|
|
|
15
16
|
PublishResponse,
|
|
16
17
|
} from "./types.js";
|
|
17
18
|
import type { ValidationBatchResult } from "@stapel/attributes-react";
|
|
19
|
+
import { engagementIds } from "./types.js";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* The pair's typed operation surface — one method per stapel-listings endpoint
|
|
@@ -123,6 +125,33 @@ export interface ListingsApi {
|
|
|
123
125
|
options?: { readonly signal?: AbortSignal }
|
|
124
126
|
): Promise<PaginatedListingCards>;
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The per-viewer OVERLAY for a whole page of cards, in one call.
|
|
130
|
+
*
|
|
131
|
+
* `{listing id: {view_count, viewed, is_favorited}}`, with an id that has
|
|
132
|
+
* no listing simply absent. This is the endpoint that makes the engagement
|
|
133
|
+
* flags visible on the two surfaces that matter: a storefront's feed and
|
|
134
|
+
* its SERP are served by the SEARCH index, whose stored card cannot hold a
|
|
135
|
+
* flag that differs per reader or a counter that moves faster than a
|
|
136
|
+
* re-index, so the grid draws the card from search and asks HERE for the
|
|
137
|
+
* three things that are about the person looking.
|
|
138
|
+
*
|
|
139
|
+
* `AllowAny` upstream, deliberately: `view_count` is public and both
|
|
140
|
+
* per-viewer flags answer `null` for a guest, so a storefront makes the
|
|
141
|
+
* same request signed in or not and a guest's grid is not a second code
|
|
142
|
+
* path.
|
|
143
|
+
*
|
|
144
|
+
* Ids are normalized by `engagementIds` before they get here — sorted,
|
|
145
|
+
* de-duplicated and capped at `LISTINGS_ENGAGEMENT_BATCH_LIMIT`, because
|
|
146
|
+
* the server TRUNCATES a longer list rather than refusing it and a short
|
|
147
|
+
* answer to a long question is the kind of absence this pair does not
|
|
148
|
+
* render.
|
|
149
|
+
*/
|
|
150
|
+
engagement(
|
|
151
|
+
ids: readonly number[],
|
|
152
|
+
options?: { readonly signal?: AbortSignal }
|
|
153
|
+
): Promise<ListingEngagementBatch>;
|
|
154
|
+
|
|
126
155
|
/**
|
|
127
156
|
* Start a draft. `category_id` is the only required member: the server
|
|
128
157
|
* forces `owner=request.user` and `status=draft` in `perform_create`, so a
|
|
@@ -254,6 +283,16 @@ export function createListingsApi(client: StapelClient): ListingsApi {
|
|
|
254
283
|
...signal(options),
|
|
255
284
|
}),
|
|
256
285
|
|
|
286
|
+
// One comma-separated value rather than a repeated parameter, for the
|
|
287
|
+
// reason `statusQuery` gives: both spellings are accepted upstream
|
|
288
|
+
// (`getlist("ids")` then splits on commas), and this one survives every
|
|
289
|
+
// `query` serializer a host's own StapelClient might carry.
|
|
290
|
+
engagement: (ids, options) =>
|
|
291
|
+
client.get(`${COLLECTION}engagement/`, {
|
|
292
|
+
query: { ids: engagementIds(ids).join(",") },
|
|
293
|
+
...signal(options),
|
|
294
|
+
}),
|
|
295
|
+
|
|
257
296
|
createDraft: (body) => client.post(COLLECTION, body),
|
|
258
297
|
|
|
259
298
|
saveDraft: (id, body) => client.post(`${listingPath(id)}save-draft/`, body),
|
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,47 @@
|
|
|
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, and why activation PINS 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
|
+
* So `open` is this component's own state. The first version of that made
|
|
39
|
+
* activation MONOTONIC for the duration of the click's own dispatch — a flag
|
|
40
|
+
* set in the capture phase and dropped a microtask later — and it closed the
|
|
41
|
+
* toggle, which was the half of the problem a synthetic click can show.
|
|
42
|
+
*
|
|
43
|
+
* It was not the half a THUMB meets, and the deployed measurement (walker
|
|
44
|
+
* D72, pass 7, the whole timeline in `logs-fp8/fp8-d72-diag3.json`) is
|
|
45
|
+
* unambiguous about which half that is. A real tap DOES open this: the
|
|
46
|
+
* overlay loses `ant-popover-hidden` 7 ms after `click`. Then the emulated
|
|
47
|
+
* hover the tap carried ENDS — a finger that has lifted is hovering nothing —
|
|
48
|
+
* `mouseleave` arrives ~10 ms later, antd's `mouseLeaveDelay` runs its 0.1 s,
|
|
49
|
+
* the leave motion runs, and the overlay is hidden again ~260 ms after it
|
|
50
|
+
* appeared. A quarter-second flash on a phone is indistinguishable from a
|
|
51
|
+
* control that did nothing, which is exactly how six passes read it. The
|
|
52
|
+
* microtask flag could not see this: the closer is not the click's toggle,
|
|
53
|
+
* it is a TIMER two hundred milliseconds behind the gesture.
|
|
54
|
+
*
|
|
55
|
+
* A disclosure that a person deliberately opened therefore PINS: once
|
|
56
|
+
* activated it stays open until it is dismissed, and hover-out, blur and the
|
|
57
|
+
* trigger's own toggle may no longer close it. Dismissal is the two gestures
|
|
58
|
+
* that mean "I am done reading" — a pointer down outside the control and
|
|
59
|
+
* outside the overlay, or `Escape` — and this component listens for both
|
|
60
|
+
* itself, because refusing antd's close is what takes them away.
|
|
61
|
+
*
|
|
62
|
+
* Hover keeps its old, unpinned behaviour: a cursor that rests on the control
|
|
63
|
+
* opens it and a cursor that leaves closes it, with no click involved.
|
|
64
|
+
*
|
|
29
65
|
* Deliberately NOT exported from `src/default/index.ts`: it is how this
|
|
30
66
|
* pair's own gated hearts speak, not a control a host composes with — the
|
|
31
67
|
* same standing `favorite.tsx` has.
|
|
32
68
|
*/
|
|
33
|
-
import { useId } from "react";
|
|
69
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
34
70
|
import type { ReactElement, ReactNode } from "react";
|
|
35
71
|
import { Flex, Typography } from "antd";
|
|
36
72
|
// 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 +100,59 @@ export interface GateReasonPopoverProps {
|
|
|
64
100
|
|
|
65
101
|
export function GateReasonPopover(props: GateReasonPopoverProps): ReactElement {
|
|
66
102
|
const reasonId = useId();
|
|
103
|
+
const [open, setOpen] = useState(false);
|
|
104
|
+
// Set by an ACTIVATION and cleared only by a dismissal. While it is set,
|
|
105
|
+
// every close antd asks for — the click's own toggle, the hover-out timer,
|
|
106
|
+
// a blur — is refused; see the file header.
|
|
107
|
+
const pinned = useRef(false);
|
|
108
|
+
// The two regions a dismissing gesture must NOT land in: the control itself
|
|
109
|
+
// (pressing it again restates the refusal) and the overlay (the sign-in
|
|
110
|
+
// door lives in there, and closing on its own pointerdown would take the
|
|
111
|
+
// link away before the click that follows it).
|
|
112
|
+
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
113
|
+
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
114
|
+
|
|
115
|
+
const dismiss = useCallback((): void => {
|
|
116
|
+
pinned.current = false;
|
|
117
|
+
setOpen(false);
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
const handleOpenChange = useCallback((next: boolean): void => {
|
|
121
|
+
if (!next && pinned.current) return;
|
|
122
|
+
setOpen(next);
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const activate = useCallback((): void => {
|
|
126
|
+
pinned.current = true;
|
|
127
|
+
setOpen(true);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
// Antd closes an unpinned popover on an outside click of its own; a pinned
|
|
131
|
+
// one has to be given those gestures back, or it would be a panel with no
|
|
132
|
+
// way out on the one device that most needs the pin.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!open) return undefined;
|
|
135
|
+
const doc = anchorRef.current?.ownerDocument ?? document;
|
|
136
|
+
const outside = (target: EventTarget | null): boolean =>
|
|
137
|
+
target instanceof Node &&
|
|
138
|
+
anchorRef.current?.contains(target) !== true &&
|
|
139
|
+
overlayRef.current?.contains(target) !== true;
|
|
140
|
+
const onPointerDown = (event: Event): void => {
|
|
141
|
+
if (outside(event.target)) dismiss();
|
|
142
|
+
};
|
|
143
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
144
|
+
if (event.key === "Escape") dismiss();
|
|
145
|
+
};
|
|
146
|
+
// Capture, so a handler that stops propagation on its own container
|
|
147
|
+
// cannot leave this stuck open.
|
|
148
|
+
doc.addEventListener("pointerdown", onPointerDown, true);
|
|
149
|
+
doc.addEventListener("keydown", onKeyDown, true);
|
|
150
|
+
return () => {
|
|
151
|
+
doc.removeEventListener("pointerdown", onPointerDown, true);
|
|
152
|
+
doc.removeEventListener("keydown", onKeyDown, true);
|
|
153
|
+
};
|
|
154
|
+
}, [open, dismiss]);
|
|
155
|
+
|
|
67
156
|
return (
|
|
68
157
|
<>
|
|
69
158
|
{/* The floor: the reason reaches AT without a pointer, popover or no
|
|
@@ -75,11 +164,15 @@ export function GateReasonPopover(props: GateReasonPopoverProps): ReactElement {
|
|
|
75
164
|
{/* 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
165
|
<Popover
|
|
77
166
|
trigger={["hover", "focus", "click"]}
|
|
167
|
+
open={open}
|
|
168
|
+
onOpenChange={handleOpenChange}
|
|
78
169
|
content={
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
170
|
+
<div ref={overlayRef}>
|
|
171
|
+
<Flex vertical gap={spacing[1]} data-testid={props.testId}>
|
|
172
|
+
<Typography.Text>{props.reason}</Typography.Text>
|
|
173
|
+
<SignInLink cta={props.cta} testId={props.signInTestId} />
|
|
174
|
+
</Flex>
|
|
175
|
+
</div>
|
|
83
176
|
}
|
|
84
177
|
>
|
|
85
178
|
{/* A plain wrapper takes the props Popover clones onto its direct
|
|
@@ -87,8 +180,22 @@ export function GateReasonPopover(props: GateReasonPopoverProps): ReactElement {
|
|
|
87
180
|
OVERWRITES that child's `aria-describedby` in both states. With
|
|
88
181
|
the wrapper in between, the control keeps its own wiring to the
|
|
89
182
|
hidden copy whether the disclosure is open or closed, and the
|
|
90
|
-
events still arrive: hover, focus and click all bubble.
|
|
91
|
-
|
|
183
|
+
events still arrive: hover, focus and click all bubble.
|
|
184
|
+
|
|
185
|
+
The two capture listeners are the tap's half of the fix. `click`
|
|
186
|
+
covers a mouse and a thumb; `keydown` covers Enter and Space,
|
|
187
|
+
which a browser does NOT turn into a click on a button carrying
|
|
188
|
+
`aria-disabled` in every engine. Capture, so they run before the
|
|
189
|
+
popover's own toggle sees the same event. */}
|
|
190
|
+
<div
|
|
191
|
+
ref={anchorRef}
|
|
192
|
+
onClickCapture={activate}
|
|
193
|
+
onKeyDownCapture={(event) => {
|
|
194
|
+
if (event.key === "Enter" || event.key === " ") activate();
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
{props.children({ "aria-describedby": reasonId })}
|
|
198
|
+
</div>
|
|
92
199
|
</Popover>
|
|
93
200
|
</>
|
|
94
201
|
);
|
|
@@ -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>
|