@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.
- package/CHANGELOG.md +259 -0
- package/MODULE.md +3 -1
- package/README.md +122 -9
- package/dist/api/generated/schema.d.ts +22 -12
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +3 -1
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingActions.d.ts +108 -0
- package/dist/default/ListingActions.d.ts.map +1 -0
- package/dist/default/ListingActions.js +29 -0
- package/dist/default/ListingActions.js.map +1 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +15 -8
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +170 -62
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +284 -28
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts.map +1 -1
- package/dist/default/ListingFeedCard.js +2 -1
- package/dist/default/ListingFeedCard.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +5 -2
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +18 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +49 -21
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/ShareAction.d.ts +32 -0
- package/dist/default/ShareAction.d.ts.map +1 -0
- package/dist/default/ShareAction.js +116 -0
- package/dist/default/ShareAction.js.map +1 -0
- package/dist/default/actionRow.d.ts +58 -0
- package/dist/default/actionRow.d.ts.map +1 -0
- package/dist/default/actionRow.js +128 -0
- package/dist/default/actionRow.js.map +1 -0
- package/dist/default/cardGallery.d.ts +2 -0
- package/dist/default/cardGallery.d.ts.map +1 -1
- package/dist/default/cardGallery.js +31 -0
- package/dist/default/cardGallery.js.map +1 -1
- package/dist/default/detailGallery.d.ts +77 -0
- package/dist/default/detailGallery.d.ts.map +1 -0
- package/dist/default/detailGallery.js +91 -0
- package/dist/default/detailGallery.js.map +1 -0
- package/dist/default/favorite.d.ts +25 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +29 -3
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/icons.d.ts +19 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +23 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +11 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +10 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/movableCluster.d.ts +18 -0
- package/dist/default/movableCluster.d.ts.map +1 -0
- package/dist/default/movableCluster.js +110 -0
- package/dist/default/movableCluster.js.map +1 -0
- package/dist/default/notice.d.ts +14 -0
- package/dist/default/notice.d.ts.map +1 -0
- package/dist/default/notice.js +62 -0
- package/dist/default/notice.js.map +1 -0
- package/dist/headless/MyListings.d.ts +61 -19
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +58 -19
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/headless/Share.d.ts +103 -0
- package/dist/headless/Share.d.ts.map +1 -0
- package/dist/headless/Share.js +190 -0
- package/dist/headless/Share.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +21 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +18 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +9 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +45 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +60 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +52 -3
- package/dist/model/cardBadges.d.ts.map +1 -1
- package/dist/model/cardBadges.js +74 -12
- package/dist/model/cardBadges.js.map +1 -1
- package/dist/model/mineSource.d.ts +9 -3
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/status.d.ts +79 -15
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +86 -17
- package/dist/model/status.js.map +1 -1
- package/llms.txt +8 -3
- package/manifest.json +96 -4
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +22 -12
- package/src/default/CardBadges.tsx +3 -1
- package/src/default/ListingActions.tsx +196 -0
- package/src/default/ListingCard.tsx +79 -43
- package/src/default/ListingDetailPane.tsx +520 -89
- package/src/default/ListingFeedCard.tsx +2 -0
- package/src/default/ListingPhoto.tsx +28 -0
- package/src/default/ListingSerpCard.tsx +39 -12
- package/src/default/MyListingsPane.tsx +81 -44
- package/src/default/ShareAction.tsx +260 -0
- package/src/default/actionRow.ts +131 -0
- package/src/default/cardGallery.ts +31 -0
- package/src/default/detailGallery.ts +97 -0
- package/src/default/favorite.tsx +68 -1
- package/src/default/icons.tsx +60 -0
- package/src/default/index.ts +37 -1
- package/src/default/movableCluster.tsx +137 -0
- package/src/default/notice.ts +68 -0
- package/src/headless/MyListings.tsx +130 -39
- package/src/headless/Share.tsx +278 -0
- package/src/i18n/es.ts +28 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +9 -0
- package/src/i18n/generated/errors.json +31 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +28 -0
- package/src/index.ts +22 -0
- package/src/model/cardBadges.ts +124 -12
- package/src/model/mineSource.ts +9 -3
- 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
|
-
/**
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
gap:
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
{/*
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
{
|
|
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
|
},
|