@stapel/listings-react 0.15.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/default/FavoritesPane.d.ts.map +1 -1
  3. package/dist/default/FavoritesPane.js +2 -1
  4. package/dist/default/FavoritesPane.js.map +1 -1
  5. package/dist/default/GateReasonPopover.d.ts +24 -0
  6. package/dist/default/GateReasonPopover.d.ts.map +1 -0
  7. package/dist/default/GateReasonPopover.js +45 -0
  8. package/dist/default/GateReasonPopover.js.map +1 -0
  9. package/dist/default/ListingCard.d.ts +26 -5
  10. package/dist/default/ListingCard.d.ts.map +1 -1
  11. package/dist/default/ListingCard.js +16 -6
  12. package/dist/default/ListingCard.js.map +1 -1
  13. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  14. package/dist/default/ListingComposerPage.js +9 -2
  15. package/dist/default/ListingComposerPage.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +60 -1
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +137 -39
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/favorite.js +1 -1
  21. package/dist/default/favorite.js.map +1 -1
  22. package/dist/default/index.d.ts +1 -1
  23. package/dist/default/index.d.ts.map +1 -1
  24. package/dist/default/index.js +4 -1
  25. package/dist/default/index.js.map +1 -1
  26. package/dist/headless/ListingDetail.d.ts +11 -0
  27. package/dist/headless/ListingDetail.d.ts.map +1 -1
  28. package/dist/headless/ListingDetail.js +7 -0
  29. package/dist/headless/ListingDetail.js.map +1 -1
  30. package/dist/i18n/es.d.ts.map +1 -1
  31. package/dist/i18n/es.js +4 -1
  32. package/dist/i18n/es.js.map +1 -1
  33. package/dist/i18n/keys.d.ts +11 -0
  34. package/dist/i18n/keys.d.ts.map +1 -1
  35. package/dist/i18n/keys.js +15 -1
  36. package/dist/i18n/keys.js.map +1 -1
  37. package/dist/i18n/ru.d.ts.map +1 -1
  38. package/dist/i18n/ru.js +4 -1
  39. package/dist/i18n/ru.js.map +1 -1
  40. package/llms.txt +1 -1
  41. package/manifest.json +4 -1
  42. package/nav-manifest.json +1 -1
  43. package/package.json +8 -8
  44. package/src/analytics/generated/events.json +1 -1
  45. package/src/default/FavoritesPane.tsx +2 -1
  46. package/src/default/GateReasonPopover.tsx +95 -0
  47. package/src/default/ListingCard.tsx +102 -44
  48. package/src/default/ListingComposerPage.tsx +13 -3
  49. package/src/default/ListingDetailPane.tsx +364 -115
  50. package/src/default/favorite.tsx +1 -1
  51. package/src/default/index.ts +9 -1
  52. package/src/headless/ListingDetail.tsx +19 -0
  53. package/src/i18n/es.ts +5 -1
  54. package/src/i18n/keys.ts +16 -1
  55. package/src/i18n/ru.ts +5 -1
@@ -2,7 +2,7 @@
2
2
  * `<ListingDetailPane>` — the listing page, and the page a marketplace makes
3
3
  * its money on.
4
4
  *
5
- * Four distinct absences, four distinct sentences, and the whole point of the
5
+ * Five distinct absences, five distinct sentences, and the whole point of the
6
6
  * component is that none of them collapses into another:
7
7
  *
8
8
  * loading — we are asking
@@ -10,6 +10,9 @@
10
10
  * not found — no listing ever had this id
11
11
  * removed — one did, and it is gone (the AllowAny status probe
12
12
  * is the only read that can still say so)
13
+ * withdrawn — one did, and its owner took it off the shelf: the
14
+ * detail 404s while the probe still answers "not
15
+ * deleted". NO retry — nothing a retry could change.
13
16
  *
14
17
  * On top of that, a listing that IS returned may still not be on sale: the
15
18
  * detail endpoint has no `published()` filter, so a draft answers 200 to
@@ -32,9 +35,24 @@
32
35
  * L2 pairs do not import each other. Unfilled it renders `<SlotPlaceholder>`,
33
36
  * so an app wired without a chat is a named gap in a dev build rather than a
34
37
  * page whose only verb is "save".
38
+ *
39
+ * ── The page has a DESKTOP, when the host says so ──────────────────────────
40
+ *
41
+ * Measured on a live classified deployment at 1440×900: the whole listing
42
+ * page was a ~930px single column hugging the start edge, the price a 22px
43
+ * line UNDER the title and smaller than it, and the right half of the screen
44
+ * empty — while the reference design for this page is two columns: gallery +
45
+ * description + specs on the left, a sticky buy column on the right with the
46
+ * price LARGE at its top, then the actions, then the seller block.
47
+ *
48
+ * `layout="split"` is that design. The HOST states the axis — the same rule
49
+ * as CategoryPage's `subcategories`: a decision taken once by the component
50
+ * that knows the viewport it granted, never a media query guessed in a leaf —
51
+ * and the default `"column"` renders exactly what existing hosts already get.
35
52
  */
36
53
  import type { ReactElement, ReactNode } from "react";
37
- import { Button, Descriptions, Divider, Flex, Typography } from "antd";
54
+ import { Descriptions, Divider, Flex, Typography } from "antd";
55
+ import { SkinButton as Button } from "@stapel/tokens-antd/skin";
38
56
  import {
39
57
  ErrorAlert,
40
58
  EmptyState,
@@ -45,6 +63,7 @@ import {
45
63
  import {
46
64
  SlotPlaceholder,
47
65
  matchLoad,
66
+ useActionGate,
48
67
  useI18n,
49
68
  useT,
50
69
  } from "@stapel/core";
@@ -56,6 +75,7 @@ import { useListingDetail } from "../headless/ListingDetail.js";
56
75
  import { useListingActions } from "../headless/ListingActions.js";
57
76
  import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
58
77
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
78
+ import { GateReasonPopover } from "./GateReasonPopover.js";
59
79
  import { SignInLink } from "./SignInLink.js";
60
80
  import { HeartIcon } from "./icons.js";
61
81
  import { ListingPhoto } from "./ListingPhoto.js";
@@ -68,6 +88,24 @@ import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
68
88
  * a 2560px pane. */
69
89
  export const DETAIL_MEASURE = "60rem";
70
90
 
91
+ /**
92
+ * The split layout's measure. {@link DETAIL_MEASURE} is a ONE-COLUMN reading
93
+ * measure; in the split the same prose shares the row with a fixed buy
94
+ * column and a gap, so the pane must be wider for the reading half to keep
95
+ * its line — 75rem puts the left column back at roughly the width the
96
+ * one-column page reads at, with the buy column beside it instead of the
97
+ * empty half-screen the 1440×900 walk measured.
98
+ */
99
+ export const DETAIL_SPLIT_MEASURE = "75rem";
100
+
101
+ /**
102
+ * The buy column's fixed track. A width, not a fraction: a price, a row of
103
+ * buttons and a seller block do not improve with width, and every pixel they
104
+ * took would come out of the reading column — so the reading column is the
105
+ * `1fr` and this is not.
106
+ */
107
+ export const DETAIL_SPLIT_ASIDE = "380px";
108
+
71
109
  /** The narrowest a gallery tile may get before the grid drops a column. A
72
110
  * measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
73
111
  * which is §83's geometry rule — one photo per row on a phone, three on a
@@ -83,6 +121,24 @@ export interface ListingDetailPaneProps
83
121
  * the only place the moderation axis is shown, because it is the only
84
122
  * person it concerns. */
85
123
  readonly viewerId?: string;
124
+ /**
125
+ * Which desktop the page renders: the single reading column it has always
126
+ * been (default `"column"`, byte-compatible for existing hosts), or the
127
+ * reference design's two-column split — see this file's header for the
128
+ * measurement that earned it. The host states the axis; the pane never
129
+ * reads the viewport.
130
+ */
131
+ readonly layout?: "column" | "split";
132
+ /**
133
+ * The host's seller block (a profile card, ratings, "member since" — a
134
+ * different pair's data, so it arrives as a node). In `"split"` it renders
135
+ * inside the sticky buy column, under the actions, where the reference
136
+ * design keeps it. In `"column"` there is no buy column to live in, so it
137
+ * joins the end of the reading flow directly ABOVE `footer` — the flow
138
+ * position the footer already holds, so a host passing both gets seller
139
+ * block then footer, in that order.
140
+ */
141
+ readonly aside?: ReactNode;
86
142
  /**
87
143
  * THE primary action for a buyer: "message the seller", filled by the
88
144
  * container from `@stapel/chat-react`. Rendered first, before favouriting,
@@ -105,6 +161,14 @@ export interface ListingDetailPaneProps
105
161
  * cards. Absent: the reason stands alone, as before.
106
162
  */
107
163
  readonly signIn?: SignInCta;
164
+ /**
165
+ * How the favourite's blocked reason speaks: `"text"` (default) keeps the
166
+ * standing sentence + door beside the heart; `"popover"` moves both into a
167
+ * disclosure on the heart itself — the cards' third arm, same argument and
168
+ * same accessibility floor (see `ListingCardBlockedReason`), for a host
169
+ * whose chrome already carries a standing sign-in door.
170
+ */
171
+ readonly blockedReason?: "text" | "popover";
108
172
  readonly footer?: ReactNode;
109
173
  }
110
174
 
@@ -120,6 +184,10 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
120
184
  const owner = bag.viewerIsOwner === true;
121
185
  const actions = useListingActions(props.id, bag.status?.lifecycle.status);
122
186
  const editGate = actions.editGate(props.onEdit !== undefined);
187
+ // The gate VIEW (localized reason), for the popover arm — the "text" arm
188
+ // leaves rendering the reason to `<GatedControl>`, which computes its own.
189
+ const favoriteView = useActionGate(bag.favoriteGate);
190
+ const split = props.layout === "split";
123
191
 
124
192
  const favoriteLabel = t(
125
193
  bag.isFavorited === true
@@ -130,7 +198,10 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
130
198
  return (
131
199
  <SkinTheme
132
200
  surface="base"
133
- style={{ maxWidth: DETAIL_MEASURE, padding: spacing[4] }}
201
+ style={{
202
+ maxWidth: split ? DETAIL_SPLIT_MEASURE : DETAIL_MEASURE,
203
+ padding: spacing[4],
204
+ }}
134
205
  {...(props.mode !== undefined ? { mode: props.mode } : {})}
135
206
  >
136
207
  <Flex vertical gap={spacing[4]} data-testid="listings-detail">
@@ -151,12 +222,22 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
151
222
  data-stapel-load-state="loading"
152
223
  />
153
224
  ),
225
+ // The failed arm, most specific sentence first: removed (the banner
226
+ // above already says it) → not found → withdrawn → the generic
227
+ // retry. `withdrawn` carries no retry control on purpose — the row
228
+ // is gone by its owner's choice, and a retry that can never help is
229
+ // what this arm replaces.
154
230
  failed: (error) =>
155
231
  bag.removed ? null : bag.notFound ? (
156
232
  <EmptyState
157
233
  testId="listings-detail-error"
158
234
  title={t(LISTINGS_I18N_KEYS.detailNotFound)}
159
235
  />
236
+ ) : bag.withdrawn ? (
237
+ <EmptyState
238
+ testId="listings-detail-withdrawn"
239
+ title={t(LISTINGS_I18N_KEYS.detailWithdrawn)}
240
+ />
160
241
  ) : (
161
242
  <ErrorAlert
162
243
  testId="listings-detail-error"
@@ -166,33 +247,39 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
166
247
  retryLabel={t(LISTINGS_I18N_KEYS.detailRetry)}
167
248
  />
168
249
  ),
169
- ready: (listing) => (
170
- <>
171
- {/* The moderation axis is the OWNER's business and nobody
172
- else's: a buyer has no use for "changes under review", and
173
- showing a stranger that a listing was refused would leak a
174
- verdict about someone else's content. */}
175
- {owner && bag.status !== undefined ? (
176
- <Flex vertical gap={spacing[2]} data-testid="listings-detail-owner-view">
177
- <ListingStatusBlock status={bag.status} />
178
- {!bag.publiclyVisible ? (
179
- <Typography.Text type="secondary">
180
- {t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
181
- </Typography.Text>
182
- ) : null}
183
- </Flex>
184
- ) : null}
250
+ ready: (listing) => {
251
+ /* The moderation axis is the OWNER's business and nobody
252
+ else's: a buyer has no use for "changes under review", and
253
+ showing a stranger that a listing was refused would leak a
254
+ verdict about someone else's content. Rendered above the
255
+ split, full width — a verdict is about the PAGE, not about
256
+ either of its columns. */
257
+ const statusBlocks = (
258
+ <>
259
+ {owner && bag.status !== undefined ? (
260
+ <Flex vertical gap={spacing[2]} data-testid="listings-detail-owner-view">
261
+ <ListingStatusBlock status={bag.status} />
262
+ {!bag.publiclyVisible ? (
263
+ <Typography.Text type="secondary">
264
+ {t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
265
+ </Typography.Text>
266
+ ) : null}
267
+ </Flex>
268
+ ) : null}
185
269
 
186
- {!owner && !bag.publiclyVisible ? (
187
- <ErrorAlert
188
- testId="listings-detail-not-published"
189
- message={t(LISTINGS_I18N_KEYS.detailNotPublished)}
190
- variant="inline"
191
- />
192
- ) : null}
270
+ {!owner && !bag.publiclyVisible ? (
271
+ <ErrorAlert
272
+ testId="listings-detail-not-published"
273
+ message={t(LISTINGS_I18N_KEYS.detailNotPublished)}
274
+ variant="inline"
275
+ />
276
+ ) : null}
277
+ </>
278
+ );
193
279
 
194
- {/* Element-width tiles: the grid decides how many fit, the
195
- photos fill them. */}
280
+ /* Element-width tiles: the grid decides how many fit, the
281
+ photos fill them. */
282
+ const gallery = (
196
283
  <div
197
284
  data-testid="listings-detail-gallery"
198
285
  style={{
@@ -219,33 +306,47 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
219
306
  ))
220
307
  )}
221
308
  </div>
309
+ );
222
310
 
223
- <Typography.Title level={3} data-testid="listings-detail-title">
224
- {listing.title ?? ""}
225
- </Typography.Title>
311
+ const heading = (
312
+ <>
313
+ <Typography.Title level={3} data-testid="listings-detail-title">
314
+ {listing.title ?? ""}
315
+ </Typography.Title>
226
316
 
227
- {/* The `show_at_title` projection, formatted from the stored
228
- DAOs — no category read needed (see model/features.ts). */}
229
- {bag.titleFeatures.length > 0 ? (
230
- <Typography.Text type="secondary" data-testid="listings-detail-title-features">
231
- {bag.titleFeatures
232
- // A hidden value is never part of a title: the server
233
- // keeps one out of `features_title` entirely, and
234
- // `formatFeatureValue` refuses a stub besides (it carries
235
- // no value, so there is nothing to format). The filter is
236
- // the third belt, and it is here rather than at the
237
- // formatter's edge because THIS is the line where a
238
- // leaked identifier would be read out loud.
239
- .filter((view) => !isRedactedValue(view.value))
240
- .map((view) =>
241
- formatFeatureValue(view.feature, view.value, { t, locale })
242
- )
243
- .filter((text): text is string => text !== undefined)
244
- .join(" · ")}
245
- </Typography.Text>
246
- ) : null}
317
+ {/* The `show_at_title` projection, formatted from the stored
318
+ DAOs — no category read needed (see model/features.ts). */}
319
+ {bag.titleFeatures.length > 0 ? (
320
+ <Typography.Text type="secondary" data-testid="listings-detail-title-features">
321
+ {bag.titleFeatures
322
+ // A hidden value is never part of a title: the server
323
+ // keeps one out of `features_title` entirely, and
324
+ // `formatFeatureValue` refuses a stub besides (it carries
325
+ // no value, so there is nothing to format). The filter is
326
+ // the third belt, and it is here rather than at the
327
+ // formatter's edge because THIS is the line where a
328
+ // leaked identifier would be read out loud.
329
+ .filter((view) => !isRedactedValue(view.value))
330
+ .map((view) =>
331
+ formatFeatureValue(view.feature, view.value, { t, locale })
332
+ )
333
+ .filter((text): text is string => text !== undefined)
334
+ .join(" · ")}
335
+ </Typography.Text>
336
+ ) : null}
337
+ </>
338
+ );
247
339
 
248
- <Typography.Title level={4} data-testid="listings-detail-price">
340
+ /* In the split the price leads the buy column at level 2 — the
341
+ measured page had it at 22px UNDER the title, smaller than the
342
+ thing it prices, which is backwards on the one line a buyer
343
+ came to read. In the column it stays the level-4 line it has
344
+ always been. */
345
+ const price = (
346
+ <Typography.Title
347
+ level={split ? 2 : 4}
348
+ data-testid="listings-detail-price"
349
+ >
249
350
  <ListingPrice
250
351
  amount={listing.price}
251
352
  {...(listing.currency !== undefined
@@ -253,9 +354,11 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
253
354
  : {})}
254
355
  />
255
356
  </Typography.Title>
357
+ );
256
358
 
257
- {/* The buy box. One primary, and which one depends on who is
258
- reading this page. */}
359
+ /* The buy box. One primary, and which one depends on who is
360
+ reading this page. */
361
+ const buyBox = (
259
362
  <Flex
260
363
  wrap
261
364
  gap={spacing[3]}
@@ -295,19 +398,23 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
295
398
 
296
399
  {/* Favouriting your own listing is not a thing anyone does;
297
400
  for everyone else it is the secondary it always was. */}
298
- {owner ? null : (
299
- <Flex vertical gap={spacing[1]}>
300
- <GatedControl
301
- gate={bag.favoriteGate}
302
- testId="listings-detail-favorite-gate"
401
+ {owner ? null : props.blockedReason === "popover" &&
402
+ favoriteView.reason !== undefined ? (
403
+ /* The cards' third volume, verbatim: nothing standing, the
404
+ reason and the door disclosed on the heart. `aria-disabled`
405
+ rather than `disabled`, so the disclosure's hover, focus
406
+ and tap all arrive — and the click is a safe no-op, because
407
+ `toggleFavorite` refuses while the gate is blocked. */
408
+ <GateReasonPopover
409
+ reason={favoriteView.reason}
410
+ cta={props.signIn}
411
+ testId="listings-detail-favorite-reason"
412
+ signInTestId="listings-detail-sign-in"
303
413
  >
304
414
  {(bind) => (
305
415
  <Button
306
- disabled={bind.disabled}
307
- data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
308
- {...(bind["aria-describedby"] !== undefined
309
- ? { "aria-describedby": bind["aria-describedby"] }
310
- : {})}
416
+ aria-disabled
417
+ {...bind}
311
418
  aria-label={favoriteLabel}
312
419
  aria-pressed={bag.isFavorited === true}
313
420
  icon={<HeartIcon filled={bag.isFavorited === true} />}
@@ -319,77 +426,146 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
319
426
  {favoriteLabel}
320
427
  </Button>
321
428
  )}
322
- </GatedControl>
323
- {/* The door. `GatedControl` prints the reason; where a
324
- visitor signs in is the container's, and arrives as
325
- `signIn` — the cards' own pattern, verbatim. */}
326
- {bag.favoriteGate.available ? null : (
327
- <Typography.Text
328
- type="secondary"
329
- data-testid="listings-detail-favorite-blocked"
429
+ </GateReasonPopover>
430
+ ) : (
431
+ <Flex vertical gap={spacing[1]}>
432
+ <GatedControl
433
+ gate={bag.favoriteGate}
434
+ testId="listings-detail-favorite-gate"
330
435
  >
331
- <SignInLink cta={props.signIn} testId="listings-detail-sign-in" />
332
- </Typography.Text>
333
- )}
436
+ {(bind) => (
437
+ <Button
438
+ disabled={bind.disabled}
439
+ data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
440
+ {...(bind["aria-describedby"] !== undefined
441
+ ? { "aria-describedby": bind["aria-describedby"] }
442
+ : {})}
443
+ aria-label={favoriteLabel}
444
+ aria-pressed={bag.isFavorited === true}
445
+ icon={<HeartIcon filled={bag.isFavorited === true} />}
446
+ data-testid="listings-detail-favorite"
447
+ data-analytics="none"
448
+ data-analytics-reason="business action — host app wraps with its own tracked()"
449
+ onClick={bag.toggleFavorite}
450
+ >
451
+ {favoriteLabel}
452
+ </Button>
453
+ )}
454
+ </GatedControl>
455
+ {/* The door. `GatedControl` prints the reason; where a
456
+ visitor signs in is the container's, and arrives as
457
+ `signIn` — the cards' own pattern, verbatim. */}
458
+ {bag.favoriteGate.available ? null : (
459
+ <Typography.Text
460
+ type="secondary"
461
+ data-testid="listings-detail-favorite-blocked"
462
+ >
463
+ <SignInLink cta={props.signIn} testId="listings-detail-sign-in" />
464
+ </Typography.Text>
465
+ )}
334
466
  </Flex>
335
467
  )}
336
468
 
337
469
  {props.actions}
338
470
  </Flex>
471
+ );
339
472
 
340
- {actions.error !== undefined && actions.error !== null ? (
473
+ const actionError =
474
+ actions.error !== undefined && actions.error !== null ? (
341
475
  <ErrorAlert
342
476
  testId="listings-detail-action-error"
343
477
  thrown={actions.error}
344
478
  variant="inline"
345
479
  />
346
- ) : null}
480
+ ) : null;
347
481
 
348
- <Divider />
482
+ const description = (
483
+ <>
484
+ <Typography.Title level={5}>
485
+ {t(LISTINGS_I18N_KEYS.detailDescription)}
486
+ </Typography.Title>
487
+ <Typography.Paragraph data-testid="listings-detail-description">
488
+ {listing.description ?? ""}
489
+ </Typography.Paragraph>
490
+ </>
491
+ );
349
492
 
350
- <Typography.Title level={5}>
351
- {t(LISTINGS_I18N_KEYS.detailDescription)}
352
- </Typography.Title>
353
- <Typography.Paragraph data-testid="listings-detail-description">
354
- {listing.description ?? ""}
355
- </Typography.Paragraph>
356
-
357
- <Typography.Title level={5}>
358
- {t(LISTINGS_I18N_KEYS.detailSpecs)}
359
- </Typography.Title>
360
- {bag.features.length === 0 ? (
493
+ /* The DISPLAY envelope, not the edit one: a redacted row keeps
494
+ its place in the table and says the seller supplied the value.
495
+ `featuresDtoFromDaoList` deliberately drops a stub, because it
496
+ is what seeds a composer. */
497
+ const specValues = featureValuesForDisplay(
498
+ asFeatureDaoList(listing.features),
499
+ props.categoryFeatures !== undefined
500
+ ? { categoryFeatures: props.categoryFeatures }
501
+ : {}
502
+ );
503
+ /* Two spec columns in the split, split by ROW COUNT and not by
504
+ `<FeatureValueList>` — the halves are cut HERE so the category's
505
+ declaration order survives: the first (larger) half fills the
506
+ left list, and the table reads top-to-bottom, left column
507
+ first, exactly as the one-column table read. */
508
+ const specHalf = Math.ceil(bag.features.length / 2);
509
+ const specs =
510
+ bag.features.length === 0 ? (
361
511
  <Typography.Text type="secondary" data-testid="listings-detail-no-specs">
362
512
  {t(LISTINGS_I18N_KEYS.detailNoSpecs)}
363
513
  </Typography.Text>
514
+ ) : split ? (
515
+ <div
516
+ data-testid="listings-detail-specs-split"
517
+ style={{
518
+ display: "grid",
519
+ gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
520
+ gap: spacing[4],
521
+ alignItems: "start",
522
+ }}
523
+ >
524
+ <FeatureValueList
525
+ features={bag.features
526
+ .slice(0, specHalf)
527
+ .map((view) => view.feature)}
528
+ values={specValues}
529
+ />
530
+ {bag.features.length > specHalf ? (
531
+ <FeatureValueList
532
+ features={bag.features
533
+ .slice(specHalf)
534
+ .map((view) => view.feature)}
535
+ values={specValues}
536
+ />
537
+ ) : null}
538
+ </div>
364
539
  ) : (
365
540
  <FeatureValueList
366
541
  features={bag.features.map((view) => view.feature)}
367
- // The DISPLAY envelope, not the edit one: a redacted row
368
- // keeps its place in the table and says the seller supplied
369
- // the value. `featuresDtoFromDaoList` deliberately drops a
370
- // stub, because it is what seeds a composer.
371
- values={featureValuesForDisplay(
372
- asFeatureDaoList(listing.features),
373
- props.categoryFeatures !== undefined
374
- ? { categoryFeatures: props.categoryFeatures }
375
- : {}
376
- )}
542
+ values={specValues}
377
543
  />
378
- )}
544
+ );
379
545
 
380
- {/* Counted, not rounded to zero: a stored attribute this build
381
- cannot key is a gap in what the buyer is being told. */}
382
- {bag.unreadableFeatures > 0 ? (
383
- <Typography.Text
384
- type="warning"
385
- data-testid="listings-detail-unreadable"
386
- >
387
- {t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
388
- count: bag.unreadableFeatures,
389
- })}
390
- </Typography.Text>
391
- ) : null}
546
+ const specsSection = (
547
+ <>
548
+ <Typography.Title level={5}>
549
+ {t(LISTINGS_I18N_KEYS.detailSpecs)}
550
+ </Typography.Title>
551
+ {specs}
392
552
 
553
+ {/* Counted, not rounded to zero: a stored attribute this build
554
+ cannot key is a gap in what the buyer is being told. */}
555
+ {bag.unreadableFeatures > 0 ? (
556
+ <Typography.Text
557
+ type="warning"
558
+ data-testid="listings-detail-unreadable"
559
+ >
560
+ {t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
561
+ count: bag.unreadableFeatures,
562
+ })}
563
+ </Typography.Text>
564
+ ) : null}
565
+ </>
566
+ );
567
+
568
+ const meta = (
393
569
  <Descriptions size="small" column={1}>
394
570
  {/* Label cell and value cell, which is what a `<Descriptions>`
395
571
  row IS: the label key carries no `{count}` (it did, and the
@@ -410,10 +586,83 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
410
586
  </Descriptions.Item>
411
587
  ) : null}
412
588
  </Descriptions>
589
+ );
413
590
 
414
- {props.footer}
415
- </>
416
- ),
591
+ const aside =
592
+ props.aside !== undefined ? (
593
+ <div data-testid="listings-detail-aside">{props.aside}</div>
594
+ ) : null;
595
+
596
+ if (!split) {
597
+ // The single column, in the order it has always read — the
598
+ // host's aside joins where the footer's flow already is.
599
+ return (
600
+ <>
601
+ {statusBlocks}
602
+ {gallery}
603
+ {heading}
604
+ {price}
605
+ {buyBox}
606
+ {actionError}
607
+ <Divider />
608
+ {description}
609
+ {specsSection}
610
+ {meta}
611
+ {aside}
612
+ {props.footer}
613
+ </>
614
+ );
615
+ }
616
+
617
+ return (
618
+ <>
619
+ {statusBlocks}
620
+ <div
621
+ data-testid="listings-detail-split"
622
+ style={{
623
+ display: "grid",
624
+ // The reading column takes what is left and may shrink
625
+ // (`minmax(0, …)`, or a long unbroken title widens the
626
+ // track past the pane); the buy column's track is fixed —
627
+ // see DETAIL_SPLIT_ASIDE for why it is not a fraction.
628
+ gridTemplateColumns: `minmax(0, 1fr) ${DETAIL_SPLIT_ASIDE}`,
629
+ gap: spacing[5],
630
+ alignItems: "start",
631
+ }}
632
+ >
633
+ <Flex vertical gap={spacing[4]}>
634
+ {gallery}
635
+ {heading}
636
+ <Divider />
637
+ {description}
638
+ {specsSection}
639
+ {meta}
640
+ {props.footer}
641
+ </Flex>
642
+ {/* Sticky, so the actions ride along a page whose left
643
+ column is as tall as the seller's photo set. `alignSelf:
644
+ "start"` is load-bearing: a grid item stretches to the
645
+ row's height by default, and an element as tall as its
646
+ scroll container has nowhere to stick. */}
647
+ <Flex
648
+ vertical
649
+ gap={spacing[3]}
650
+ data-testid="listings-detail-buy-column"
651
+ style={{
652
+ position: "sticky",
653
+ top: spacing[4],
654
+ alignSelf: "start",
655
+ }}
656
+ >
657
+ {price}
658
+ {buyBox}
659
+ {actionError}
660
+ {aside}
661
+ </Flex>
662
+ </div>
663
+ </>
664
+ );
665
+ },
417
666
  })}
418
667
  </Flex>
419
668
  </SkinTheme>
@@ -26,7 +26,7 @@
26
26
  * list or a grid of these cards should wrap it in one.
27
27
  */
28
28
  import type { CSSProperties, ReactElement } from "react";
29
- import { Button } from "antd";
29
+ import { SkinButton as Button } from "@stapel/tokens-antd/skin";
30
30
  import { GatedControl } from "@stapel/tokens-antd/skin";
31
31
  import { useT } from "@stapel/core";
32
32
  import { useFavoriteToggle } from "../headless/Favorites.js";
@@ -59,7 +59,15 @@ export type {
59
59
  } from "./ListingFeedCard.js";
60
60
  export { FeedGrid, FEED_GRID_COLUMNS } from "./FeedGrid.js";
61
61
  export type { FeedGridProps } from "./FeedGrid.js";
62
- export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDetailPane.js";
62
+ export {
63
+ ListingDetailPane,
64
+ DETAIL_MEASURE,
65
+ // The split layout's two geometry constants, for a container laying out
66
+ // against the same tracks (and for a test that measures them).
67
+ DETAIL_SPLIT_MEASURE,
68
+ DETAIL_SPLIT_ASIDE,
69
+ DETAIL_PHOTO_MIN,
70
+ } from "./ListingDetailPane.js";
63
71
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
64
72
  export {
65
73
  ListingComposerPage,