@stapel/listings-react 0.29.1 → 0.30.1

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 (64) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/dist/default/CardBadges.d.ts +32 -0
  3. package/dist/default/CardBadges.d.ts.map +1 -1
  4. package/dist/default/CardBadges.js +41 -3
  5. package/dist/default/CardBadges.js.map +1 -1
  6. package/dist/default/ListingDetailPane.d.ts +115 -8
  7. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  8. package/dist/default/ListingDetailPane.js +366 -177
  9. package/dist/default/ListingDetailPane.js.map +1 -1
  10. package/dist/default/ListingRelated.d.ts +88 -0
  11. package/dist/default/ListingRelated.d.ts.map +1 -0
  12. package/dist/default/ListingRelated.js +41 -0
  13. package/dist/default/ListingRelated.js.map +1 -0
  14. package/dist/default/ListingSpecList.d.ts +29 -34
  15. package/dist/default/ListingSpecList.d.ts.map +1 -1
  16. package/dist/default/ListingSpecList.js +67 -12
  17. package/dist/default/ListingSpecList.js.map +1 -1
  18. package/dist/default/condensedBar.d.ts +17 -0
  19. package/dist/default/condensedBar.d.ts.map +1 -0
  20. package/dist/default/condensedBar.js +73 -0
  21. package/dist/default/condensedBar.js.map +1 -0
  22. package/dist/default/detailGallery.d.ts +40 -40
  23. package/dist/default/detailGallery.d.ts.map +1 -1
  24. package/dist/default/detailGallery.js +129 -0
  25. package/dist/default/detailGallery.js.map +1 -1
  26. package/dist/default/detailRhythm.d.ts +90 -0
  27. package/dist/default/detailRhythm.d.ts.map +1 -0
  28. package/dist/default/detailRhythm.js +51 -0
  29. package/dist/default/detailRhythm.js.map +1 -0
  30. package/dist/default/icons.d.ts +13 -0
  31. package/dist/default/icons.d.ts.map +1 -1
  32. package/dist/default/icons.js +15 -0
  33. package/dist/default/icons.js.map +1 -1
  34. package/dist/default/index.d.ts +9 -5
  35. package/dist/default/index.d.ts.map +1 -1
  36. package/dist/default/index.js +13 -4
  37. package/dist/default/index.js.map +1 -1
  38. package/dist/i18n/es.d.ts.map +1 -1
  39. package/dist/i18n/es.js +9 -0
  40. package/dist/i18n/es.js.map +1 -1
  41. package/dist/i18n/keys.d.ts +23 -0
  42. package/dist/i18n/keys.d.ts.map +1 -1
  43. package/dist/i18n/keys.js +32 -0
  44. package/dist/i18n/keys.js.map +1 -1
  45. package/dist/i18n/ru.d.ts.map +1 -1
  46. package/dist/i18n/ru.js +9 -0
  47. package/dist/i18n/ru.js.map +1 -1
  48. package/llms.txt +3 -2
  49. package/manifest.json +28 -1
  50. package/nav-manifest.json +1 -1
  51. package/package.json +4 -4
  52. package/src/analytics/generated/events.json +1 -1
  53. package/src/default/CardBadges.tsx +65 -4
  54. package/src/default/ListingDetailPane.tsx +501 -55
  55. package/src/default/ListingRelated.tsx +154 -0
  56. package/src/default/ListingSpecList.tsx +64 -1
  57. package/src/default/condensedBar.ts +74 -0
  58. package/src/default/detailGallery.ts +141 -0
  59. package/src/default/detailRhythm.ts +107 -0
  60. package/src/default/icons.tsx +32 -0
  61. package/src/default/index.ts +46 -2
  62. package/src/i18n/es.ts +9 -0
  63. package/src/i18n/keys.ts +32 -0
  64. package/src/i18n/ru.ts +9 -0
@@ -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, DETAIL_TITLE_RESET, 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 cluster's HOME: the first entry that is not the borrowed bar. */
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
- if (placement === undefined)
151
- return "header";
152
- if (typeof placement === "string") {
153
- // `"bar"` alone names no home — the cluster still has to live somewhere
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
- return placement === "bar" ? "header" : placement;
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 the borrowed placement at all? */
164
- function wantsBar(placement) {
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 === "bar";
169
- return placement.includes("bar");
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
- return () => {
204
- observer.disconnect();
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
- const barred = wantsBar(props.actionsPlacement) && renderBar !== undefined;
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
- const titleRef = useTitleVisibility(props.onTitleVisible);
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,232 @@ export function ListingDetailPane(props) {
283
403
  ? LISTINGS_I18N_KEYS.favoriteAdded
284
404
  : LISTINGS_I18N_KEYS.favoriteRemoved));
285
405
  };
286
- return (_jsx(SkinTheme, { surface: "base", style: {
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
- 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" })),
293
- // The failed arm, most specific sentence first: removed (the banner
294
- // above already says it) → not found → withdrawn → the generic
295
- // retry. `withdrawn` carries no retry control on purpose — the row
296
- // is gone by its owner's choice, and a retry that can never help is
297
- // what this arm replaces.
298
- 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) })),
299
- ready: (listing) => {
300
- /* The moderation axis is the OWNER's business and nobody
301
- else's: a buyer has no use for "changes under review", and
302
- showing a stranger that a listing was refused would leak a
303
- verdict about someone else's content. Rendered above the
304
- split, full width — a verdict is about the PAGE, not about
305
- either of its columns. */
306
- 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] }));
307
- /* THE READER'S TWO ACTIONS, as one cluster — see
308
- `<ListingActions>` and `actionsPlacement`.
309
-
310
- The heart is handed IN rather than mounted by the cluster:
311
- this page's favourite is driven by `useListingDetail`'s own
312
- optimistic bag (it holds the whole listing and flips the row it
313
- already has), not by `useFavoriteToggle` against a card row.
314
- One control, two hooks, one geometry.
315
-
316
- The OWNER gets the share button and no heart: favouriting your
317
- own listing is not a thing anyone does, and sending somebody
318
- your own listing is the first thing a seller does. */
319
- const favoriteControl = owner ? null : props.blockedReason === "popover" &&
320
- favoriteView.reason !== undefined ? (
321
- /* The cards' third volume, verbatim: nothing standing, the
322
- reason and the door disclosed on the heart. `aria-disabled`
323
- rather than `disabled`, so the disclosure's hover, focus
324
- and tap all arrive — and the click is a safe no-op, because
325
- `toggleFavorite` refuses while the gate is blocked. */
326
- _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" }) }))] }));
327
- const readerActions = (_jsx(ListingActions, { listingId: props.id, favorite: favoriteControl, testId: "listings-detail-reader-actions", placement: placement === "gallery" ? "overlay" : "inline", actions: {
328
- ...actionsConfig,
329
- // The owner keeps the share button and loses the heart.
330
- ...(owner ? { favorite: false } : {}),
331
- }, ...(props.favoriteCount !== undefined && !owner
332
- ? { favoriteCount: props.favoriteCount }
333
- : {}), ...(props.shareUrl !== undefined ? { shareUrl: props.shareUrl } : {}), ...(listing.title !== undefined && listing.title !== null
334
- ? { shareTitle: listing.title }
335
- : {}), ...(props.sharePrefer !== undefined
336
- ? { sharePrefer: props.sharePrefer }
337
- : {}), ...(props.onShared !== undefined ? { onShared: props.onShared } : {}) }));
338
- /* What the PAGE draws where the cluster lives. With the bar in
339
- play that is a slot and not the cluster itself: the cluster is
340
- rendered once into `clusterLayer` below and travels between the
341
- two slots as a DOM node, so it mounts once, holds one
342
- `useFavoriteToggle`, and keeps its element identity across the
343
- move. Without it, the cluster is drawn inline exactly as it has
344
- always been. */
345
- const homeActions = moving
346
- ? movable.slot(CLUSTER_HOME, placement)
347
- : readerActions;
348
- /* The one instance, plus the host's bar around the slot that may
349
- borrow it. Rendered at the end of the page's own flow: the
350
- portal has no position of its own (its content is wherever the
351
- winning slot is), and a bar is `position: fixed` chrome whose
352
- place in the document order is not its place on the screen. */
353
- const clusterLayer = !moving || renderBar === undefined ? null : (_jsxs(_Fragment, { children: [movable.render(readerActions), renderBar(movable.slot(CLUSTER_BAR, "bar"))] }));
354
- /* Element-width tiles: the grid decides how many fit, the
355
- photos fill them. */
356
- const gallery = (_jsxs("div", { "data-testid": "listings-detail-gallery", className: LISTINGS_GALLERY_CLASS, "data-gallery-layout": galleryLayout, style: {
357
- // The page's own edge, per breakpoint — see
358
- // `DETAIL_GALLERY_GUTTER` (D418).
359
- gap: DETAIL_GALLERY_GUTTER,
360
- // The containing block the overlay arm is pinned to. A
361
- // `relative` with no offsets moves no pixel of what is
362
- // already in it the same trick `cardGalleryCss` uses.
363
- position: "relative",
364
- }, 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, {
365
- index: index + 1,
366
- total: bag.images.length,
367
- }) }, ref)))), placement === "gallery" ? homeActions : null] }));
368
- 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
369
- // A hidden value is never part of a title: the server
370
- // keeps one out of `features_title` entirely, and
371
- // `formatFeatureValue` refuses a stub besides (it carries
372
- // no value, so there is nothing to format). The filter is
373
- // the third belt, and it is here rather than at the
374
- // formatter's edge because THIS is the line where a
375
- // leaked identifier would be read out loud.
376
- .filter((view) => !isRedactedValue(view.value))
377
- .map((view) => formatSpecValue(view.feature, view.value, { t, locale }))
378
- .filter((text) => text !== undefined)
379
- .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] }));
380
- /* In the split the price leads the buy column at level 2 — the
381
- measured page had it at 22px UNDER the title, smaller than the
382
- thing it prices, which is backwards on the one line a buyer
383
- came to read. In the column it stays the level-4 line it has
384
- always been. */
385
- const price = (_jsx(Typography.Title, { level: split ? 2 : 4, "data-testid": "listings-detail-price", children: _jsx(ListingPrice, { amount: listing.price, ...(listing.currency !== undefined
386
- ? { currency: listing.currency }
387
- : {}) }) }));
388
- /* The buy box. One primary, and which one depends on who is
389
- reading this page. */
390
- 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: () => {
391
- props.onEdit?.(props.id);
392
- }, 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) })] })) : (_jsx("div", { "data-testid": "listings-detail-contact", children: props.contactSlot ?? _jsx(SlotPlaceholder, { name: "contactSlot" }) })), placement === "buy-box" ? homeActions : null, actionsNode] }));
393
- 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" })] }));
394
- 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 ?? "" })] }));
395
- /* The DISPLAY envelope, not the edit one: a redacted row keeps
396
- its place in the table and says the seller supplied the value.
397
- `featuresDtoFromDaoList` deliberately drops a stub, because it
398
- is what seeds a composer. */
399
- const specValues = featureValuesForDisplay(asFeatureDaoList(listing.features), props.categoryFeatures !== undefined
400
- ? { categoryFeatures: props.categoryFeatures }
401
- : {});
402
- /* Two spec columns in the split — a grid of whole ROWS, cut by
403
- row count so the category's declaration order survives: the
404
- first (larger) half fills the left list and the page reads
405
- top-to-bottom, left column first, exactly as the one-column
406
- list reads. The label is never a column of its own; see
407
- `<ListingSpecList>` for the shape and why the table went. */
408
- const specFeatures = bag.features.map((view) => view.feature);
409
- 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 }));
410
- 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, {
411
- count: bag.unreadableFeatures,
412
- }) })) : null] }));
413
- const hasStock = listing.stock_quantity != null;
414
- const hasPlace = listing.location_label !== undefined &&
415
- listing.location_label.length > 0;
416
- // Both rows absent draws an empty table, which the view count used
417
- // to hide by almost always being there. Nothing is nothing.
418
- 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] }));
419
- const aside = props.aside !== undefined ? (_jsx("div", { "data-testid": "listings-detail-aside", children: props.aside })) : null;
420
- if (!split) {
421
- // The single column. `"end"` is the order it has always read —
422
- // the host's aside joins where the footer's flow already is;
423
- // `"after-actions"` puts the seller beside the decision to
424
- // contact them, which is where the split layout already has it.
425
- const asideAfterActions = props.asidePlacement === "after-actions";
426
- return (_jsxs(_Fragment, { children: [statusBlocks, gallery, heading, price, buyBox, actionError, asideAfterActions ? aside : null, _jsx(Divider, {}), description, specsSection, meta, asideAfterActions ? null : aside, props.footer, clusterLayer] }));
427
- }
428
- return (_jsxs(_Fragment, { children: [statusBlocks, _jsxs("div", { "data-testid": "listings-detail-split", style: {
429
- display: "grid",
430
- // The reading column takes what is left and may shrink
431
- // (`minmax(0, )`, or a long unbroken title widens the
432
- // track past the pane); the buy column's track is fixed
433
- // see DETAIL_SPLIT_ASIDE for why it is not a fraction.
434
- gridTemplateColumns: `minmax(0, 1fr) ${DETAIL_SPLIT_ASIDE}`,
435
- gap: spacing[5],
436
- alignItems: "start",
437
- }, children: [_jsxs(Flex, { vertical: true, gap: spacing[4], children: [gallery, heading, _jsx(Divider, {}), description, specsSection, meta, props.footer] }), _jsxs(Flex, { vertical: true, gap: spacing[3], "data-testid": "listings-detail-buy-column", style: {
438
- position: "sticky",
439
- top: props.buyTop ?? spacing[4],
440
- alignSelf: "start",
441
- }, children: [price, buyBox, actionError, aside] })] }), clusterLayer] }));
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",
518
+ /* …and the same reset the price carries: the heading's own
519
+ margin is a second opinion about the column's gap. */
520
+ style: { minWidth: 0, flex: "1 1 auto", ...DETAIL_TITLE_RESET }, 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
521
+ // A hidden value is never part of a title: the server
522
+ // keeps one out of `features_title` entirely, and
523
+ // `formatFeatureValue` refuses a stub besides (it carries
524
+ // no value, so there is nothing to format). The filter is
525
+ // the third belt, and it is here rather than at the
526
+ // formatter's edge because THIS is the line where a
527
+ // leaked identifier would be read out loud.
528
+ .filter((view) => !isRedactedValue(view.value))
529
+ .map((view) => formatSpecValue(view.feature, view.value, { t, locale }))
530
+ .filter((text) => text !== undefined)
531
+ .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] }));
532
+ /* In the split the price leads the buy column at level 2 — the
533
+ measured page had it at 22px UNDER the title, smaller than the
534
+ thing it prices, which is backwards on the one line a buyer
535
+ came to read. In the column it stays the level-4 line it has
536
+ always been. */
537
+ const price = (_jsx(Typography.Title, { level: split ? 2 : 4, "data-testid": "listings-detail-price",
538
+ /* The column's declared gap, and nothing else, between the
539
+ price and the button under it antd's heading margin
540
+ outranks the column's reset, so it is answered here. See
541
+ {@link DETAIL_TITLE_RESET}. */
542
+ style: DETAIL_TITLE_RESET, children: _jsx(ListingPrice, { amount: listing.price, ...(listing.currency !== undefined
543
+ ? { currency: listing.currency }
544
+ : {}) }) }));
545
+ /* The buy box. One primary, and which one depends on who is
546
+ reading this page. */
547
+ 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: () => {
548
+ props.onEdit?.(props.id);
549
+ }, 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: () => {
550
+ props.onQuickQuestion?.(question);
551
+ }, children: question }, question))) })] })), _jsx("div", { "data-testid": "listings-detail-contact", children: props.contactSlot ?? _jsx(SlotPlaceholder, { name: "contactSlot" }) })] })), placement === "buy-box" ? homeActions : null, actionsNode] }));
552
+ 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" })] }));
553
+ const description = (_jsxs(_Fragment, { children: [_jsx(Typography.Title, { level: 5, style: DETAIL_TITLE_RESET, children: t(LISTINGS_I18N_KEYS.detailDescription) }), _jsx(Typography.Paragraph, { "data-testid": "listings-detail-description", children: listing.description ?? "" })] }));
554
+ /* The DISPLAY envelope, not the edit one: a redacted row keeps
555
+ its place in the table and says the seller supplied the value.
556
+ `featuresDtoFromDaoList` deliberately drops a stub, because it
557
+ is what seeds a composer. */
558
+ const specValues = featureValuesForDisplay(asFeatureDaoList(listing.features), props.categoryFeatures !== undefined
559
+ ? { categoryFeatures: props.categoryFeatures }
560
+ : {});
561
+ /* Two spec columns in the split — a grid of whole ROWS, cut by
562
+ row count so the category's declaration order survives: the
563
+ first (larger) half fills the left list and the page reads
564
+ top-to-bottom, left column first, exactly as the one-column
565
+ list reads. The label is never a column of its own; see
566
+ `<ListingSpecList>` for the shape and why the table went. */
567
+ const specFeatures = bag.features.map((view) => view.feature);
568
+ 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
569
+ ? { limit: props.characteristicsLimit }
570
+ : {}) }));
571
+ const specsSection = (_jsxs(_Fragment, { children: [_jsx(Typography.Title, { level: 5, style: DETAIL_TITLE_RESET, 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, {
572
+ count: bag.unreadableFeatures,
573
+ }) })) : null] }));
574
+ const hasStock = listing.stock_quantity != null;
575
+ const hasPlace = listing.location_label !== undefined &&
576
+ listing.location_label.length > 0;
577
+ // Both rows absent draws an empty table, which the view count used
578
+ // to hide by almost always being there. Nothing is nothing.
579
+ 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] }));
580
+ const aside = props.aside !== undefined ? (_jsx("div", { "data-testid": "listings-detail-aside", children: props.aside })) : null;
581
+ /* THE TWO "FIND MORE" SECTIONS — see `<ListingRelatedStrip>` for
582
+ why the rows arrive from the host and are not read here.
583
+
584
+ The render prop wins over the rows for each strip separately: a
585
+ host may have a catalogue-link widget for "similar" and a plain
586
+ list of the seller's other listings, which is exactly the pair
587
+ the reference draws. Neither is rendered empty. */
588
+ const related = {
589
+ listingId: props.id,
590
+ ...(listing.category_id !== undefined && listing.category_id !== null
591
+ ? { categoryId: listing.category_id }
592
+ : { categoryId: undefined }),
593
+ ownerKey: listing.owner,
594
+ axes: bag.titleFeatures,
595
+ };
596
+ 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
597
+ ? { showAllHref: props.similarHref }
598
+ : {}), ...(props.listingHref !== undefined
599
+ ? { listingHref: props.listingHref }
600
+ : {}), ...(props.linkComponent !== undefined
601
+ ? { linkComponent: props.linkComponent }
602
+ : {}) })), 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
603
+ ? { showAllHref: props.fromSellerHref }
604
+ : {}), ...(props.listingHref !== undefined
605
+ ? { listingHref: props.listingHref }
606
+ : {}), ...(props.linkComponent !== undefined
607
+ ? { linkComponent: props.linkComponent }
608
+ : {}) }))] }));
609
+ if (!split) {
610
+ // The single column. `"end"` is the order it has always read —
611
+ // the host's aside joins where the footer's flow already is;
612
+ // `"after-actions"` puts the seller beside the decision to
613
+ // contact them, which is where the split layout already has it.
614
+ const asideAfterActions = props.asidePlacement === "after-actions";
615
+ 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] }));
616
+ }
617
+ return (_jsxs(_Fragment, { children: [statusBlocks, _jsxs("div", { "data-testid": "listings-detail-split", style: {
618
+ display: "grid",
619
+ // The reading column takes what is left and may shrink
620
+ // (`minmax(0, …)`, or a long unbroken title widens the
621
+ // track past the pane); the buy column's track is fixed —
622
+ // see DETAIL_SPLIT_ASIDE for why it is not a fraction.
623
+ gridTemplateColumns: `minmax(0, 1fr) ${DETAIL_SPLIT_ASIDE}`,
624
+ gap: spacing[5],
625
+ alignItems: "start",
626
+ }, 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: {
627
+ position: "sticky",
628
+ top: props.buyTop ?? spacing[4],
629
+ alignSelf: "start",
630
+ }, children: [price, buyBox, actionError, aside] })] }), clusterLayer] }));
631
+ },
632
+ })] })] }));
444
633
  }
445
634
  //# sourceMappingURL=ListingDetailPane.js.map