@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/api/generated/schema.d.ts +101 -13
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/listingsApi.d.ts +26 -1
  5. package/dist/api/listingsApi.d.ts.map +1 -1
  6. package/dist/api/listingsApi.js +9 -0
  7. package/dist/api/listingsApi.js.map +1 -1
  8. package/dist/api/types.d.ts +91 -6
  9. package/dist/api/types.d.ts.map +1 -1
  10. package/dist/api/types.js +32 -0
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/GateReasonPopover.d.ts.map +1 -1
  13. package/dist/default/GateReasonPopover.js +47 -2
  14. package/dist/default/GateReasonPopover.js.map +1 -1
  15. package/dist/default/ListingCard.d.ts +32 -1
  16. package/dist/default/ListingCard.d.ts.map +1 -1
  17. package/dist/default/ListingCard.js +65 -14
  18. package/dist/default/ListingCard.js.map +1 -1
  19. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  20. package/dist/default/ListingDetailPane.js +12 -7
  21. package/dist/default/ListingDetailPane.js.map +1 -1
  22. package/dist/default/ListingFeedCard.d.ts +32 -7
  23. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  24. package/dist/default/ListingFeedCard.js +11 -3
  25. package/dist/default/ListingFeedCard.js.map +1 -1
  26. package/dist/default/ListingPrice.d.ts +13 -2
  27. package/dist/default/ListingPrice.d.ts.map +1 -1
  28. package/dist/default/ListingPrice.js.map +1 -1
  29. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  30. package/dist/default/ListingSerpCard.js +10 -2
  31. package/dist/default/ListingSerpCard.js.map +1 -1
  32. package/dist/default/favorite.d.ts +11 -0
  33. package/dist/default/favorite.d.ts.map +1 -1
  34. package/dist/default/favorite.js +24 -9
  35. package/dist/default/favorite.js.map +1 -1
  36. package/dist/default/icons.d.ts +17 -3
  37. package/dist/default/icons.d.ts.map +1 -1
  38. package/dist/default/icons.js +2 -1
  39. package/dist/default/icons.js.map +1 -1
  40. package/dist/default/index.d.ts +1 -1
  41. package/dist/default/index.d.ts.map +1 -1
  42. package/dist/default/index.js +4 -1
  43. package/dist/default/index.js.map +1 -1
  44. package/dist/headless/Engagement.d.ts +62 -0
  45. package/dist/headless/Engagement.d.ts.map +1 -0
  46. package/dist/headless/Engagement.js +116 -0
  47. package/dist/headless/Engagement.js.map +1 -0
  48. package/dist/headless/Favorites.d.ts +50 -0
  49. package/dist/headless/Favorites.d.ts.map +1 -1
  50. package/dist/headless/Favorites.js +63 -3
  51. package/dist/headless/Favorites.js.map +1 -1
  52. package/dist/headless/ListingDetail.d.ts +21 -1
  53. package/dist/headless/ListingDetail.d.ts.map +1 -1
  54. package/dist/headless/ListingDetail.js +39 -3
  55. package/dist/headless/ListingDetail.js.map +1 -1
  56. package/dist/i18n/es.d.ts.map +1 -1
  57. package/dist/i18n/es.js +4 -0
  58. package/dist/i18n/es.js.map +1 -1
  59. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  60. package/dist/i18n/generated/errors.es.gen.js +1 -1
  61. package/dist/i18n/generated/errors.gen.d.ts +12 -0
  62. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  63. package/dist/i18n/generated/errors.gen.js +6 -0
  64. package/dist/i18n/generated/errors.gen.js.map +1 -1
  65. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  66. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  67. package/dist/i18n/keys.d.ts +7 -0
  68. package/dist/i18n/keys.d.ts.map +1 -1
  69. package/dist/i18n/keys.js +8 -0
  70. package/dist/i18n/keys.js.map +1 -1
  71. package/dist/i18n/ru.d.ts.map +1 -1
  72. package/dist/i18n/ru.js +6 -0
  73. package/dist/i18n/ru.js.map +1 -1
  74. package/dist/index.d.ts +6 -3
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +5 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/model/engagement.d.ts +85 -0
  79. package/dist/model/engagement.d.ts.map +1 -0
  80. package/dist/model/engagement.js +59 -0
  81. package/dist/model/engagement.js.map +1 -0
  82. package/dist/model/queries.d.ts +28 -1
  83. package/dist/model/queries.d.ts.map +1 -1
  84. package/dist/model/queries.js +36 -0
  85. package/dist/model/queries.js.map +1 -1
  86. package/dist/model/queryKeys.d.ts +12 -13
  87. package/dist/model/queryKeys.d.ts.map +1 -1
  88. package/dist/model/queryKeys.js +15 -0
  89. package/dist/model/queryKeys.js.map +1 -1
  90. package/llms.txt +6 -3
  91. package/manifest.json +71 -3
  92. package/nav-manifest.json +1 -1
  93. package/package.json +8 -8
  94. package/src/analytics/generated/events.json +1 -1
  95. package/src/api/generated/schema.ts +101 -13
  96. package/src/api/listingsApi.ts +39 -0
  97. package/src/api/types.ts +115 -6
  98. package/src/default/GateReasonPopover.tsx +62 -3
  99. package/src/default/ListingCard.tsx +84 -10
  100. package/src/default/ListingDetailPane.tsx +48 -16
  101. package/src/default/ListingFeedCard.tsx +45 -8
  102. package/src/default/ListingPrice.tsx +13 -2
  103. package/src/default/ListingSerpCard.tsx +11 -1
  104. package/src/default/favorite.tsx +49 -21
  105. package/src/default/icons.tsx +21 -3
  106. package/src/default/index.ts +4 -0
  107. package/src/headless/Engagement.tsx +177 -0
  108. package/src/headless/Favorites.tsx +93 -3
  109. package/src/headless/ListingDetail.tsx +64 -4
  110. package/src/i18n/es.ts +7 -0
  111. package/src/i18n/generated/errors.es.gen.ts +1 -1
  112. package/src/i18n/generated/errors.gen.ts +6 -0
  113. package/src/i18n/generated/errors.json +14 -0
  114. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  115. package/src/i18n/keys.ts +8 -0
  116. package/src/i18n/ru.ts +9 -0
  117. package/src/index.ts +27 -0
  118. package/src/model/engagement.ts +118 -0
  119. package/src/model/queries.ts +41 -0
  120. 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
- /** One row of a card list (`GET /listings/`, `GET /listings/my/favorites/`). */
45
- export type ListingCard = Schemas["ListingCard"];
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 = Schemas["PaginatedListingCardList"];
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 = Schemas["PaginatedMyListingCardList"];
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
- <div>{props.children({ "aria-describedby": reasonId })}</div>
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. That arm is gone; `blockedReason`
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
- const { listing } = props;
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={<HeartIcon filled={favorite.favorited} />}
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
- disabled={bind.disabled}
607
- data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
608
- {...(bind["aria-describedby"] !== undefined
609
- ? { "aria-describedby": bind["aria-describedby"] }
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={<HeartIcon filled={favorite.favorited} />}
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 === true
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 === true}
420
- icon={<HeartIcon filled={bag.isFavorited === true} />}
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
- disabled={bind.disabled}
439
- data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
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 === true}
445
- icon={<HeartIcon filled={bag.isFavorited === true} />}
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
- actions.error !== undefined && actions.error !== null ? (
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-action-error"
477
- thrown={actions.error}
496
+ testId="listings-detail-favorite-error"
497
+ thrown={bag.favoriteError}
478
498
  variant="inline"
479
499
  />
480
- ) : null;
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 fleet already has the mechanism for exactly this and it is not "hide the
28
- * reason": `GateReasonScopeContext` / `<PaneGate>` pool identical reasons and
29
- * render each ONCE for everything inside the scope, with every control's
30
- * `aria-describedby` still pointing at that single copy. **A container drawing
31
- * a feed should wrap `<FeedGrid>` in a `<PaneGate>`.** Unscoped, the reason
32
- * still renders — over the photo, visible, never behind a hover — because a
33
- * reason a person cannot read is the one outcome the doctrine forbids.
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
- const { listing } = props;
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
  )}