@stapel/listings-react 0.29.0 → 0.30.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 +145 -0
- package/MODULE.md +9 -7
- package/README.md +5 -4
- package/dist/default/CardBadges.d.ts +32 -0
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +41 -3
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +115 -8
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +358 -177
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingRelated.d.ts +88 -0
- package/dist/default/ListingRelated.d.ts.map +1 -0
- package/dist/default/ListingRelated.js +41 -0
- package/dist/default/ListingRelated.js.map +1 -0
- package/dist/default/ListingSpecList.d.ts +29 -34
- package/dist/default/ListingSpecList.d.ts.map +1 -1
- package/dist/default/ListingSpecList.js +67 -12
- package/dist/default/ListingSpecList.js.map +1 -1
- package/dist/default/condensedBar.d.ts +17 -0
- package/dist/default/condensedBar.d.ts.map +1 -0
- package/dist/default/condensedBar.js +73 -0
- package/dist/default/condensedBar.js.map +1 -0
- package/dist/default/detailGallery.d.ts +40 -40
- package/dist/default/detailGallery.d.ts.map +1 -1
- package/dist/default/detailGallery.js +129 -0
- package/dist/default/detailGallery.js.map +1 -1
- package/dist/default/detailRhythm.d.ts +15 -0
- package/dist/default/detailRhythm.d.ts.map +1 -0
- package/dist/default/detailRhythm.js +74 -0
- package/dist/default/detailRhythm.js.map +1 -0
- package/dist/default/icons.d.ts +13 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +15 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +9 -5
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -4
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +12 -6
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +2 -2
- package/dist/i18n/generated/errors.gen.js +2 -2
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +23 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +38 -7
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +14 -9
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +3 -3
- package/manifest.json +29 -4
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/default/CardBadges.tsx +65 -4
- package/src/default/ListingDetailPane.tsx +490 -52
- package/src/default/ListingRelated.tsx +154 -0
- package/src/default/ListingSpecList.tsx +64 -1
- package/src/default/condensedBar.ts +74 -0
- package/src/default/detailGallery.ts +141 -0
- package/src/default/detailRhythm.ts +78 -0
- package/src/default/icons.tsx +32 -0
- package/src/default/index.ts +45 -2
- package/src/i18n/es.ts +12 -7
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +2 -2
- package/src/i18n/generated/errors.json +2 -4
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +38 -7
- package/src/i18n/ru.ts +14 -10
|
@@ -76,8 +76,43 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
76
76
|
* `<h1>` through its published test id and a `MutationObserver`, for a
|
|
77
77
|
* boolean the pane already knows. It is an `IntersectionObserver` on the
|
|
78
78
|
* title, never a scroll listener.
|
|
79
|
+
*
|
|
80
|
+
* `actionsPlacement="condensed-top"` is the round after that: the pane draws
|
|
81
|
+
* the bar ITSELF — back, the title on one line, the travelling cluster — for
|
|
82
|
+
* the four things every container was writing identically around the render
|
|
83
|
+
* prop. See `condensedBar.ts`; the render prop is untouched and still wins.
|
|
84
|
+
*
|
|
85
|
+
* ── What else the reference has on this page, and now so does this ────────
|
|
86
|
+
*
|
|
87
|
+
* Three absences the closing-wave comparison measured against the reference
|
|
88
|
+
* classified, each closed as a SEAM rather than as a feature this pair
|
|
89
|
+
* invented data for:
|
|
90
|
+
*
|
|
91
|
+
* the strip did not say where it was
|
|
92
|
+
* `galleryLayout="strip"` is a native scroll container and the page had
|
|
93
|
+
* no position indicator at all (REPORT §20b). `useGalleryPosition` reads
|
|
94
|
+
* the strip's own scroll — never a tap, because the gesture that changes
|
|
95
|
+
* the photograph is not one.
|
|
96
|
+
*
|
|
97
|
+
* nothing on the page started a sentence
|
|
98
|
+
* The reference puts four canned questions above its contact control on
|
|
99
|
+
* every listing (§16). `quickQuestions` draws them and
|
|
100
|
+
* `onQuickQuestion` reports the press, because chat-react's door takes
|
|
101
|
+
* no initial message and a pane that claimed to prefill one would be
|
|
102
|
+
* inventing a seam the other pair does not have.
|
|
103
|
+
*
|
|
104
|
+
* the page ended
|
|
105
|
+
* `similar` / `fromSeller` (rows) and `renderSimilar` /
|
|
106
|
+
* `renderFromSeller` (slots) are the reference's two distinct "find
|
|
107
|
+
* more" mechanisms (§16 comparison 4). The rows come from the HOST's
|
|
108
|
+
* search, because this pair does not read search.
|
|
109
|
+
*
|
|
110
|
+
* And one that was NOT a defect in this component: the characteristics table
|
|
111
|
+
* draws every stored row it can key and counts the ones it cannot. Measured
|
|
112
|
+
* against a live answer, the shortfall the comparison saw was in the DATA —
|
|
113
|
+
* `characteristicsLimit` adds the reference's FOLD, not rows.
|
|
79
114
|
*/
|
|
80
|
-
import { isValidElement, useCallback, useEffect, useRef } from "react";
|
|
115
|
+
import { isValidElement, useCallback, useEffect, useRef, useState } from "react";
|
|
81
116
|
import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
|
|
82
117
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
83
118
|
import { ErrorAlert, EmptyState, GatedButton, GatedControl, SkinTheme, } from "@stapel/tokens-antd/skin";
|
|
@@ -92,12 +127,15 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
92
127
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
93
128
|
import { ListingActions } from "./ListingActions.js";
|
|
94
129
|
import { LISTING_ACTION_CLASS } from "./actionRow.js";
|
|
95
|
-
import { LISTINGS_GALLERY_CLASS, LISTINGS_GALLERY_STYLE_HREF, detailGalleryCss, } from "./detailGallery.js";
|
|
130
|
+
import { LISTINGS_GALLERY_CLASS, LISTINGS_GALLERY_COUNTER_CLASS, LISTINGS_GALLERY_FRAME_CLASS, LISTINGS_GALLERY_STYLE_HREF, detailGalleryCss, useGalleryPosition, } from "./detailGallery.js";
|
|
96
131
|
import { useMovableCluster } from "./movableCluster.js";
|
|
132
|
+
import { DETAIL_RHYTHM_CLASS, DETAIL_RHYTHM_STYLE_HREF, DETAIL_RULE_CLASS, detailRhythmCss, } from "./detailRhythm.js";
|
|
133
|
+
import { CONDENSED_BAR_CLASS, CONDENSED_BAR_STYLE_HREF, CONDENSED_TITLE_CLASS, condensedBarCss, } from "./condensedBar.js";
|
|
134
|
+
import { ListingRelatedStrip } from "./ListingRelated.js";
|
|
97
135
|
import { useNotice } from "./notice.js";
|
|
98
136
|
import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
|
|
99
137
|
import { SignInLink } from "./SignInLink.js";
|
|
100
|
-
import { HeartIcon } from "./icons.js";
|
|
138
|
+
import { BackIcon, HeartIcon } from "./icons.js";
|
|
101
139
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
102
140
|
import { ListingPrice } from "./ListingPrice.js";
|
|
103
141
|
import { ListingStatusBlock } from "./StatusTags.js";
|
|
@@ -145,32 +183,45 @@ export { DETAIL_PHOTO_MIN } from "./detailGallery.js";
|
|
|
145
183
|
* fallback for a host that loads no token stylesheet.
|
|
146
184
|
*/
|
|
147
185
|
export const DETAIL_GALLERY_GUTTER = `${cssVar("page-gutter").slice(0, -1)}, ${String(spacing[3])}px)`;
|
|
148
|
-
/** The
|
|
186
|
+
/** The two LOANS: places the one cluster may travel to, neither of them a
|
|
187
|
+
* home. See {@link ListingDetailPaneProps.actionsPlacement}. */
|
|
188
|
+
const LOANS = ["bar", "condensed-top"];
|
|
189
|
+
/** The cluster's HOME: the first entry that is not one of the two loans. */
|
|
149
190
|
function homePlacement(placement) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
191
|
+
const all = placement === undefined
|
|
192
|
+
? []
|
|
193
|
+
: typeof placement === "string"
|
|
194
|
+
? [placement]
|
|
195
|
+
: placement;
|
|
196
|
+
for (const one of all) {
|
|
197
|
+
// A loan alone names no home — the cluster still has to live somewhere
|
|
154
198
|
// while the bar is off screen, and that somewhere is the default.
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
for (const one of placement) {
|
|
158
|
-
if (one !== "bar")
|
|
199
|
+
if (!LOANS.includes(one)) {
|
|
159
200
|
return one;
|
|
201
|
+
}
|
|
160
202
|
}
|
|
161
203
|
return "header";
|
|
162
204
|
}
|
|
163
|
-
/** Did the host ask for
|
|
164
|
-
function
|
|
205
|
+
/** Did the host ask for this borrowed placement at all? */
|
|
206
|
+
function wants(placement, one) {
|
|
165
207
|
if (placement === undefined)
|
|
166
208
|
return false;
|
|
167
209
|
if (typeof placement === "string")
|
|
168
|
-
return placement ===
|
|
169
|
-
return placement.includes(
|
|
210
|
+
return placement === one;
|
|
211
|
+
return placement.includes(one);
|
|
170
212
|
}
|
|
171
213
|
/** Priorities for the two mount points: the bar wins while it is on screen. */
|
|
172
214
|
const CLUSTER_HOME = 0;
|
|
173
215
|
const CLUSTER_BAR = 1;
|
|
216
|
+
/**
|
|
217
|
+
* How many canned questions the page draws, whoever supplies them.
|
|
218
|
+
*
|
|
219
|
+
* Four is the reference's own count and it is not arbitrary: the chips stand
|
|
220
|
+
* between the price and the one primary action on the page, and a fifth does
|
|
221
|
+
* not lengthen the row on a 390px phone — it wraps, and pushes "message the
|
|
222
|
+
* seller" a line further down.
|
|
223
|
+
*/
|
|
224
|
+
export const QUICK_QUESTIONS_MAX = 4;
|
|
174
225
|
/**
|
|
175
226
|
* The pane's own title, watched — see
|
|
176
227
|
* {@link ListingDetailPaneProps.onTitleVisible}.
|
|
@@ -186,13 +237,44 @@ function useTitleVisibility(onTitleVisible) {
|
|
|
186
237
|
latest.current = onTitleVisible;
|
|
187
238
|
});
|
|
188
239
|
const wanted = onTitleVisible !== undefined;
|
|
240
|
+
/**
|
|
241
|
+
* THE ONE LIVE OBSERVER, held by the node it watches.
|
|
242
|
+
*
|
|
243
|
+
* React 19's ref cleanup is the normal way this is disconnected, and it is
|
|
244
|
+
* not the only caller: `<Typography.Title ref>` is antd's, which merges refs
|
|
245
|
+
* and invokes them itself — a callback's RETURN VALUE means nothing to a
|
|
246
|
+
* caller that is not React, so the cleanup was simply dropped and a fresh
|
|
247
|
+
* `IntersectionObserver` was attached to the same `<h1>` on every render of
|
|
248
|
+
* a page that re-renders on every query settle. Measured: three live
|
|
249
|
+
* observers on one heading after two crossings, each firing the host's
|
|
250
|
+
* callback again, so a subscriber counting crossings counted three where
|
|
251
|
+
* there were two.
|
|
252
|
+
*
|
|
253
|
+
* Holding the pair here makes the hook idempotent for the node it is already
|
|
254
|
+
* watching, whoever calls it and however many times, and disconnecting from
|
|
255
|
+
* an effect closes the case a dropped cleanup leaves open.
|
|
256
|
+
*/
|
|
257
|
+
const held = useRef(null);
|
|
258
|
+
const stop = useCallback(() => {
|
|
259
|
+
held.current?.observer.disconnect();
|
|
260
|
+
held.current = null;
|
|
261
|
+
}, []);
|
|
262
|
+
// The belt, for the caller that discards the cleanup above.
|
|
263
|
+
useEffect(() => stop, [stop]);
|
|
189
264
|
return useCallback((node) => {
|
|
190
|
-
if (node === null || !wanted)
|
|
265
|
+
if (node === null || !wanted) {
|
|
266
|
+
stop();
|
|
191
267
|
return undefined;
|
|
268
|
+
}
|
|
192
269
|
// No observer, no answer. A fabricated `true` would wedge a host's bar
|
|
193
270
|
// open on an arm that has no scrolling to close it with.
|
|
194
271
|
if (typeof IntersectionObserver === "undefined")
|
|
195
272
|
return undefined;
|
|
273
|
+
// Already watching this very element: a second observer on it would
|
|
274
|
+
// report every crossing twice.
|
|
275
|
+
if (held.current?.node === node)
|
|
276
|
+
return stop;
|
|
277
|
+
stop();
|
|
196
278
|
const observer = new IntersectionObserver((entries) => {
|
|
197
279
|
const entry = entries[entries.length - 1];
|
|
198
280
|
if (entry === undefined)
|
|
@@ -200,10 +282,9 @@ function useTitleVisibility(onTitleVisible) {
|
|
|
200
282
|
latest.current?.(entry.isIntersecting);
|
|
201
283
|
});
|
|
202
284
|
observer.observe(node);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}, [wanted]);
|
|
285
|
+
held.current = { node, observer };
|
|
286
|
+
return stop;
|
|
287
|
+
}, [wanted, stop]);
|
|
207
288
|
}
|
|
208
289
|
/**
|
|
209
290
|
* Which arm of `actions` this is.
|
|
@@ -245,10 +326,49 @@ export function ListingDetailPane(props) {
|
|
|
245
326
|
rendered inline where it always was, no portal and no slot divs. */
|
|
246
327
|
const galleryLayout = props.galleryLayout ?? "grid";
|
|
247
328
|
const renderBar = props.renderActionsBar;
|
|
248
|
-
|
|
329
|
+
/* TWO LOANS, ONE WINNER. The host's own render prop is the specific answer
|
|
330
|
+
and the pane's condensed bar is the default one, so a page that asked for
|
|
331
|
+
both gets the host's — and never two bars competing for the top of one
|
|
332
|
+
viewport, each holding half a cluster. */
|
|
333
|
+
const hostBar = wants(props.actionsPlacement, "bar") && renderBar !== undefined;
|
|
334
|
+
const ownBar = !hostBar && wants(props.actionsPlacement, "condensed-top");
|
|
335
|
+
const barred = hostBar || ownBar;
|
|
249
336
|
const movable = useMovableCluster(barred);
|
|
250
337
|
const moving = barred && movable.portable;
|
|
251
|
-
|
|
338
|
+
/* IS THE TITLE ON SCREEN — the pane's own copy of the answer it already
|
|
339
|
+
publishes. `false` to start, because the page opens AT the title and a bar
|
|
340
|
+
that flashed on the first frame is the defect the observer exists to
|
|
341
|
+
avoid. */
|
|
342
|
+
const [titleGone, setTitleGone] = useState(false);
|
|
343
|
+
const onTitleVisible = props.onTitleVisible;
|
|
344
|
+
/* One observer for both readers. The hook holds the latest callback in a
|
|
345
|
+
ref, so this inline arrow costs no re-observation — and where the host
|
|
346
|
+
asked for neither, `undefined` keeps the whole thing unarmed. */
|
|
347
|
+
const titleRef = useTitleVisibility(onTitleVisible === undefined && !ownBar
|
|
348
|
+
? undefined
|
|
349
|
+
: (visible) => {
|
|
350
|
+
// Only the arm that draws a bar keeps state. A host that merely
|
|
351
|
+
// subscribed does not get a re-render of this page per crossing.
|
|
352
|
+
if (ownBar)
|
|
353
|
+
setTitleGone(!visible);
|
|
354
|
+
onTitleVisible?.(visible);
|
|
355
|
+
});
|
|
356
|
+
/* WHERE THE STRIP IS. Armed only for the arm that scrolls and only for a
|
|
357
|
+
listing with something to scroll THROUGH — see `useGalleryPosition`. */
|
|
358
|
+
const strip = galleryLayout === "strip" && bag.images.length > 1;
|
|
359
|
+
const photo = useGalleryPosition(strip);
|
|
360
|
+
/* THE FOUR QUESTIONS. Defaults from this pair's own catalogue, a host's own
|
|
361
|
+
list when it has one, and NOTHING at all when nobody can act on a press —
|
|
362
|
+
see `onQuickQuestion`. Capped at four: a fifth is a second row of chips
|
|
363
|
+
standing between a reader and the one primary action on the page. */
|
|
364
|
+
const quickQuestions = props.onQuickQuestion === undefined
|
|
365
|
+
? []
|
|
366
|
+
: (props.quickQuestions ?? [
|
|
367
|
+
t(LISTINGS_I18N_KEYS.detailQuestionAvailable),
|
|
368
|
+
t(LISTINGS_I18N_KEYS.detailQuestionPrice),
|
|
369
|
+
t(LISTINGS_I18N_KEYS.detailQuestionViewing),
|
|
370
|
+
t(LISTINGS_I18N_KEYS.detailQuestionDelivery),
|
|
371
|
+
]).slice(0, QUICK_QUESTIONS_MAX);
|
|
252
372
|
// The two arms of `actions` — see `isActionsConfig`.
|
|
253
373
|
const actionsConfig = isActionsConfig(props.actions)
|
|
254
374
|
? props.actions
|
|
@@ -283,163 +403,224 @@ export function ListingDetailPane(props) {
|
|
|
283
403
|
? LISTINGS_I18N_KEYS.favoriteAdded
|
|
284
404
|
: LISTINGS_I18N_KEYS.favoriteRemoved));
|
|
285
405
|
};
|
|
286
|
-
return (
|
|
406
|
+
return (_jsxs(SkinTheme, { surface: "base", style: {
|
|
287
407
|
maxWidth: props.measure ?? (split ? DETAIL_SPLIT_MEASURE : DETAIL_MEASURE),
|
|
288
408
|
// See `gutter`: a frame that already placed the page edge does not get
|
|
289
409
|
// a second one stacked inside it.
|
|
290
410
|
padding: props.gutter === "shell" ? 0 : spacing[4],
|
|
291
|
-
}, ...(props.mode !== undefined ? { mode: props.mode } : {}), children: _jsxs(Flex, { vertical: true, gap: spacing[4], "data-testid": "listings-detail", children: [bag.removed ? (_jsx(ErrorAlert, { testId: "listings-detail-removed", message: t(LISTINGS_I18N_KEYS.detailRemoved) })) : null, matchLoad(bag.state, {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
411
|
+
}, ...(props.mode !== undefined ? { mode: props.mode } : {}), children: [_jsx("style", { href: DETAIL_RHYTHM_STYLE_HREF, precedence: "default", children: detailRhythmCss() }), _jsxs(Flex, { vertical: true, gap: spacing[4], className: DETAIL_RHYTHM_CLASS, "data-testid": "listings-detail", children: [bag.removed ? (_jsx(ErrorAlert, { testId: "listings-detail-removed", message: t(LISTINGS_I18N_KEYS.detailRemoved) })) : null, matchLoad(bag.state, {
|
|
412
|
+
loading: () => (_jsx("div", { role: "status", "aria-busy": "true", "aria-label": t(LISTINGS_I18N_KEYS.detailLoading), "data-testid": "listings-detail-loading", "data-stapel-load-state": "loading" })),
|
|
413
|
+
// The failed arm, most specific sentence first: removed (the banner
|
|
414
|
+
// above already says it) → not found → withdrawn → the generic
|
|
415
|
+
// retry. `withdrawn` carries no retry control on purpose — the row
|
|
416
|
+
// is gone by its owner's choice, and a retry that can never help is
|
|
417
|
+
// what this arm replaces.
|
|
418
|
+
failed: (error) => bag.removed ? null : bag.notFound ? (_jsx(EmptyState, { testId: "listings-detail-error", title: t(LISTINGS_I18N_KEYS.detailNotFound) })) : bag.withdrawn ? (_jsx(EmptyState, { testId: "listings-detail-withdrawn", title: t(LISTINGS_I18N_KEYS.detailWithdrawn) })) : (_jsx(ErrorAlert, { testId: "listings-detail-error", thrown: error, message: t(LISTINGS_I18N_KEYS.detailLoadFailed), onRetry: bag.refetch, retryLabel: t(LISTINGS_I18N_KEYS.detailRetry) })),
|
|
419
|
+
ready: (listing) => {
|
|
420
|
+
/* The moderation axis is the OWNER's business and nobody
|
|
421
|
+
else's: a buyer has no use for "changes under review", and
|
|
422
|
+
showing a stranger that a listing was refused would leak a
|
|
423
|
+
verdict about someone else's content. Rendered above the
|
|
424
|
+
split, full width — a verdict is about the PAGE, not about
|
|
425
|
+
either of its columns. */
|
|
426
|
+
const statusBlocks = (_jsxs(_Fragment, { children: [owner && bag.status !== undefined ? (_jsxs(Flex, { vertical: true, gap: spacing[2], "data-testid": "listings-detail-owner-view", children: [_jsx(ListingStatusBlock, { status: bag.status }), !bag.publiclyVisible ? (_jsx(Typography.Text, { type: "secondary", children: t(LISTINGS_I18N_KEYS.detailOwnerOnlyView) })) : null] })) : null, !owner && !bag.publiclyVisible ? (_jsx(ErrorAlert, { testId: "listings-detail-not-published", message: t(LISTINGS_I18N_KEYS.detailNotPublished), variant: "inline" })) : null] }));
|
|
427
|
+
/* THE READER'S TWO ACTIONS, as one cluster — see
|
|
428
|
+
`<ListingActions>` and `actionsPlacement`.
|
|
429
|
+
|
|
430
|
+
The heart is handed IN rather than mounted by the cluster:
|
|
431
|
+
this page's favourite is driven by `useListingDetail`'s own
|
|
432
|
+
optimistic bag (it holds the whole listing and flips the row it
|
|
433
|
+
already has), not by `useFavoriteToggle` against a card row.
|
|
434
|
+
One control, two hooks, one geometry.
|
|
435
|
+
|
|
436
|
+
The OWNER gets the share button and no heart: favouriting your
|
|
437
|
+
own listing is not a thing anyone does, and sending somebody
|
|
438
|
+
your own listing is the first thing a seller does. */
|
|
439
|
+
const favoriteControl = owner ? null : props.blockedReason === "popover" &&
|
|
440
|
+
favoriteView.reason !== undefined ? (
|
|
441
|
+
/* The cards' third volume, verbatim: nothing standing, the
|
|
442
|
+
reason and the door disclosed on the heart. `aria-disabled`
|
|
443
|
+
rather than `disabled`, so the disclosure's hover, focus
|
|
444
|
+
and tap all arrive — and the click is a safe no-op, because
|
|
445
|
+
`toggleFavorite` refuses while the gate is blocked. */
|
|
446
|
+
_jsx(GateReasonPopover, { reason: favoriteView.reason, cta: props.signIn, testId: "listings-detail-favorite-reason", signInTestId: "listings-detail-sign-in", children: (bind) => (_jsx(Button, { shape: "circle", "aria-disabled": true, ...bind, className: LISTING_ACTION_CLASS, "aria-label": favoriteLabel, "aria-pressed": bag.isFavorited, icon: heartIcon, "data-testid": "listings-detail-favorite", "data-favorited": String(bag.isFavorited), "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: pressFavorite })) })) : (_jsxs(Flex, { vertical: true, gap: spacing[1], children: [_jsx(GatedControl, { gate: bag.favoriteGate, testId: "listings-detail-favorite-gate", children: (bind) => (_jsx(Button, { shape: "circle", ...bind, className: LISTING_ACTION_CLASS, "aria-label": favoriteLabel, "aria-pressed": bag.isFavorited, icon: heartIcon, "data-testid": "listings-detail-favorite", "data-favorited": String(bag.isFavorited), "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: pressFavorite })) }), bag.favoriteGate.available ? null : (_jsx(Typography.Text, { type: "secondary", "data-testid": "listings-detail-favorite-blocked", children: _jsx(SignInLink, { cta: props.signIn, testId: "listings-detail-sign-in" }) }))] }));
|
|
447
|
+
const readerActions = (_jsx(ListingActions, { listingId: props.id, favorite: favoriteControl, testId: "listings-detail-reader-actions", placement: placement === "gallery" ? "overlay" : "inline", actions: {
|
|
448
|
+
...actionsConfig,
|
|
449
|
+
// The owner keeps the share button and loses the heart.
|
|
450
|
+
...(owner ? { favorite: false } : {}),
|
|
451
|
+
}, ...(props.favoriteCount !== undefined && !owner
|
|
452
|
+
? { favoriteCount: props.favoriteCount }
|
|
453
|
+
: {}), ...(props.shareUrl !== undefined ? { shareUrl: props.shareUrl } : {}), ...(listing.title !== undefined && listing.title !== null
|
|
454
|
+
? { shareTitle: listing.title }
|
|
455
|
+
: {}), ...(props.sharePrefer !== undefined
|
|
456
|
+
? { sharePrefer: props.sharePrefer }
|
|
457
|
+
: {}), ...(props.onShared !== undefined ? { onShared: props.onShared } : {}) }));
|
|
458
|
+
/* What the PAGE draws where the cluster lives. With the bar in
|
|
459
|
+
play that is a slot and not the cluster itself: the cluster is
|
|
460
|
+
rendered once into `clusterLayer` below and travels between the
|
|
461
|
+
two slots as a DOM node, so it mounts once, holds one
|
|
462
|
+
`useFavoriteToggle`, and keeps its element identity across the
|
|
463
|
+
move. Without it, the cluster is drawn inline exactly as it has
|
|
464
|
+
always been. */
|
|
465
|
+
const homeActions = moving
|
|
466
|
+
? movable.slot(CLUSTER_HOME, placement)
|
|
467
|
+
: readerActions;
|
|
468
|
+
/* The one instance, plus the host's bar around the slot that may
|
|
469
|
+
borrow it. Rendered at the end of the page's own flow: the
|
|
470
|
+
portal has no position of its own (its content is wherever the
|
|
471
|
+
winning slot is), and a bar is `position: fixed` chrome whose
|
|
472
|
+
place in the document order is not its place on the screen. */
|
|
473
|
+
/* THE PANE'S OWN CONDENSED BAR (`"condensed-top"`).
|
|
474
|
+
|
|
475
|
+
Mounted only while the title is off screen, which is what makes
|
|
476
|
+
the cluster travel at all: the slot inside it is the higher
|
|
477
|
+
priority, so appearing borrows the cluster and disappearing
|
|
478
|
+
hands it straight back to the heading — the same DOM node, the
|
|
479
|
+
same optimistic favourite, no second `useFavoriteToggle`.
|
|
480
|
+
|
|
481
|
+
The back arrow is absent when the host gave no `onBack`: a
|
|
482
|
+
listing opened in a new tab has nothing to go back to. */
|
|
483
|
+
const condensedBar = !moving || !ownBar || !titleGone ? null : (_jsxs("div", { className: CONDENSED_BAR_CLASS, "data-testid": "listings-detail-condensed-bar", children: [_jsx("style", { href: CONDENSED_BAR_STYLE_HREF, precedence: "default", children: condensedBarCss() }), props.onBack === undefined ? null : (_jsx(Button, { type: "text", shape: "circle", className: LISTING_ACTION_CLASS, "aria-label": t(LISTINGS_I18N_KEYS.detailBack), icon: _jsx(BackIcon, {}), "data-testid": "listings-detail-back", "data-analytics": "none", "data-analytics-reason": "navigation \u2014 the host owns its own history", onClick: props.onBack })), _jsx("span", { className: CONDENSED_TITLE_CLASS, "data-testid": "listings-detail-condensed-title", children: listing.title ?? "" }), movable.slot(CLUSTER_BAR, "condensed-top")] }));
|
|
484
|
+
const clusterLayer = !moving ? null : (_jsxs(_Fragment, { children: [movable.render(readerActions), hostBar && renderBar !== undefined
|
|
485
|
+
? renderBar(movable.slot(CLUSTER_BAR, "bar"))
|
|
486
|
+
: condensedBar] }));
|
|
487
|
+
/* Element-width tiles: the grid decides how many fit, the
|
|
488
|
+
photos fill them. */
|
|
489
|
+
const galleryBox = (_jsxs("div", { ref: photo.ref, "data-testid": "listings-detail-gallery", "data-gallery-active": String(photo.active), className: LISTINGS_GALLERY_CLASS, "data-gallery-layout": galleryLayout, style: {
|
|
490
|
+
// The page's own edge, per breakpoint — see
|
|
491
|
+
// `DETAIL_GALLERY_GUTTER` (D418).
|
|
492
|
+
gap: DETAIL_GALLERY_GUTTER,
|
|
493
|
+
// The containing block the overlay arm is pinned to. A
|
|
494
|
+
// `relative` with no offsets moves no pixel of what is
|
|
495
|
+
// already in it — the same trick `cardGalleryCss` uses.
|
|
496
|
+
position: "relative",
|
|
497
|
+
}, children: [_jsx("style", { href: LISTINGS_GALLERY_STYLE_HREF, precedence: "default", children: detailGalleryCss() }), bag.images.length === 0 ? (_jsx(ListingPhoto, { imageRef: undefined, alt: listing.title ?? String(listing.id) })) : (bag.images.map((ref, index) => (_jsx(ListingPhoto, { imageRef: ref, alt: t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
498
|
+
index: index + 1,
|
|
499
|
+
total: bag.images.length,
|
|
500
|
+
}) }, ref)))), placement === "gallery" ? homeActions : null] }));
|
|
501
|
+
/* "3 of 16" OVER THE STRIP, and it moves with the strip.
|
|
502
|
+
`aria-live="polite"` because it changes with no gesture a screen
|
|
503
|
+
reader would otherwise report — a finger scrolls the strip
|
|
504
|
+
natively and this line is the only announcement of the move.
|
|
505
|
+
The grid arm gets none of it: every photograph is on screen at
|
|
506
|
+
once there, and "1 of 3" over a grid of three is a control
|
|
507
|
+
panel for a picture that needs none.
|
|
508
|
+
|
|
509
|
+
It is a SIBLING of the strip inside a frame, never a child: in
|
|
510
|
+
this arm the strip is the scroll container, and an absolutely
|
|
511
|
+
positioned child of a scroller scrolls away with the content it
|
|
512
|
+
is supposed to be counting. See `detailGallery.ts`. */
|
|
513
|
+
const gallery = !strip ? (galleryBox) : (_jsxs("div", { className: LISTINGS_GALLERY_FRAME_CLASS, children: [galleryBox, _jsx("span", { className: LISTINGS_GALLERY_COUNTER_CLASS, "data-testid": "listings-detail-photo-counter", "aria-live": "polite", children: t(LISTINGS_I18N_KEYS.cardPhotoCounter, {
|
|
514
|
+
index: photo.active + 1,
|
|
515
|
+
total: bag.images.length,
|
|
516
|
+
}) })] }));
|
|
517
|
+
const heading = (_jsxs(_Fragment, { children: [_jsxs(Flex, { align: "flex-start", justify: "space-between", gap: spacing[3], children: [_jsx(Typography.Title, { level: props.headingLevel ?? 3, ref: titleRef, "data-testid": "listings-detail-title", style: { minWidth: 0, flex: "1 1 auto" }, children: listing.title ?? "" }), placement === "header" ? homeActions : null] }), bag.titleFeatures.length > 0 ? (_jsx(Typography.Text, { type: "secondary", "data-testid": "listings-detail-title-features", children: bag.titleFeatures
|
|
518
|
+
// A hidden value is never part of a title: the server
|
|
519
|
+
// keeps one out of `features_title` entirely, and
|
|
520
|
+
// `formatFeatureValue` refuses a stub besides (it carries
|
|
521
|
+
// no value, so there is nothing to format). The filter is
|
|
522
|
+
// the third belt, and it is here rather than at the
|
|
523
|
+
// formatter's edge because THIS is the line where a
|
|
524
|
+
// leaked identifier would be read out loud.
|
|
525
|
+
.filter((view) => !isRedactedValue(view.value))
|
|
526
|
+
.map((view) => formatSpecValue(view.feature, view.value, { t, locale }))
|
|
527
|
+
.filter((text) => text !== undefined)
|
|
528
|
+
.join(" · ") })) : null, bag.viewCount !== undefined ? (_jsxs(Typography.Text, { type: "secondary", "data-testid": "listings-detail-meta", style: { display: "block" }, children: [t(LISTINGS_I18N_KEYS.detailViews), ":", " ", _jsx("span", { "data-testid": "listings-detail-views", children: bag.viewCount })] })) : null] }));
|
|
529
|
+
/* In the split the price leads the buy column at level 2 — the
|
|
530
|
+
measured page had it at 22px UNDER the title, smaller than the
|
|
531
|
+
thing it prices, which is backwards on the one line a buyer
|
|
532
|
+
came to read. In the column it stays the level-4 line it has
|
|
533
|
+
always been. */
|
|
534
|
+
const price = (_jsx(Typography.Title, { level: split ? 2 : 4, "data-testid": "listings-detail-price", children: _jsx(ListingPrice, { amount: listing.price, ...(listing.currency !== undefined
|
|
535
|
+
? { currency: listing.currency }
|
|
536
|
+
: {}) }) }));
|
|
537
|
+
/* The buy box. One primary, and which one depends on who is
|
|
538
|
+
reading this page. */
|
|
539
|
+
const buyBox = (_jsxs(Flex, { wrap: true, gap: spacing[3], align: "flex-start", "data-testid": "listings-detail-actions", children: [owner ? (_jsxs(_Fragment, { children: [_jsx(GatedButton, { gate: editGate, type: "primary", testId: "listings-detail-edit", "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: () => {
|
|
540
|
+
props.onEdit?.(props.id);
|
|
541
|
+
}, children: t(LISTINGS_I18N_KEYS.detailEdit) }), _jsx(GatedButton, { gate: actions.archive, danger: true, testId: "listings-detail-take-down", "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: actions.doArchive, children: t(LISTINGS_I18N_KEYS.detailTakeDown) })] })) : (_jsxs(Flex, { vertical: true, gap: spacing[2], style: { minWidth: 0 }, children: [quickQuestions.length === 0 ? null : (_jsxs(Flex, { vertical: true, gap: spacing[1], "data-testid": "listings-detail-questions", children: [_jsx(Typography.Text, { type: "secondary", children: t(LISTINGS_I18N_KEYS.detailAskSeller) }), _jsx(Flex, { wrap: true, gap: spacing[2], children: quickQuestions.map((question) => (_jsx(Button, { size: "small", "data-testid": "listings-detail-question", "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: () => {
|
|
542
|
+
props.onQuickQuestion?.(question);
|
|
543
|
+
}, children: question }, question))) })] })), _jsx("div", { "data-testid": "listings-detail-contact", children: props.contactSlot ?? _jsx(SlotPlaceholder, { name: "contactSlot" }) })] })), placement === "buy-box" ? homeActions : null, actionsNode] }));
|
|
544
|
+
const actionError = (_jsxs(_Fragment, { children: [actions.error !== undefined && actions.error !== null ? (_jsx(ErrorAlert, { testId: "listings-detail-action-error", thrown: actions.error, variant: "inline" })) : null, _jsx(ErrorAlert, { testId: "listings-detail-favorite-error", thrown: bag.favoriteError, variant: "inline" })] }));
|
|
545
|
+
const description = (_jsxs(_Fragment, { children: [_jsx(Typography.Title, { level: 5, children: t(LISTINGS_I18N_KEYS.detailDescription) }), _jsx(Typography.Paragraph, { "data-testid": "listings-detail-description", children: listing.description ?? "" })] }));
|
|
546
|
+
/* The DISPLAY envelope, not the edit one: a redacted row keeps
|
|
547
|
+
its place in the table and says the seller supplied the value.
|
|
548
|
+
`featuresDtoFromDaoList` deliberately drops a stub, because it
|
|
549
|
+
is what seeds a composer. */
|
|
550
|
+
const specValues = featureValuesForDisplay(asFeatureDaoList(listing.features), props.categoryFeatures !== undefined
|
|
551
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
552
|
+
: {});
|
|
553
|
+
/* Two spec columns in the split — a grid of whole ROWS, cut by
|
|
554
|
+
row count so the category's declaration order survives: the
|
|
555
|
+
first (larger) half fills the left list and the page reads
|
|
556
|
+
top-to-bottom, left column first, exactly as the one-column
|
|
557
|
+
list reads. The label is never a column of its own; see
|
|
558
|
+
`<ListingSpecList>` for the shape and why the table went. */
|
|
559
|
+
const specFeatures = bag.features.map((view) => view.feature);
|
|
560
|
+
const specs = bag.features.length === 0 ? (_jsx(Typography.Text, { type: "secondary", "data-testid": "listings-detail-no-specs", children: t(LISTINGS_I18N_KEYS.detailNoSpecs) })) : split ? (_jsx(ListingSpecColumns, { features: specFeatures, values: specValues })) : (_jsx(ListingSpecList, { features: specFeatures, values: specValues, ...(props.characteristicsLimit !== undefined
|
|
561
|
+
? { limit: props.characteristicsLimit }
|
|
562
|
+
: {}) }));
|
|
563
|
+
const specsSection = (_jsxs(_Fragment, { children: [_jsx(Typography.Title, { level: 5, children: t(LISTINGS_I18N_KEYS.detailSpecs) }), specs, bag.unreadableFeatures > 0 ? (_jsx(Typography.Text, { type: "warning", "data-testid": "listings-detail-unreadable", children: t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
|
|
564
|
+
count: bag.unreadableFeatures,
|
|
565
|
+
}) })) : null] }));
|
|
566
|
+
const hasStock = listing.stock_quantity != null;
|
|
567
|
+
const hasPlace = listing.location_label !== undefined &&
|
|
568
|
+
listing.location_label.length > 0;
|
|
569
|
+
// Both rows absent draws an empty table, which the view count used
|
|
570
|
+
// to hide by almost always being there. Nothing is nothing.
|
|
571
|
+
const meta = !hasStock && !hasPlace ? null : (_jsxs(Descriptions, { size: "small", column: 1, children: [hasStock ? (_jsx(Descriptions.Item, { label: t(LISTINGS_I18N_KEYS.detailStock), children: _jsx("span", { "data-testid": "listings-detail-stock", children: listing.stock_quantity }) })) : null, hasPlace ? (_jsx(Descriptions.Item, { label: t(LISTINGS_I18N_KEYS.composeLocationLabel), children: listing.location_label })) : null] }));
|
|
572
|
+
const aside = props.aside !== undefined ? (_jsx("div", { "data-testid": "listings-detail-aside", children: props.aside })) : null;
|
|
573
|
+
/* THE TWO "FIND MORE" SECTIONS — see `<ListingRelatedStrip>` for
|
|
574
|
+
why the rows arrive from the host and are not read here.
|
|
575
|
+
|
|
576
|
+
The render prop wins over the rows for each strip separately: a
|
|
577
|
+
host may have a catalogue-link widget for "similar" and a plain
|
|
578
|
+
list of the seller's other listings, which is exactly the pair
|
|
579
|
+
the reference draws. Neither is rendered empty. */
|
|
580
|
+
const related = {
|
|
581
|
+
listingId: props.id,
|
|
582
|
+
...(listing.category_id !== undefined && listing.category_id !== null
|
|
583
|
+
? { categoryId: listing.category_id }
|
|
584
|
+
: { categoryId: undefined }),
|
|
585
|
+
ownerKey: listing.owner,
|
|
586
|
+
axes: bag.titleFeatures,
|
|
587
|
+
};
|
|
588
|
+
const strips = (_jsxs(_Fragment, { children: [props.renderSimilar !== undefined ? (props.renderSimilar(related)) : (_jsx(ListingRelatedStrip, { heading: t(LISTINGS_I18N_KEYS.detailSimilar), items: props.similar ?? [], testId: "listings-detail-similar", ...(props.similarHref !== undefined
|
|
589
|
+
? { showAllHref: props.similarHref }
|
|
590
|
+
: {}), ...(props.listingHref !== undefined
|
|
591
|
+
? { listingHref: props.listingHref }
|
|
592
|
+
: {}), ...(props.linkComponent !== undefined
|
|
593
|
+
? { linkComponent: props.linkComponent }
|
|
594
|
+
: {}) })), props.renderFromSeller !== undefined ? (props.renderFromSeller(related)) : (_jsx(ListingRelatedStrip, { heading: t(LISTINGS_I18N_KEYS.detailFromSeller), items: props.fromSeller ?? [], testId: "listings-detail-from-seller", ...(props.fromSellerHref !== undefined
|
|
595
|
+
? { showAllHref: props.fromSellerHref }
|
|
596
|
+
: {}), ...(props.listingHref !== undefined
|
|
597
|
+
? { listingHref: props.listingHref }
|
|
598
|
+
: {}), ...(props.linkComponent !== undefined
|
|
599
|
+
? { linkComponent: props.linkComponent }
|
|
600
|
+
: {}) }))] }));
|
|
601
|
+
if (!split) {
|
|
602
|
+
// The single column. `"end"` is the order it has always read —
|
|
603
|
+
// the host's aside joins where the footer's flow already is;
|
|
604
|
+
// `"after-actions"` puts the seller beside the decision to
|
|
605
|
+
// contact them, which is where the split layout already has it.
|
|
606
|
+
const asideAfterActions = props.asidePlacement === "after-actions";
|
|
607
|
+
return (_jsxs(_Fragment, { children: [statusBlocks, gallery, heading, price, buyBox, actionError, asideAfterActions ? aside : null, _jsx(Divider, { className: DETAIL_RULE_CLASS }), description, specsSection, meta, strips, asideAfterActions ? null : aside, props.footer, clusterLayer] }));
|
|
608
|
+
}
|
|
609
|
+
return (_jsxs(_Fragment, { children: [statusBlocks, _jsxs("div", { "data-testid": "listings-detail-split", style: {
|
|
610
|
+
display: "grid",
|
|
611
|
+
// The reading column takes what is left and may shrink
|
|
612
|
+
// (`minmax(0, …)`, or a long unbroken title widens the
|
|
613
|
+
// track past the pane); the buy column's track is fixed —
|
|
614
|
+
// see DETAIL_SPLIT_ASIDE for why it is not a fraction.
|
|
615
|
+
gridTemplateColumns: `minmax(0, 1fr) ${DETAIL_SPLIT_ASIDE}`,
|
|
616
|
+
gap: spacing[5],
|
|
617
|
+
alignItems: "start",
|
|
618
|
+
}, children: [_jsxs(Flex, { vertical: true, gap: spacing[4], className: DETAIL_RHYTHM_CLASS, "data-testid": "listings-detail-reading-column", children: [gallery, heading, _jsx(Divider, { className: DETAIL_RULE_CLASS }), description, specsSection, meta, strips, props.footer] }), _jsxs(Flex, { vertical: true, gap: spacing[3], className: DETAIL_RHYTHM_CLASS, "data-testid": "listings-detail-buy-column", style: {
|
|
619
|
+
position: "sticky",
|
|
620
|
+
top: props.buyTop ?? spacing[4],
|
|
621
|
+
alignSelf: "start",
|
|
622
|
+
}, children: [price, buyBox, actionError, aside] })] }), clusterLayer] }));
|
|
623
|
+
},
|
|
624
|
+
})] })] }));
|
|
444
625
|
}
|
|
445
626
|
//# sourceMappingURL=ListingDetailPane.js.map
|