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