@stapel/listings-react 0.25.10 → 0.27.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 (144) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/MODULE.md +3 -1
  3. package/README.md +122 -9
  4. package/dist/api/generated/schema.d.ts +22 -12
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/CardBadges.d.ts.map +1 -1
  7. package/dist/default/CardBadges.js +3 -1
  8. package/dist/default/CardBadges.js.map +1 -1
  9. package/dist/default/ListingActions.d.ts +108 -0
  10. package/dist/default/ListingActions.d.ts.map +1 -0
  11. package/dist/default/ListingActions.js +29 -0
  12. package/dist/default/ListingActions.js.map +1 -0
  13. package/dist/default/ListingCard.d.ts.map +1 -1
  14. package/dist/default/ListingCard.js +15 -8
  15. package/dist/default/ListingCard.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +170 -62
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +284 -28
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/ListingFeedCard.d.ts.map +1 -1
  21. package/dist/default/ListingFeedCard.js +2 -1
  22. package/dist/default/ListingFeedCard.js.map +1 -1
  23. package/dist/default/ListingPhoto.d.ts.map +1 -1
  24. package/dist/default/ListingPhoto.js +5 -2
  25. package/dist/default/ListingPhoto.js.map +1 -1
  26. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  27. package/dist/default/ListingSerpCard.js +18 -4
  28. package/dist/default/ListingSerpCard.js.map +1 -1
  29. package/dist/default/MyListingsPane.d.ts.map +1 -1
  30. package/dist/default/MyListingsPane.js +49 -21
  31. package/dist/default/MyListingsPane.js.map +1 -1
  32. package/dist/default/ShareAction.d.ts +32 -0
  33. package/dist/default/ShareAction.d.ts.map +1 -0
  34. package/dist/default/ShareAction.js +116 -0
  35. package/dist/default/ShareAction.js.map +1 -0
  36. package/dist/default/actionRow.d.ts +58 -0
  37. package/dist/default/actionRow.d.ts.map +1 -0
  38. package/dist/default/actionRow.js +128 -0
  39. package/dist/default/actionRow.js.map +1 -0
  40. package/dist/default/cardGallery.d.ts +2 -0
  41. package/dist/default/cardGallery.d.ts.map +1 -1
  42. package/dist/default/cardGallery.js +31 -0
  43. package/dist/default/cardGallery.js.map +1 -1
  44. package/dist/default/detailGallery.d.ts +77 -0
  45. package/dist/default/detailGallery.d.ts.map +1 -0
  46. package/dist/default/detailGallery.js +91 -0
  47. package/dist/default/detailGallery.js.map +1 -0
  48. package/dist/default/favorite.d.ts +25 -0
  49. package/dist/default/favorite.d.ts.map +1 -1
  50. package/dist/default/favorite.js +29 -3
  51. package/dist/default/favorite.js.map +1 -1
  52. package/dist/default/icons.d.ts +19 -0
  53. package/dist/default/icons.d.ts.map +1 -1
  54. package/dist/default/icons.js +23 -0
  55. package/dist/default/icons.js.map +1 -1
  56. package/dist/default/index.d.ts +11 -2
  57. package/dist/default/index.d.ts.map +1 -1
  58. package/dist/default/index.js +10 -1
  59. package/dist/default/index.js.map +1 -1
  60. package/dist/default/movableCluster.d.ts +18 -0
  61. package/dist/default/movableCluster.d.ts.map +1 -0
  62. package/dist/default/movableCluster.js +110 -0
  63. package/dist/default/movableCluster.js.map +1 -0
  64. package/dist/default/notice.d.ts +14 -0
  65. package/dist/default/notice.d.ts.map +1 -0
  66. package/dist/default/notice.js +62 -0
  67. package/dist/default/notice.js.map +1 -0
  68. package/dist/headless/MyListings.d.ts +61 -19
  69. package/dist/headless/MyListings.d.ts.map +1 -1
  70. package/dist/headless/MyListings.js +58 -19
  71. package/dist/headless/MyListings.js.map +1 -1
  72. package/dist/headless/Share.d.ts +103 -0
  73. package/dist/headless/Share.d.ts.map +1 -0
  74. package/dist/headless/Share.js +190 -0
  75. package/dist/headless/Share.js.map +1 -0
  76. package/dist/i18n/es.d.ts.map +1 -1
  77. package/dist/i18n/es.js +21 -0
  78. package/dist/i18n/es.js.map +1 -1
  79. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  80. package/dist/i18n/generated/errors.es.gen.js +1 -1
  81. package/dist/i18n/generated/errors.gen.d.ts +18 -0
  82. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  83. package/dist/i18n/generated/errors.gen.js +9 -0
  84. package/dist/i18n/generated/errors.gen.js.map +1 -1
  85. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  86. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  87. package/dist/i18n/keys.d.ts +45 -0
  88. package/dist/i18n/keys.d.ts.map +1 -1
  89. package/dist/i18n/keys.js +60 -0
  90. package/dist/i18n/keys.js.map +1 -1
  91. package/dist/i18n/ru.d.ts.map +1 -1
  92. package/dist/i18n/ru.js +21 -0
  93. package/dist/i18n/ru.js.map +1 -1
  94. package/dist/index.d.ts +5 -3
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +2 -1
  97. package/dist/index.js.map +1 -1
  98. package/dist/model/cardBadges.d.ts +52 -3
  99. package/dist/model/cardBadges.d.ts.map +1 -1
  100. package/dist/model/cardBadges.js +74 -12
  101. package/dist/model/cardBadges.js.map +1 -1
  102. package/dist/model/mineSource.d.ts +9 -3
  103. package/dist/model/mineSource.d.ts.map +1 -1
  104. package/dist/model/mineSource.js.map +1 -1
  105. package/dist/model/status.d.ts +79 -15
  106. package/dist/model/status.d.ts.map +1 -1
  107. package/dist/model/status.js +86 -17
  108. package/dist/model/status.js.map +1 -1
  109. package/llms.txt +8 -3
  110. package/manifest.json +96 -4
  111. package/nav-manifest.json +1 -1
  112. package/package.json +10 -10
  113. package/src/analytics/generated/events.json +1 -1
  114. package/src/api/generated/schema.ts +22 -12
  115. package/src/default/CardBadges.tsx +3 -1
  116. package/src/default/ListingActions.tsx +196 -0
  117. package/src/default/ListingCard.tsx +79 -43
  118. package/src/default/ListingDetailPane.tsx +520 -89
  119. package/src/default/ListingFeedCard.tsx +2 -0
  120. package/src/default/ListingPhoto.tsx +28 -0
  121. package/src/default/ListingSerpCard.tsx +39 -12
  122. package/src/default/MyListingsPane.tsx +81 -44
  123. package/src/default/ShareAction.tsx +260 -0
  124. package/src/default/actionRow.ts +131 -0
  125. package/src/default/cardGallery.ts +31 -0
  126. package/src/default/detailGallery.ts +97 -0
  127. package/src/default/favorite.tsx +68 -1
  128. package/src/default/icons.tsx +60 -0
  129. package/src/default/index.ts +37 -1
  130. package/src/default/movableCluster.tsx +137 -0
  131. package/src/default/notice.ts +68 -0
  132. package/src/headless/MyListings.tsx +130 -39
  133. package/src/headless/Share.tsx +278 -0
  134. package/src/i18n/es.ts +28 -0
  135. package/src/i18n/generated/errors.es.gen.ts +1 -1
  136. package/src/i18n/generated/errors.gen.ts +9 -0
  137. package/src/i18n/generated/errors.json +31 -0
  138. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  139. package/src/i18n/keys.ts +63 -0
  140. package/src/i18n/ru.ts +28 -0
  141. package/src/index.ts +22 -0
  142. package/src/model/cardBadges.ts +124 -12
  143. package/src/model/mineSource.ts +9 -3
  144. package/src/model/status.ts +102 -20
@@ -49,7 +49,34 @@
49
49
  * as CategoryPage's `subcategories`: a decision taken once by the component
50
50
  * that knows the viewport it granted, never a media query guessed in a leaf —
51
51
  * and the default `"column"` renders exactly what existing hosts already get.
52
+ *
53
+ * ── The reader's cluster can be in TWO places, and is ONE thing ────────────
54
+ *
55
+ * A phone reads this page over four screens. Past the first, the reference
56
+ * classified draws a condensed bar — back, the title, and the two verbs — and
57
+ * a container building one had to mount a `<ListingActions>` of its own,
58
+ * because `actionsPlacement` took a single value and the pane exposed no
59
+ * target for the cluster it builds. That second mount is a second
60
+ * `useFavoriteToggle` on one page: two hearts that agree only after a refetch,
61
+ * two `aria-pressed` controls, and a second set of test ids kept in step by
62
+ * hand so the pane's own stayed single.
63
+ *
64
+ * `actionsPlacement={["header", "bar"]}` + `renderActionsBar` is the answer,
65
+ * and the shape is deliberate: the render prop is handed a MOUNT POINT, not
66
+ * the cluster. `<ListingActions>` is rendered once through a portal and the
67
+ * portal's container is moved between the two slots as a DOM node, so the
68
+ * component mounts once, holds one hook, and is literally the same element in
69
+ * both places (`movableCluster.tsx` has the argument; the test holds the
70
+ * favourite across the move and compares identity). A render prop handed the
71
+ * cluster's ELEMENT would have read the same at a call site and mounted twice,
72
+ * which is the defect with the pair's name on it.
73
+ *
74
+ * `onTitleVisible` is the other half: the same container watched the pane's
75
+ * `<h1>` through its published test id and a `MutationObserver`, for a
76
+ * boolean the pane already knows. It is an `IntersectionObserver` on the
77
+ * title, never a scroll listener.
52
78
  */
79
+ import { isValidElement, useCallback, useEffect, useRef } from "react";
53
80
  import type { ReactElement, ReactNode } from "react";
54
81
  import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
55
82
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
@@ -68,14 +95,26 @@ import {
68
95
  useT,
69
96
  } from "@stapel/core";
70
97
  import type { SignInCta } from "@stapel/core";
71
- import { spacing } from "@stapel/tokens";
98
+ import { cssVar, spacing } from "@stapel/tokens";
72
99
  import { isRedactedValue } from "@stapel/attributes-react";
73
100
  import { useListingDetail } from "../headless/ListingDetail.js";
74
101
  import { useListingActions } from "../headless/ListingActions.js";
75
102
  import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
76
103
  import { formatSpecValue } from "../model/featureText.js";
77
104
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
105
+ import type { ShareChannel } from "../headless/Share.js";
78
106
  import { GateReasonPopover } from "./GateReasonPopover.js";
107
+ import { ListingActions } from "./ListingActions.js";
108
+ import type { ListingActionsConfig } from "./ListingActions.js";
109
+ import { LISTING_ACTION_CLASS } from "./actionRow.js";
110
+ import {
111
+ LISTINGS_GALLERY_CLASS,
112
+ LISTINGS_GALLERY_STYLE_HREF,
113
+ detailGalleryCss,
114
+ } from "./detailGallery.js";
115
+ import type { ListingGalleryLayout } from "./detailGallery.js";
116
+ import { useMovableCluster } from "./movableCluster.js";
117
+ import { useNotice } from "./notice.js";
79
118
  import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
80
119
  import { SignInLink } from "./SignInLink.js";
81
120
  import { HeartIcon } from "./icons.js";
@@ -107,12 +146,31 @@ export const DETAIL_SPLIT_MEASURE = "75rem";
107
146
  */
108
147
  export const DETAIL_SPLIT_ASIDE = "380px";
109
148
 
110
- /** The narrowest a gallery tile may get before the grid drops a column. A
111
- * measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
112
- * which is §83's geometry rule — one photo per row on a phone, three on a
113
- * desktop pane, and no `width: 320` that is near-full-bleed on one and a
114
- * postage stamp on the other. */
115
- export const DETAIL_PHOTO_MIN = "14rem";
149
+ /** Re-exported: the tile floor is declared beside the track it feeds, in
150
+ * `detailGallery.ts`. */
151
+ export { DETAIL_PHOTO_MIN } from "./detailGallery.js";
152
+
153
+ /**
154
+ * THE GUTTER BETWEEN TWO PHOTOGRAPHS, and it is the page's own (D418).
155
+ *
156
+ * The gallery painted a flat `spacing[3]` — 12px on a 390px phone and 12px on
157
+ * a 1280px desktop — while the page around it had already decided that its
158
+ * edge is 4px on a phone and 24px on a desktop. Measured on the live listing:
159
+ * `getComputedStyle(gallery).gap` answered `12px` at both widths, so neither
160
+ * of the two declared numbers was ever on screen and the tiles sat closer
161
+ * together than the page edge on a desktop and three times further apart than
162
+ * it on a phone.
163
+ *
164
+ * `--stapel-page-gutter` is a RESPONSIVE token role (`@stapel/tokens`: 4px
165
+ * phone, 8px tablet, 24px desktop, declared once with its own media arms), and
166
+ * reading it as a VAR rather than computing a number is the load-bearing half:
167
+ * a value picked in JS is applied at render, so a window resized between
168
+ * renders keeps the gutter it was drawn with, where a var reflows. Written
169
+ * through `cssVar` so a renamed role fails to compile instead of silently
170
+ * resolving to nothing, with the flat value this grid used before as the
171
+ * fallback for a host that loads no token stylesheet.
172
+ */
173
+ export const DETAIL_GALLERY_GUTTER: string = `${cssVar("page-gutter").slice(0, -1)}, ${String(spacing[3])}px)`;
116
174
 
117
175
  export interface ListingDetailPaneProps
118
176
  extends ThemeModeProp,
@@ -184,10 +242,146 @@ export interface ListingDetailPaneProps
184
242
  /** Open the composer on this listing — the owner's primary. Absent is a real
185
243
  * answer: the button then states that this app has no editing screen. */
186
244
  readonly onEdit?: (id: number) => void;
187
- /** Extra chrome beside the primary (the seller's profile link, a share
188
- * button). Cross-pair navigation is the container's job (spec §6.2 item 5),
189
- * so this pair takes nodes rather than routes. */
190
- readonly actions?: ReactNode;
245
+ /**
246
+ * TWO THINGS UNDER ONE NAME, and the type tells them apart.
247
+ *
248
+ * - a NODE — extra chrome beside the primary (the seller's profile link, a
249
+ * control from another pair). What this prop has always been, unchanged,
250
+ * and still rendered at the end of the buy box. Cross-pair navigation is
251
+ * the container's job (spec §6.2 item 5), so this pair takes nodes
252
+ * rather than routes.
253
+ * - a CONFIG — `{ share: false }` / `{ favorite: false }`, switching off
254
+ * one of the page's own two reader actions.
255
+ *
256
+ * A union rather than a second prop, because they are the same question
257
+ * ("what is in the action row") asked from two sides, and it is
258
+ * unambiguous at runtime: a plain object that is not a React element was
259
+ * never a legal `ReactNode` in the first place — React refuses to render
260
+ * one — so `{ share: false }` cannot be a node that somebody meant.
261
+ */
262
+ readonly actions?: ReactNode | ListingActionsConfig;
263
+ /**
264
+ * The listing's CANONICAL address, for the share sheet — the route the
265
+ * container built (`/l/7`, or an absolute URL), not the address bar.
266
+ *
267
+ * Absent, sharing falls back to `window.location.href`, which is honest for
268
+ * a bare mount and wrong for a real app: the address a visitor is standing
269
+ * on carries the SERP query they arrived from, the page anchor and whatever
270
+ * tracking parameters came with them, and none of that belongs in a link
271
+ * somebody sends to a friend. See `useShare`.
272
+ */
273
+ readonly shareUrl?: string;
274
+ /** Analytics: which channel a completed share went through. */
275
+ readonly onShared?: (channel: ShareChannel) => void;
276
+ /**
277
+ * How many people saved this listing, when the host was told by something
278
+ * else. The listings wire carries `is_favorited` — a per-reader boolean —
279
+ * and no aggregate at all, so this pair never invents the number and never
280
+ * draws a zero in place of "nobody counted".
281
+ */
282
+ readonly favoriteCount?: number;
283
+ /**
284
+ * WHERE THE READER'S TWO ACTIONS SIT.
285
+ *
286
+ * - `"header"` (default) — beside the title, at the trailing edge of the
287
+ * heading row, which is where the reference classified puts them and
288
+ * where a person looks for them on both a phone and a desktop;
289
+ * - `"gallery"` — pinned over the photographs' trailing top corner, for a
290
+ * phone-first host that wants them on the picture. The corner is chosen
291
+ * rather than free: the dots own the bottom centre of the strip and the
292
+ * photo counter owns the bottom trailing corner;
293
+ * - `"buy-box"` — inside `listings-detail-actions`, beside "message the
294
+ * seller", which is where the favourite alone used to live. The escape
295
+ * hatch for a host whose page was laid out around it;
296
+ * - `"bar"` — the condensed top bar the host draws through
297
+ * {@link renderActionsBar}. Only meaningful in a LIST beside one of the
298
+ * three above, and only with that render prop: it names a second place
299
+ * the one cluster may travel to, never a home of its own.
300
+ *
301
+ * A LIST is how a host says "both": `["header", "bar"]` keeps the cluster
302
+ * beside the title and lends it to the bar for as long as the bar is on
303
+ * screen. Exactly one home placement is honoured — the first non-`"bar"`
304
+ * entry — because two homes would need two instances, which is the defect
305
+ * this closes rather than the feature it adds.
306
+ */
307
+ readonly actionsPlacement?:
308
+ | ListingActionsPlacement
309
+ | readonly ListingActionsPlacement[];
310
+ /**
311
+ * THE SAME CLUSTER, IN A SECOND PLACE — a condensed bar, typically.
312
+ *
313
+ * Called with the bar's MOUNT POINT, not with a copy of the cluster: return
314
+ * it wrapped in whatever chrome the bar is (`position: fixed`, a back arrow,
315
+ * the title), and the pane moves its one `<ListingActions>` into it. Return
316
+ * `null` while the bar is not on screen and the cluster goes back where it
317
+ * came from — the same DOM node, the same hooks, an optimistic favourite
318
+ * still in flight uninterrupted.
319
+ *
320
+ * Requires `"bar"` in {@link actionsPlacement}. Pair it with
321
+ * {@link onTitleVisible} for the usual rule: the bar appears when the title
322
+ * leaves the fold.
323
+ *
324
+ * ```tsx
325
+ * <ListingDetailPane
326
+ * actionsPlacement={["header", "bar"]}
327
+ * onTitleVisible={(visible) => { setBarShown(!visible); }}
328
+ * renderActionsBar={(cluster) =>
329
+ * barShown ? <div className="topbar">{back}{title}{cluster}</div> : null
330
+ * }
331
+ * />
332
+ * ```
333
+ *
334
+ * A container that mounted its own second `<ListingActions>` for this can
335
+ * delete it: two `useFavoriteToggle` instances on one page, two hearts that
336
+ * agree only after a refetch, and a second set of test ids to keep the
337
+ * pane's own single are all what this prop exists to end.
338
+ */
339
+ readonly renderActionsBar?: (cluster: ReactNode) => ReactNode;
340
+ /**
341
+ * IS THE TITLE STILL IN THE FOLD?
342
+ *
343
+ * An `IntersectionObserver` on the pane's own `<h1>` — never a `scroll`
344
+ * listener, which asks the question on every frame of a page whose job is
345
+ * scrolling photographs and answers it no better. Called on each crossing
346
+ * and not on every scroll: `false` when the title leaves, `true` when it
347
+ * comes back.
348
+ *
349
+ * It exists because the chrome a host hangs on this had no way to ask. A
350
+ * container drawing a condensed bar found the title by the pane's published
351
+ * `data-testid` and waited for it with a `MutationObserver`, because the
352
+ * title lands with the listing and not with the first frame — a private
353
+ * selector and a subscription, for a boolean the pane already knows.
354
+ *
355
+ * NOT called at all where the environment has no `IntersectionObserver`:
356
+ * the honest answer there is "this page cannot tell", and a fabricated
357
+ * `true` would leave a host's bar wedged open on the arm that has no
358
+ * scrolling anyway.
359
+ *
360
+ * Pass a STABLE function (a `useState` setter, a `useCallback`). The
361
+ * observer is created once for the title node and reads the latest callback
362
+ * through a ref, so an inline arrow works and does not re-observe.
363
+ */
364
+ readonly onTitleVisible?: (visible: boolean) => void;
365
+ /**
366
+ * WHAT SHAPE THE PHOTOGRAPHS ARE IN.
367
+ *
368
+ * - `"grid"` (default) — the element-width grid this pane has always drawn,
369
+ * `repeat(auto-fit, minmax(14rem, 1fr))`: three tiles across a desktop
370
+ * pane, one across a phone;
371
+ * - `"strip"` — a snap-scrolling horizontal strip, one photograph visible
372
+ * with the next peeking. On a 390px phone the grid resolves to one
373
+ * column, so a listing with three pictures pushes its own title and price
374
+ * nearly three screens down — the first thing a person sees after tapping
375
+ * a search result is a photograph with nothing beside it.
376
+ *
377
+ * The HOST names it, the same rule as {@link layout} and for the same
378
+ * reason: the side that knows the viewport it granted decides, and no media
379
+ * query is guessed in a leaf. A live storefront was carrying
380
+ * `display: flex !important` against this pane's inline `display: grid` to
381
+ * say exactly this; that declaration is a class now, so even a host wanting
382
+ * a third shape needs a selector rather than an `!important`.
383
+ */
384
+ readonly galleryLayout?: ListingGalleryLayout;
191
385
  /**
192
386
  * The container's sign-in door, rendered beside the favourite's refusal —
193
387
  * the same `SignInCta` seam the three card skins already take. The pane was
@@ -221,8 +415,104 @@ export interface ListingDetailPaneProps
221
415
  readonly footer?: ReactNode;
222
416
  }
223
417
 
418
+ /**
419
+ * Where the reader's cluster may sit. Three homes and one loan — see
420
+ * {@link ListingDetailPaneProps.actionsPlacement}.
421
+ */
422
+ export type ListingActionsPlacement =
423
+ | "header"
424
+ | "gallery"
425
+ | "buy-box"
426
+ | "bar";
427
+
428
+ /** The cluster's HOME: the first entry that is not the borrowed bar. */
429
+ function homePlacement(
430
+ placement: ListingDetailPaneProps["actionsPlacement"]
431
+ ): Exclude<ListingActionsPlacement, "bar"> {
432
+ if (placement === undefined) return "header";
433
+ if (typeof placement === "string") {
434
+ // `"bar"` alone names no home — the cluster still has to live somewhere
435
+ // while the bar is off screen, and that somewhere is the default.
436
+ return placement === "bar" ? "header" : placement;
437
+ }
438
+ for (const one of placement) {
439
+ if (one !== "bar") return one;
440
+ }
441
+ return "header";
442
+ }
443
+
444
+ /** Did the host ask for the borrowed placement at all? */
445
+ function wantsBar(
446
+ placement: ListingDetailPaneProps["actionsPlacement"]
447
+ ): boolean {
448
+ if (placement === undefined) return false;
449
+ if (typeof placement === "string") return placement === "bar";
450
+ return placement.includes("bar");
451
+ }
452
+
453
+ /** Priorities for the two mount points: the bar wins while it is on screen. */
454
+ const CLUSTER_HOME = 0;
455
+ const CLUSTER_BAR = 1;
456
+
457
+ /**
458
+ * The pane's own title, watched — see
459
+ * {@link ListingDetailPaneProps.onTitleVisible}.
460
+ *
461
+ * Returns a callback ref for the heading element. The observer is created once
462
+ * per node and disconnected by React 19's ref cleanup; the host's callback is
463
+ * read through a ref at call time, so an inline arrow does not re-observe on
464
+ * every render of a page that re-renders on every query update.
465
+ */
466
+ function useTitleVisibility(
467
+ onTitleVisible: ((visible: boolean) => void) | undefined
468
+ ): (node: HTMLElement | null) => (() => void) | undefined {
469
+ const latest = useRef(onTitleVisible);
470
+ useEffect(() => {
471
+ latest.current = onTitleVisible;
472
+ });
473
+ const wanted = onTitleVisible !== undefined;
474
+ return useCallback(
475
+ (node: HTMLElement | null): (() => void) | undefined => {
476
+ if (node === null || !wanted) return undefined;
477
+ // No observer, no answer. A fabricated `true` would wedge a host's bar
478
+ // open on an arm that has no scrolling to close it with.
479
+ if (typeof IntersectionObserver === "undefined") return undefined;
480
+ const observer = new IntersectionObserver((entries) => {
481
+ const entry = entries[entries.length - 1];
482
+ if (entry === undefined) return;
483
+ latest.current?.(entry.isIntersecting);
484
+ });
485
+ observer.observe(node);
486
+ return () => {
487
+ observer.disconnect();
488
+ };
489
+ },
490
+ [wanted]
491
+ );
492
+ }
493
+
494
+ /**
495
+ * Which arm of `actions` this is.
496
+ *
497
+ * A plain object that is not a React element and not an array was never a
498
+ * legal `ReactNode` — React throws on rendering one — so there is no value a
499
+ * caller could have meant as chrome that lands here. `null` and `undefined`
500
+ * are nodes (the empty ones) and stay on the node side.
501
+ */
502
+ function isActionsConfig(
503
+ value: ReactNode | ListingActionsConfig
504
+ ): value is ListingActionsConfig {
505
+ return (
506
+ typeof value === "object" &&
507
+ value !== null &&
508
+ !Array.isArray(value) &&
509
+ !isValidElement(value)
510
+ );
511
+ }
512
+
224
513
  export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
225
514
  const t = useT();
515
+ const notice = useNotice();
226
516
  const { locale } = useI18n();
227
517
  const { token } = antdTheme.useToken();
228
518
  const bag = useListingDetail(props.id, {
@@ -238,6 +528,27 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
238
528
  // leaves rendering the reason to `<GatedControl>`, which computes its own.
239
529
  const favoriteView = useActionGate(bag.favoriteGate);
240
530
  const split = props.layout === "split";
531
+ const placement = homePlacement(props.actionsPlacement);
532
+ /* THE SECOND PLACEMENT IS A LOAN, NOT A COPY. Both halves have to be asked
533
+ for: `"bar"` in the placement list says the cluster may travel, and
534
+ `renderActionsBar` is the only thing that can put it anywhere. With
535
+ neither — every existing mount — nothing below changes: one cluster,
536
+ rendered inline where it always was, no portal and no slot divs. */
537
+ const galleryLayout: ListingGalleryLayout = props.galleryLayout ?? "grid";
538
+ const renderBar = props.renderActionsBar;
539
+ const barred = wantsBar(props.actionsPlacement) && renderBar !== undefined;
540
+ const movable = useMovableCluster(barred);
541
+ const moving = barred && movable.portable;
542
+ const titleRef = useTitleVisibility(props.onTitleVisible);
543
+ // The two arms of `actions` — see `isActionsConfig`.
544
+ const actionsConfig: ListingActionsConfig | undefined = isActionsConfig(
545
+ props.actions
546
+ )
547
+ ? props.actions
548
+ : undefined;
549
+ const actionsNode: ReactNode = isActionsConfig(props.actions)
550
+ ? null
551
+ : props.actions;
241
552
 
242
553
  const favoriteLabel = t(
243
554
  bag.isFavorited
@@ -253,6 +564,29 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
253
564
  {...(bag.isFavorited ? { color: token.colorPrimary } : {})}
254
565
  />
255
566
  );
567
+ /**
568
+ * The press, and the sentence it earns.
569
+ *
570
+ * The heart on this page is icon-only (§23: the reference draws a 44×44
571
+ * glyph where this pane drew a 152px button with a word in it), so the
572
+ * only thing a person reads back off the gesture is a fill changing colour
573
+ * in the corner of a row. That is enough to SEE and not enough to be sure
574
+ * of, which is what the toast is for — raised from the state the icon is
575
+ * about to draw, so both arrive together. A write that then fails rolls the
576
+ * icon back and says so through `listings-detail-favorite-error`.
577
+ */
578
+ const pressFavorite = (): void => {
579
+ const next = !bag.isFavorited;
580
+ bag.toggleFavorite();
581
+ if (!bag.favoriteGate.available) return;
582
+ notice(
583
+ t(
584
+ next
585
+ ? LISTINGS_I18N_KEYS.favoriteAdded
586
+ : LISTINGS_I18N_KEYS.favoriteRemoved
587
+ )
588
+ );
589
+ };
256
590
 
257
591
  return (
258
592
  <SkinTheme
@@ -338,17 +672,162 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
338
672
  </>
339
673
  );
340
674
 
675
+ /* THE READER'S TWO ACTIONS, as one cluster — see
676
+ `<ListingActions>` and `actionsPlacement`.
677
+
678
+ The heart is handed IN rather than mounted by the cluster:
679
+ this page's favourite is driven by `useListingDetail`'s own
680
+ optimistic bag (it holds the whole listing and flips the row it
681
+ already has), not by `useFavoriteToggle` against a card row.
682
+ One control, two hooks, one geometry.
683
+
684
+ The OWNER gets the share button and no heart: favouriting your
685
+ own listing is not a thing anyone does, and sending somebody
686
+ your own listing is the first thing a seller does. */
687
+ const favoriteControl =
688
+ owner ? null : props.blockedReason === "popover" &&
689
+ favoriteView.reason !== undefined ? (
690
+ /* The cards' third volume, verbatim: nothing standing, the
691
+ reason and the door disclosed on the heart. `aria-disabled`
692
+ rather than `disabled`, so the disclosure's hover, focus
693
+ and tap all arrive — and the click is a safe no-op, because
694
+ `toggleFavorite` refuses while the gate is blocked. */
695
+ <GateReasonPopover
696
+ reason={favoriteView.reason}
697
+ cta={props.signIn}
698
+ testId="listings-detail-favorite-reason"
699
+ signInTestId="listings-detail-sign-in"
700
+ >
701
+ {(bind) => (
702
+ <Button
703
+ shape="circle"
704
+ aria-disabled
705
+ {...bind}
706
+ className={LISTING_ACTION_CLASS}
707
+ aria-label={favoriteLabel}
708
+ aria-pressed={bag.isFavorited}
709
+ icon={heartIcon}
710
+ data-testid="listings-detail-favorite"
711
+ data-favorited={String(bag.isFavorited)}
712
+ data-analytics="none"
713
+ data-analytics-reason="business action — host app wraps with its own tracked()"
714
+ onClick={pressFavorite}
715
+ />
716
+ )}
717
+ </GateReasonPopover>
718
+ ) : (
719
+ <Flex vertical gap={spacing[1]}>
720
+ <GatedControl
721
+ gate={bag.favoriteGate}
722
+ testId="listings-detail-favorite-gate"
723
+ >
724
+ {(bind) => (
725
+ <Button
726
+ shape="circle"
727
+ // See `<ListingCard>`: the binding, spread whole.
728
+ {...bind}
729
+ className={LISTING_ACTION_CLASS}
730
+ aria-label={favoriteLabel}
731
+ aria-pressed={bag.isFavorited}
732
+ icon={heartIcon}
733
+ data-testid="listings-detail-favorite"
734
+ data-favorited={String(bag.isFavorited)}
735
+ data-analytics="none"
736
+ data-analytics-reason="business action — host app wraps with its own tracked()"
737
+ onClick={pressFavorite}
738
+ />
739
+ )}
740
+ </GatedControl>
741
+ {/* The door. `GatedControl` prints the reason; where a
742
+ visitor signs in is the container's, and arrives as
743
+ `signIn` — the cards' own pattern, verbatim. */}
744
+ {bag.favoriteGate.available ? null : (
745
+ <Typography.Text
746
+ type="secondary"
747
+ data-testid="listings-detail-favorite-blocked"
748
+ >
749
+ <SignInLink cta={props.signIn} testId="listings-detail-sign-in" />
750
+ </Typography.Text>
751
+ )}
752
+ </Flex>
753
+ );
754
+
755
+ const readerActions = (
756
+ <ListingActions
757
+ listingId={props.id}
758
+ favorite={favoriteControl}
759
+ testId="listings-detail-reader-actions"
760
+ placement={placement === "gallery" ? "overlay" : "inline"}
761
+ actions={{
762
+ ...actionsConfig,
763
+ // The owner keeps the share button and loses the heart.
764
+ ...(owner ? { favorite: false } : {}),
765
+ }}
766
+ {...(props.favoriteCount !== undefined && !owner
767
+ ? { favoriteCount: props.favoriteCount }
768
+ : {})}
769
+ {...(props.shareUrl !== undefined ? { shareUrl: props.shareUrl } : {})}
770
+ {...(listing.title !== undefined && listing.title !== null
771
+ ? { shareTitle: listing.title }
772
+ : {})}
773
+ {...(props.onShared !== undefined ? { onShared: props.onShared } : {})}
774
+ />
775
+ );
776
+
777
+ /* What the PAGE draws where the cluster lives. With the bar in
778
+ play that is a slot and not the cluster itself: the cluster is
779
+ rendered once into `clusterLayer` below and travels between the
780
+ two slots as a DOM node, so it mounts once, holds one
781
+ `useFavoriteToggle`, and keeps its element identity across the
782
+ move. Without it, the cluster is drawn inline exactly as it has
783
+ always been. */
784
+ const homeActions = moving
785
+ ? movable.slot(CLUSTER_HOME, placement)
786
+ : readerActions;
787
+
788
+ /* The one instance, plus the host's bar around the slot that may
789
+ borrow it. Rendered at the end of the page's own flow: the
790
+ portal has no position of its own (its content is wherever the
791
+ winning slot is), and a bar is `position: fixed` chrome whose
792
+ place in the document order is not its place on the screen. */
793
+ const clusterLayer =
794
+ !moving || renderBar === undefined ? null : (
795
+ <>
796
+ {movable.render(readerActions)}
797
+ {/* NOT a slot with a silent absence: `renderBar` is the only
798
+ thing that makes `moving` true, so this arm is
799
+ unreachable without one and the host's own `null` (the
800
+ bar off screen) is the answer that sends the cluster
801
+ home. There is no hole to place a `<SlotPlaceholder>` in
802
+ — the cluster is at its primary placement instead. */}
803
+ {renderBar(movable.slot(CLUSTER_BAR, "bar"))}
804
+ </>
805
+ );
806
+
341
807
  /* Element-width tiles: the grid decides how many fit, the
342
808
  photos fill them. */
343
809
  const gallery = (
344
810
  <div
345
811
  data-testid="listings-detail-gallery"
812
+ className={LISTINGS_GALLERY_CLASS}
813
+ // The layout is a CLASS and an attribute, not an inline
814
+ // `display`: a host with a shape neither arm offers can then
815
+ // write CSS for it at its own breakpoints without `!important`
816
+ // over a pair's own geometry. See `detailGallery.ts`.
817
+ data-gallery-layout={galleryLayout}
346
818
  style={{
347
- display: "grid",
348
- gridTemplateColumns: `repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr))`,
349
- gap: spacing[3],
819
+ // The page's own edge, per breakpoint — see
820
+ // `DETAIL_GALLERY_GUTTER` (D418).
821
+ gap: DETAIL_GALLERY_GUTTER,
822
+ // The containing block the overlay arm is pinned to. A
823
+ // `relative` with no offsets moves no pixel of what is
824
+ // already in it — the same trick `cardGalleryCss` uses.
825
+ position: "relative",
350
826
  }}
351
827
  >
828
+ <style href={LISTINGS_GALLERY_STYLE_HREF} precedence="default">
829
+ {detailGalleryCss()}
830
+ </style>
352
831
  {bag.images.length === 0 ? (
353
832
  <ListingPhoto
354
833
  imageRef={undefined}
@@ -366,17 +845,31 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
366
845
  />
367
846
  ))
368
847
  )}
848
+ {placement === "gallery" ? homeActions : null}
369
849
  </div>
370
850
  );
371
851
 
372
852
  const heading = (
373
853
  <>
374
- <Typography.Title
375
- level={props.headingLevel ?? 3}
376
- data-testid="listings-detail-title"
377
- >
378
- {listing.title ?? ""}
379
- </Typography.Title>
854
+ {/* THE TITLE AND THE TWO ACTIONS SHARE A LINE (§23).
855
+ The reference classified puts save-and-share at the
856
+ trailing edge of the heading, on a phone and on a desktop
857
+ alike, and that is the only place on this page where a
858
+ person looks for them. `align-items:flex-start` so a
859
+ two-line title does not drag the glyphs down its second
860
+ line; `minWidth:0` so a long unbroken word wraps instead
861
+ of pushing them off the pane. */}
862
+ <Flex align="flex-start" justify="space-between" gap={spacing[3]}>
863
+ <Typography.Title
864
+ level={props.headingLevel ?? 3}
865
+ ref={titleRef}
866
+ data-testid="listings-detail-title"
867
+ style={{ minWidth: 0, flex: "1 1 auto" }}
868
+ >
869
+ {listing.title ?? ""}
870
+ </Typography.Title>
871
+ {placement === "header" ? homeActions : null}
872
+ </Flex>
380
873
 
381
874
  {/* The `show_at_title` projection, formatted from the stored
382
875
  DAOs — no category read needed (see model/features.ts). */}
@@ -487,76 +980,12 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
487
980
  </div>
488
981
  )}
489
982
 
490
- {/* Favouriting your own listing is not a thing anyone does;
491
- for everyone else it is the secondary it always was. */}
492
- {owner ? null : props.blockedReason === "popover" &&
493
- favoriteView.reason !== undefined ? (
494
- /* The cards' third volume, verbatim: nothing standing, the
495
- reason and the door disclosed on the heart. `aria-disabled`
496
- rather than `disabled`, so the disclosure's hover, focus
497
- and tap all arrive — and the click is a safe no-op, because
498
- `toggleFavorite` refuses while the gate is blocked. */
499
- <GateReasonPopover
500
- reason={favoriteView.reason}
501
- cta={props.signIn}
502
- testId="listings-detail-favorite-reason"
503
- signInTestId="listings-detail-sign-in"
504
- >
505
- {(bind) => (
506
- <Button
507
- aria-disabled
508
- {...bind}
509
- aria-label={favoriteLabel}
510
- aria-pressed={bag.isFavorited}
511
- icon={heartIcon}
512
- data-testid="listings-detail-favorite"
513
- data-favorited={String(bag.isFavorited)}
514
- data-analytics="none"
515
- data-analytics-reason="business action — host app wraps with its own tracked()"
516
- onClick={bag.toggleFavorite}
517
- >
518
- {favoriteLabel}
519
- </Button>
520
- )}
521
- </GateReasonPopover>
522
- ) : (
523
- <Flex vertical gap={spacing[1]}>
524
- <GatedControl
525
- gate={bag.favoriteGate}
526
- testId="listings-detail-favorite-gate"
527
- >
528
- {(bind) => (
529
- <Button
530
- // See `<ListingCard>`: the binding, spread whole.
531
- {...bind}
532
- aria-label={favoriteLabel}
533
- aria-pressed={bag.isFavorited}
534
- icon={heartIcon}
535
- data-testid="listings-detail-favorite"
536
- data-favorited={String(bag.isFavorited)}
537
- data-analytics="none"
538
- data-analytics-reason="business action — host app wraps with its own tracked()"
539
- onClick={bag.toggleFavorite}
540
- >
541
- {favoriteLabel}
542
- </Button>
543
- )}
544
- </GatedControl>
545
- {/* The door. `GatedControl` prints the reason; where a
546
- visitor signs in is the container's, and arrives as
547
- `signIn` — the cards' own pattern, verbatim. */}
548
- {bag.favoriteGate.available ? null : (
549
- <Typography.Text
550
- type="secondary"
551
- data-testid="listings-detail-favorite-blocked"
552
- >
553
- <SignInLink cta={props.signIn} testId="listings-detail-sign-in" />
554
- </Typography.Text>
555
- )}
556
- </Flex>
557
- )}
983
+ {/* The reader's two actions live in the cluster now (see
984
+ `actionsPlacement`); the buy box keeps them only when a
985
+ host asks for the layout this page used to have. */}
986
+ {placement === "buy-box" ? homeActions : null}
558
987
 
559
- {props.actions}
988
+ {actionsNode}
560
989
  </Flex>
561
990
  );
562
991
 
@@ -701,6 +1130,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
701
1130
  {meta}
702
1131
  {asideAfterActions ? null : aside}
703
1132
  {props.footer}
1133
+ {clusterLayer}
704
1134
  </>
705
1135
  );
706
1136
  }
@@ -751,6 +1181,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
751
1181
  {aside}
752
1182
  </Flex>
753
1183
  </div>
1184
+ {clusterLayer}
754
1185
  </>
755
1186
  );
756
1187
  },