@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
@@ -37,8 +37,19 @@ import {
37
37
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
38
38
 
39
39
  export interface ListingPriceProps {
40
- /** The amount as the wire spells it — a decimal string. */
41
- readonly amount: string | undefined;
40
+ /**
41
+ * The amount as the wire spells it — a decimal string, or `null` for a
42
+ * listing that carries no price at all.
43
+ *
44
+ * `null` is in the TYPE since stapel-listings 0.17 spelled it in the
45
+ * schema (`price?: string | null`, D51: a blank price stays null
46
+ * server-side). It was always in the DATA — `hasAmount` below has guarded
47
+ * for it from the start, with a comment saying the serializer hands it
48
+ * through an unguarded `string | undefined`. Now the type says what the
49
+ * runtime already knew, and the guard stops being a defence against its
50
+ * own signature.
51
+ */
52
+ readonly amount: string | null | undefined;
42
53
  /** The code the amount is quoted in (`Listing.currency`). */
43
54
  readonly currency?: string | undefined;
44
55
  }
@@ -88,6 +88,8 @@ import {
88
88
  } from "../model/features.js";
89
89
  import type { FeatureCopySource } from "../model/features.js";
90
90
  import { lifecycleCaption } from "../model/status.js";
91
+ import { isListingViewed } from "../model/engagement.js";
92
+ import { useEngagedListing } from "../headless/Engagement.js";
91
93
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
92
94
  import { PriceTrendIcon } from "./icons.js";
93
95
  import { FavoriteHeart } from "./favorite.js";
@@ -97,6 +99,7 @@ import {
97
99
  CARD_MEDIA_CLASS,
98
100
  CARD_QUERY_CLASS,
99
101
  CARD_TARGET_STYLE_HREF,
102
+ CARD_VIEWED_CLASS,
100
103
  CardTarget,
101
104
  cardTargetCss,
102
105
  } from "./ListingCard.js";
@@ -172,7 +175,9 @@ const BODY: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
172
175
 
173
176
  export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
174
177
  const t = useT();
175
- const { listing, priceTrend } = props;
178
+ const { priceTrend } = props;
179
+ // See `<ListingCard>`: the scope's overlay over the row, or the row.
180
+ const listing = useEngagedListing(props.listing);
176
181
  const { token } = antdTheme.useToken();
177
182
 
178
183
  const badgeDaos = asFeatureDaoList(listing.features_badges);
@@ -200,6 +205,8 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
200
205
  const photos = listing.images ?? [];
201
206
  const currency =
202
207
  listing.currency !== undefined ? { currency: listing.currency } : {};
208
+ // Already seen — `false` for every response that carries no such field.
209
+ const viewed = isListingViewed(listing);
203
210
 
204
211
  const rail =
205
212
  props.actionsRail !== undefined || props.showFavorite !== false ? (
@@ -240,6 +247,9 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
240
247
  {...(status !== undefined
241
248
  ? { "data-listing-status": status.status }
242
249
  : {})}
250
+ {...(viewed
251
+ ? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
252
+ : {})}
243
253
  styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
244
254
  style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
245
255
  >
@@ -26,8 +26,9 @@
26
26
  * list or a grid of these cards should wrap it in one.
27
27
  */
28
28
  import type { CSSProperties, ReactElement } from "react";
29
+ import { theme as antdTheme } from "antd";
29
30
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
30
- import { GatedControl } from "@stapel/tokens-antd/skin";
31
+ import { ErrorAlert, GatedControl } from "@stapel/tokens-antd/skin";
31
32
  import type { SignInCta } from "@stapel/core";
32
33
  import { useActionGate, useT } from "@stapel/core";
33
34
  import { useFavoriteToggle } from "../headless/Favorites.js";
@@ -70,22 +71,49 @@ export interface FavoriteHeartProps {
70
71
  * (`signIn`) is the surface's business, not this control's — a disabled antd
71
72
  * button fires no pointer events, so a tooltip here would be a reason nobody
72
73
  * could read on any device (`stapel/no-tooltip-in-skin`).
74
+ *
75
+ * ── It is never html-`disabled`, in either volume ─────────────────────────
76
+ *
77
+ * `aria-disabled` plus a live handler, always. The refusal happens on
78
+ * ACTIVATION — `useFavoriteToggle().toggle` is a no-op while the gate is
79
+ * blocked, so the click cannot write anything — and that is the only shape of
80
+ * refusal a person can interrogate. A `disabled` DOM button is inert: it
81
+ * takes no focus, receives no pointer events, and swallows the very tap that
82
+ * was supposed to explain it. Measured on a phone: a signed-out visitor's
83
+ * heart produced no toast, no reason and no navigation, and on a touch device
84
+ * there is no hover to fall back on.
73
85
  */
74
86
  export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
75
87
  const t = useT();
88
+ const { token } = antdTheme.useToken();
76
89
  const favorite = useFavoriteToggle(props.listingId, props.favorited);
77
90
  const label = t(
78
91
  favorite.favorited
79
92
  ? LISTINGS_I18N_KEYS.cardFavoriteRemove
80
93
  : LISTINGS_I18N_KEYS.cardFavoriteAdd
81
94
  );
95
+ // Saved is a SOLID accent shape, not-saved is the outline. `is_favorited:
96
+ // null` — an anonymous read's "nobody asked" — resolves to not-saved in the
97
+ // bag, so it draws the outline and never a third look of its own.
98
+ const icon = (
99
+ <HeartIcon
100
+ filled={favorite.favorited}
101
+ {...(favorite.favorited ? { color: token.colorPrimary } : {})}
102
+ />
103
+ );
82
104
  // The RESOLVED sentence, not the gate's key — `useActionGate` is the one
83
105
  // place a blocked reason becomes words in this fleet.
84
106
  const reason = useActionGate(favorite.gate).reason;
107
+ // A failed save, stated where the heart is. The rollback already put the
108
+ // icon back; this says why it went back.
109
+ const failure = (
110
+ <ErrorAlert
111
+ testId={`${props.testId}-error`}
112
+ thrown={favorite.error}
113
+ variant="inline"
114
+ />
115
+ );
85
116
  if (props.blockedReason === "popover" && reason !== undefined) {
86
- // `aria-disabled`, never `disabled`: the gate already refuses the action
87
- // (`toggle` is a no-op while blocked), and an html-disabled button
88
- // swallows the hover, the focus and the tap the disclosure opens on.
89
117
  return (
90
118
  <GateReasonPopover
91
119
  reason={reason}
@@ -105,7 +133,7 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
105
133
  data-analytics="none"
106
134
  data-analytics-reason="business action — host app wraps with its own tracked()"
107
135
  onClick={favorite.toggle}
108
- icon={<HeartIcon filled={favorite.favorited} />}
136
+ icon={icon}
109
137
  {...(props.style !== undefined ? { style: props.style } : {})}
110
138
  />
111
139
  )}
@@ -120,22 +148,22 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
120
148
  {...(props.style !== undefined ? { style: props.style } : {})}
121
149
  >
122
150
  {(bind) => (
123
- <Button
124
- shape="circle"
125
- disabled={bind.disabled}
126
- data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
127
- {...(bind["aria-describedby"] !== undefined
128
- ? { "aria-describedby": bind["aria-describedby"] }
129
- : {})}
130
- aria-label={label}
131
- aria-pressed={favorite.favorited}
132
- data-testid={props.testId}
133
- data-favorited={String(favorite.favorited)}
134
- data-analytics="none"
135
- data-analytics-reason="business action — host app wraps with its own tracked()"
136
- onClick={favorite.toggle}
137
- icon={<HeartIcon filled={favorite.favorited} />}
138
- />
151
+ <>
152
+ <Button
153
+ shape="circle"
154
+ // See `<ListingCard>`: the substrate's binding, spread whole.
155
+ {...bind}
156
+ aria-label={label}
157
+ aria-pressed={favorite.favorited}
158
+ data-testid={props.testId}
159
+ data-favorited={String(favorite.favorited)}
160
+ data-analytics="none"
161
+ data-analytics-reason="business action — host app wraps with its own tracked()"
162
+ onClick={favorite.toggle}
163
+ icon={icon}
164
+ />
165
+ {failure}
166
+ </>
139
167
  )}
140
168
  </GatedControl>
141
169
  );
@@ -13,14 +13,32 @@
13
13
  */
14
14
  import type { ReactElement } from "react";
15
15
 
16
- export function HeartIcon(props: { filled: boolean }): ReactElement {
16
+ export interface HeartIconProps {
17
+ /** Saved: the glyph is a solid shape rather than an outline. THE state a
18
+ * person reads off a card, so it is the one prop this icon has. */
19
+ readonly filled: boolean;
20
+ /**
21
+ * The accent a SAVED heart is painted in — an antd theme token the caller
22
+ * resolved (`token.colorPrimary`), never a literal.
23
+ *
24
+ * The icon stays `currentColor` by default, which is what keeps it
25
+ * theme-neutral in every other state; this is the one place a colour is
26
+ * warranted, because "filled" and "outline" of the same neutral grey is a
27
+ * difference a person reads at a glance on a mock-up and misses entirely on
28
+ * a 170px feed tile in daylight.
29
+ */
30
+ readonly color?: string;
31
+ }
32
+
33
+ export function HeartIcon(props: HeartIconProps): ReactElement {
34
+ const paint = props.color ?? "currentColor";
17
35
  return (
18
36
  <svg
19
37
  width="16"
20
38
  height="16"
21
39
  viewBox="0 0 24 24"
22
- fill={props.filled ? "currentColor" : "none"}
23
- stroke="currentColor"
40
+ fill={props.filled ? paint : "none"}
41
+ stroke={paint}
24
42
  strokeWidth="2"
25
43
  strokeLinecap="round"
26
44
  strokeLinejoin="round"
@@ -39,6 +39,10 @@ export {
39
39
  // threshold (and for a test that measures it). See `<ListingCard>`'s header.
40
40
  LISTING_CARD_ROW_MIN,
41
41
  LISTING_CARD_ROW_MEDIA,
42
+ // The already-seen mark, for a container that wants to dim its own chrome
43
+ // on the same rule (and for a test that reads it). All three cards take it.
44
+ CARD_VIEWED_CLASS,
45
+ LISTING_VIEWED_OPACITY,
42
46
  } from "./ListingCard.js";
43
47
  export type {
44
48
  ListingCardProps,
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The ENGAGEMENT OVERLAY as a scope: one request for a page of cards, read by
3
+ * every card in it without a single extra prop.
4
+ *
5
+ * ── The problem this shape exists to solve ────────────────────────────────
6
+ *
7
+ * `viewed`, `view_count` and `is_favorited` are on stapel-listings' own card
8
+ * and detail serializers, so a listing PAGE and this module's own card list
9
+ * carry them and need nothing here. The two surfaces a buyer actually
10
+ * scrolls do not: a storefront's home feed and its SERP are drawn by
11
+ * `@stapel/search-react` from the search index, whose stored document can
12
+ * hold neither a flag that differs per reader nor a counter that moves faster
13
+ * than a re-index. Those rows reach `<ListingCard>` with no engagement fields
14
+ * at all — so without this, every card on the two screens the feature was
15
+ * built for renders undimmed with an outline heart no matter what the person
16
+ * has done, and nothing anywhere reports a fault.
17
+ *
18
+ * ── Why a SCOPE and not a prop ────────────────────────────────────────────
19
+ *
20
+ * Because the cost is per PAGE and the reader is per CARD, and a design that
21
+ * puts those in the same place gets one of them wrong. A `engagement` prop on
22
+ * the card would make each card responsible for its own answer, and the
23
+ * shortest way to satisfy that is one request per card — forty requests for a
24
+ * decoration, which is the exact N+1 the batch endpoint exists to prevent.
25
+ * The container knows the page; the card knows its id. So the container opens
26
+ * a scope with the ids it just rendered, ONE query runs, and each card looks
27
+ * itself up. It is the arrangement `GateReasonScopeContext` / `<PaneGate>`
28
+ * already use in this fleet for the same shape of problem.
29
+ *
30
+ * ```tsx
31
+ * <ListingEngagementScope ids={items.map((item) => item.id)}>
32
+ * <FeedGrid>
33
+ * {items.map((item) => <ListingFeedCard key={item.id} listing={item.card} … />)}
34
+ * </FeedGrid>
35
+ * </ListingEngagementScope>
36
+ * ```
37
+ *
38
+ * ── No scope is a first-class answer ──────────────────────────────────────
39
+ *
40
+ * A card outside a scope fires NOTHING and draws from its row exactly as it
41
+ * did before this module existed — which is right for this module's own card
42
+ * list and for the listing page, whose rows already carry the fields. Absent,
43
+ * empty, in-flight and FAILED are all the same silent no-op: no dimming, no
44
+ * count, no banner over a grid that is otherwise working. A grid that renders
45
+ * is worth more than a flag.
46
+ */
47
+ import { createContext, useContext, useMemo } from "react";
48
+ import type { Context, ReactElement, ReactNode } from "react";
49
+ import { loadFailed, loadLoading, loadReady } from "@stapel/core";
50
+ import type { LoadState } from "@stapel/core";
51
+ import type {
52
+ ListingCard,
53
+ ListingEngagement,
54
+ ListingEngagementBatch,
55
+ } from "../api/types.js";
56
+ import { engagementIds } from "../api/types.js";
57
+ import { useListingEngagement } from "../model/queries.js";
58
+ import { engagementFor, withEngagement } from "../model/engagement.js";
59
+
60
+ /** What the scope publishes: the batch, however it is doing. */
61
+ export interface ListingEngagementBag {
62
+ /** This listing's entry, or `undefined` for an id the answer did not carry
63
+ * — which includes "the read has not landed" and "the read failed". */
64
+ overlayFor(id: number): ListingEngagement | undefined;
65
+ /** The read's own state, for a host that wants to know. NOTHING in this
66
+ * pair renders the failed arm: see the file header. */
67
+ readonly state: LoadState<ListingEngagementBatch>;
68
+ /** The normalized ids actually asked for — after the sort, the de-dupe and
69
+ * the server's own cap. A caller can compare it with what it handed in to
70
+ * see that a hundred-and-forty-card page was truncated. */
71
+ readonly ids: readonly number[];
72
+ }
73
+
74
+ const NO_OVERLAY: ListingEngagementBag = {
75
+ overlayFor: () => undefined,
76
+ state: loadLoading(),
77
+ ids: [],
78
+ };
79
+
80
+ /**
81
+ * The scope. `null` outside a provider — and `null` is not an error, it is a
82
+ * container that never opted in.
83
+ */
84
+ export const ListingEngagementContext: Context<ListingEngagementBag | null> =
85
+ createContext<ListingEngagementBag | null>(null);
86
+
87
+ export interface UseListingEngagementOverlayOptions {
88
+ /** Hold the request (a grid that has not resolved its rows yet). */
89
+ readonly enabled?: boolean;
90
+ }
91
+
92
+ /**
93
+ * Run the batch read for a page of ids and get the bag back.
94
+ *
95
+ * The headless half: a host rendering its own visuals over its own grid calls
96
+ * this directly. A host using this pair's cards wraps them in
97
+ * {@link ListingEngagementScope} instead, which calls this once and puts the
98
+ * bag where the cards can find it.
99
+ */
100
+ export function useListingEngagementOverlay(
101
+ ids: readonly number[],
102
+ options: UseListingEngagementOverlayOptions = {}
103
+ ): ListingEngagementBag {
104
+ const query = useListingEngagement(
105
+ ids,
106
+ options.enabled !== undefined ? { enabled: options.enabled } : {}
107
+ );
108
+ const wanted = useMemo(() => engagementIds(ids), [ids]);
109
+ const batch = query.data;
110
+ return useMemo(
111
+ () => ({
112
+ overlayFor: (id: number) => engagementFor(batch, id),
113
+ state:
114
+ query.status === "error"
115
+ ? loadFailed(query.error)
116
+ : batch !== undefined
117
+ ? loadReady(batch)
118
+ : loadLoading(),
119
+ ids: wanted,
120
+ }),
121
+ [batch, query.status, query.error, wanted]
122
+ );
123
+ }
124
+
125
+ export interface ListingEngagementScopeProps
126
+ extends UseListingEngagementOverlayOptions {
127
+ /** The listing ids on this page — normalized before the request, so the
128
+ * caller may hand them in any order and need not de-duplicate. */
129
+ readonly ids: readonly number[];
130
+ readonly children: ReactNode;
131
+ }
132
+
133
+ /**
134
+ * One request for the page, one lookup for every card inside it.
135
+ *
136
+ * Renderless: it draws no element of its own, so it can be wrapped around a
137
+ * grid without entering its layout.
138
+ */
139
+ export function ListingEngagementScope(
140
+ props: ListingEngagementScopeProps
141
+ ): ReactElement {
142
+ const bag = useListingEngagementOverlay(
143
+ props.ids,
144
+ props.enabled !== undefined ? { enabled: props.enabled } : {}
145
+ );
146
+ return (
147
+ <ListingEngagementContext.Provider value={bag}>
148
+ {props.children}
149
+ </ListingEngagementContext.Provider>
150
+ );
151
+ }
152
+
153
+ /** The scope's bag, or a permanently-empty one outside a scope. Never throws:
154
+ * a card must render everywhere, wired or not. */
155
+ export function useListingEngagementScope(): ListingEngagementBag {
156
+ return useContext(ListingEngagementContext) ?? NO_OVERLAY;
157
+ }
158
+
159
+ /** One listing's overlay entry, or `undefined`. */
160
+ export function useEngagementOverlay(
161
+ id: number
162
+ ): ListingEngagement | undefined {
163
+ return useListingEngagementScope().overlayFor(id);
164
+ }
165
+
166
+ /**
167
+ * A row with the scope's answer laid over it — what every card in this pair
168
+ * actually reads.
169
+ *
170
+ * Outside a scope, or for an id the batch did not carry, this returns the
171
+ * SAME object it was given, so a card that never opted in re-renders exactly
172
+ * as often as it used to.
173
+ */
174
+ export function useEngagedListing(row: ListingCard): ListingCard {
175
+ const overlay = useEngagementOverlay(row.id);
176
+ return useMemo(() => withEngagement(row, overlay), [row, overlay]);
177
+ }
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import type { ReactElement, ReactNode } from "react";
3
3
  import {
4
4
  actionAvailable,
@@ -27,7 +27,25 @@ import {
27
27
  */
28
28
 
29
29
  export interface FavoriteToggleBag {
30
+ /**
31
+ * What the heart must DRAW: the predicted state while a toggle is in
32
+ * flight, the row's own answer otherwise. Never `null` — see
33
+ * {@link FavoriteToggleBag.known} for the question `null` answers.
34
+ */
30
35
  readonly favorited: boolean;
36
+ /**
37
+ * Whether the row is AUTHORITATIVE about this reader's saved state.
38
+ *
39
+ * `is_favorited` is `boolean | null` and the null is not a `false`: it is
40
+ * "nobody asked on this person's behalf", which is what every anonymous
41
+ * read of a card list sends. Both draw the outline heart — an unsaved
42
+ * listing and an unasked one look the same, and a third mystery look would
43
+ * be a state nobody could name — but a caller deciding whether it may
44
+ * TRUST the row (a favourites count, a "you saved this" line) must be able
45
+ * to tell them apart, and squeezing that into the boolean is how it stops
46
+ * being tellable.
47
+ */
48
+ readonly known: boolean;
31
49
  /** Blocked with the reason for a visitor, a guest, an unknown mandate, or
32
50
  * while the previous toggle is still in flight. Never a hidden heart: a
33
51
  * control that disappears for a visitor teaches them nothing, and the CTA
@@ -38,6 +56,12 @@ export interface FavoriteToggleBag {
38
56
  readonly error: unknown;
39
57
  }
40
58
 
59
+ /** An outstanding prediction, tagged with the listing it is about. */
60
+ interface FavoritePrediction {
61
+ readonly id: number;
62
+ readonly value: boolean;
63
+ }
64
+
41
65
  /**
42
66
  * The heart on one card.
43
67
  *
@@ -46,6 +70,38 @@ export interface FavoriteToggleBag {
46
70
  * reader (`with_favorited` annotates `Value(None)`), which is a THIRD state
47
71
  * and not `false`: "we did not ask" versus "not saved". The gate is what
48
72
  * renders it, so the distinction never has to be squeezed into the boolean.
73
+ *
74
+ * ── The toggle is OPTIMISTIC, and this is the pair's only such write ───────
75
+ *
76
+ * Measured on a live deployment: a signed-in person taps the heart and
77
+ * nothing on screen moves. The write went out and the invalidation landed,
78
+ * but the row this hook was handed is a PROP — it belongs to a list query one
79
+ * or two components up, and until that query has refetched and re-rendered
80
+ * the grid, the icon is still drawing the state from before the tap. On a
81
+ * phone that is a second or more of a control that looks broken, and the
82
+ * usual response to a control that looks broken is to press it again.
83
+ *
84
+ * So the state the heart draws is the PREDICTION while the write is in
85
+ * flight, and the prediction is allowed here for the reason `mutations.ts`
86
+ * argues at its own hook: the truth is a single boolean the caller already
87
+ * holds, so guessing it is arithmetic rather than invention. Everything else
88
+ * in the pair moves a lifecycle whose next state is the server's to decide.
89
+ *
90
+ * Three ways the prediction ends, and all three are wired:
91
+ *
92
+ * - the write SUCCEEDS → the server's own `favorited` replaces it, so a
93
+ * server that disagreed with the guess wins;
94
+ * - the write FAILS → it rolls back to the state the tap started from,
95
+ * and `error` carries the failure for the surface to
96
+ * show. A heart that stayed filled over a save that
97
+ * never happened is worse than one that never moved;
98
+ * - the MINT fails → `elevation.run` never reaches the write, so there
99
+ * is nothing to roll back FROM; the prediction is
100
+ * dropped the moment the mint's failure appears.
101
+ *
102
+ * And it is tagged with the listing id, because a virtualised grid reuses a
103
+ * hook instance across rows: an untagged prediction would move one card's
104
+ * heart onto its neighbour on scroll.
49
105
  */
50
106
  export function useFavoriteToggle(
51
107
  id: number,
@@ -55,6 +111,24 @@ export function useFavoriteToggle(
55
111
  LISTINGS_ELEVATION_ACTIONS.favorite
56
112
  );
57
113
  const mutation = useFavoriteListing();
114
+ const [predicted, setPredicted] = useState<FavoritePrediction | undefined>(
115
+ undefined
116
+ );
117
+
118
+ const mintError = elevation.error;
119
+ useEffect(() => {
120
+ // The mint failed, so `run` never reached the write — the prediction has
121
+ // nothing behind it and no `onError` coming to retire it.
122
+ if (mintError !== undefined && mintError !== null) setPredicted(undefined);
123
+ }, [mintError]);
124
+
125
+ const mine = predicted !== undefined && predicted.id === id ? predicted : undefined;
126
+ const shown = mine !== undefined ? mine.value : favorited === true;
127
+ // The row caught up: the prediction has become an opinion about the past.
128
+ // Retiring it here (rather than in the success callback) is what stops a
129
+ // stale guess from shadowing a row that changed for some other reason.
130
+ if (mine !== undefined && favorited === mine.value) setPredicted(undefined);
131
+
58
132
  const gate = firstBlock(
59
133
  mandate,
60
134
  mutation.isPending || elevation.pending
@@ -62,14 +136,30 @@ export function useFavoriteToggle(
62
136
  : actionAvailable()
63
137
  );
64
138
  return {
65
- favorited: favorited === true,
139
+ favorited: shown,
140
+ known: favorited === true || favorited === false,
66
141
  gate,
67
142
  // On a host with auto-anonymous wired, the first heart an anonymous
68
143
  // visitor presses mints their account and then saves — one press, no
69
144
  // form, nothing said about it. Everywhere else `run` performs directly.
70
145
  toggle: () => {
71
146
  if (!gate.available) return;
72
- elevation.run(() => mutation.mutate({ id, favorited: favorited !== true }));
147
+ const next = !shown;
148
+ const before = shown;
149
+ setPredicted({ id, value: next });
150
+ elevation.run(() => {
151
+ mutation.mutate(
152
+ { id, favorited: next },
153
+ {
154
+ onSuccess: (data) => {
155
+ setPredicted({ id, value: data.favorited ?? next });
156
+ },
157
+ onError: () => {
158
+ setPredicted({ id, value: before });
159
+ },
160
+ }
161
+ );
162
+ });
73
163
  },
74
164
  inFlight: mutation.isPending || elevation.pending,
75
165
  error: mutation.error ?? elevation.error,
@@ -1,4 +1,4 @@
1
- import { useMemo } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import type { ReactElement, ReactNode } from "react";
3
3
  import {
4
4
  actionAvailable,
@@ -19,6 +19,7 @@ import type {
19
19
  import { useListing, useListingStatus } from "../model/queries.js";
20
20
  import { useFavoriteListing } from "../model/mutations.js";
21
21
  import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from "../model/features.js";
22
+ import { isListingViewed, listingViewCount } from "../model/engagement.js";
22
23
  import type { FeatureCopySource } from "../model/features.js";
23
24
  import { listingStatusView } from "../model/status.js";
24
25
  import type { ListingStatusView } from "../model/status.js";
@@ -104,12 +105,32 @@ export interface ListingDetailBag {
104
105
  * (`resolveImage`), because no contract in this fleet resolves a
105
106
  * stranger's reference — see `model/runtime.ts`. */
106
107
  readonly images: readonly string[];
107
- readonly isFavorited: boolean | undefined;
108
+ /**
109
+ * What the heart must DRAW — the prediction while a toggle is in flight,
110
+ * the row's own answer otherwise, and never `null`. See
111
+ * {@link ListingDetailBag.favoriteKnown} for the fact `null` carried.
112
+ */
113
+ readonly isFavorited: boolean;
114
+ /**
115
+ * Whether the row is AUTHORITATIVE about the reader's saved state.
116
+ * `is_favorited: null` is an anonymous read's "nobody asked", not a
117
+ * `false`; both draw the outline heart, and only this tells them apart.
118
+ */
119
+ readonly favoriteKnown: boolean;
108
120
  /** Saving a favourite needs a mandate; the block says which of the four
109
121
  * reasons applies. */
110
122
  readonly favoriteGate: ActionAvailability;
111
123
  toggleFavorite(): void;
112
124
  readonly favoriteInFlight: boolean;
125
+ /** The last favourite write's failure, for the surface to state. The heart
126
+ * has already rolled back by the time this is set. */
127
+ readonly favoriteError: unknown;
128
+ /** Has this reader already opened this listing? See `model/engagement.ts`
129
+ * — absent on every response today, and `false` when it is. */
130
+ readonly isViewed: boolean;
131
+ /** How many times it has been opened, or `undefined` where the server sent
132
+ * no number. Never a zero standing in for an absence. */
133
+ readonly viewCount: number | undefined;
113
134
  refetch(): void;
114
135
  }
115
136
 
@@ -220,7 +241,25 @@ export function useListingDetail(
220
241
  [detail.data, copy]
221
242
  );
222
243
 
223
- const isFavorited = detail.data?.is_favorited ?? undefined;
244
+ // The row's own answer, and the third state it is allowed to be in: `null`
245
+ // means the read was anonymous and nobody asked on this person's behalf.
246
+ const rowFavorited = detail.data?.is_favorited ?? undefined;
247
+ const [predicted, setPredicted] = useState<boolean | undefined>(undefined);
248
+
249
+ const mintError = elevation.error;
250
+ useEffect(() => {
251
+ // A failed mint never reached the write, so no `onError` is coming to
252
+ // retire the prediction — see `useFavoriteToggle` for the whole argument.
253
+ if (mintError !== undefined && mintError !== null) setPredicted(undefined);
254
+ }, [mintError]);
255
+
256
+ const isFavorited = predicted ?? rowFavorited === true;
257
+ // The detail refetch caught up; the prediction is now an opinion about the
258
+ // past and must stop shadowing a row that may have changed for some other
259
+ // reason.
260
+ if (predicted !== undefined && rowFavorited === predicted) {
261
+ setPredicted(undefined);
262
+ }
224
263
 
225
264
  const favoriteGate = firstBlock(
226
265
  mandate,
@@ -250,14 +289,35 @@ export function useListingDetail(
250
289
  ),
251
290
  images: detail.data?.images ?? NO_IMAGES,
252
291
  isFavorited,
292
+ favoriteKnown: rowFavorited === true || rowFavorited === false,
253
293
  favoriteGate,
254
294
  toggleFavorite: () => {
255
295
  if (!favoriteGate.available) return;
296
+ const next = !isFavorited;
297
+ const before = isFavorited;
298
+ // The icon flips on the gesture; the invalidation below is what makes
299
+ // it true, and the two callbacks are what make it honest if it is not.
300
+ setPredicted(next);
256
301
  // Mints the anonymous account first where the host permits it for this
257
302
  // action; a direct call everywhere else.
258
- elevation.run(() => favorite.mutate({ id, favorited: isFavorited !== true }));
303
+ elevation.run(() => {
304
+ favorite.mutate(
305
+ { id, favorited: next },
306
+ {
307
+ onSuccess: (data) => {
308
+ setPredicted(data.favorited ?? next);
309
+ },
310
+ onError: () => {
311
+ setPredicted(before);
312
+ },
313
+ }
314
+ );
315
+ });
259
316
  },
260
317
  favoriteInFlight: favorite.isPending || elevation.pending,
318
+ favoriteError: favorite.error ?? elevation.error,
319
+ isViewed: isListingViewed(detail.data),
320
+ viewCount: listingViewCount(detail.data),
261
321
  refetch: () => {
262
322
  void detail.refetch();
263
323
  void probe.refetch();