@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.
- package/CHANGELOG.md +93 -0
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +2 -1
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/GateReasonPopover.d.ts +24 -0
- package/dist/default/GateReasonPopover.d.ts.map +1 -0
- package/dist/default/GateReasonPopover.js +45 -0
- package/dist/default/GateReasonPopover.js.map +1 -0
- package/dist/default/ListingCard.d.ts +26 -5
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +16 -6
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +9 -2
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +60 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +137 -39
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/favorite.js +1 -1
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts +11 -0
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +7 -0
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +11 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +15 -1
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -1
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +4 -1
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FavoritesPane.tsx +2 -1
- package/src/default/GateReasonPopover.tsx +95 -0
- package/src/default/ListingCard.tsx +102 -44
- package/src/default/ListingComposerPage.tsx +13 -3
- package/src/default/ListingDetailPane.tsx +364 -115
- package/src/default/favorite.tsx +1 -1
- package/src/default/index.ts +9 -1
- package/src/headless/ListingDetail.tsx +19 -0
- package/src/i18n/es.ts +5 -1
- package/src/i18n/keys.ts +16 -1
- 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
|
-
*
|
|
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 {
|
|
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={{
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
311
|
+
const heading = (
|
|
312
|
+
<>
|
|
313
|
+
<Typography.Title level={3} data-testid="listings-detail-title">
|
|
314
|
+
{listing.title ?? ""}
|
|
315
|
+
</Typography.Title>
|
|
226
316
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
307
|
-
|
|
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
|
-
</
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
{
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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>
|
package/src/default/favorite.tsx
CHANGED
|
@@ -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";
|
package/src/default/index.ts
CHANGED
|
@@ -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 {
|
|
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,
|