@stapel/listings-react 0.11.0 → 0.12.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 +171 -0
- package/dist/api/types.d.ts +10 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/ListingCard.d.ts +2 -2
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +8 -2
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +22 -62
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +220 -54
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +2 -2
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +6 -1
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts +2 -2
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +7 -2
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/types.d.ts +21 -0
- package/dist/default/types.d.ts.map +1 -1
- package/dist/headless/ListingComposer.d.ts +11 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +18 -1
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts +16 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +13 -4
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +16 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/features.d.ts +90 -20
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +148 -17
- package/dist/model/features.js.map +1 -1
- package/dist/model/validation.d.ts +12 -0
- package/dist/model/validation.d.ts.map +1 -1
- package/dist/model/validation.js +19 -0
- package/dist/model/validation.js.map +1 -1
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +2 -0
- package/dist/nav/manifest.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +6 -1
- package/nav-manifest.json +3 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +10 -0
- package/src/default/ListingCard.tsx +17 -4
- package/src/default/ListingComposerPage.tsx +203 -33
- package/src/default/ListingDetailPane.tsx +10 -6
- package/src/default/ListingSerpCard.tsx +13 -4
- package/src/default/index.ts +7 -2
- package/src/default/types.ts +22 -0
- package/src/headless/ListingComposer.tsx +28 -0
- package/src/headless/ListingDetail.tsx +37 -10
- package/src/i18n/es.ts +5 -0
- package/src/i18n/keys.ts +17 -0
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +1 -0
- package/src/model/features.ts +253 -35
- package/src/model/validation.ts +20 -0
- package/src/nav/manifest.ts +2 -0
package/src/api/types.ts
CHANGED
|
@@ -176,6 +176,16 @@ export interface ListingFeatureDao {
|
|
|
176
176
|
* can lack it and a JSONField will pass that through. */
|
|
177
177
|
readonly type?: ListingFeatureType;
|
|
178
178
|
readonly value?: unknown;
|
|
179
|
+
/**
|
|
180
|
+
* The write-time LABEL SNAPSHOT: the copy each entry of `value` had when the
|
|
181
|
+
* listing was stored, positionally aligned with it, one entry per value.
|
|
182
|
+
* `ref_select`/`ref_hierarchical_select` have always carried it (their codes
|
|
183
|
+
* name vocabulary terms no display package can reach) and `select` carries
|
|
184
|
+
* it from the release that snapshots option copy — so it is OPTIONAL, and a
|
|
185
|
+
* row stored before that one has no key at all. `model/features.ts` reads
|
|
186
|
+
* it, and says what a row without it falls back to.
|
|
187
|
+
*/
|
|
188
|
+
readonly labels?: readonly string[];
|
|
179
189
|
/** Display name or translation key; falls back to the slug. */
|
|
180
190
|
readonly name?: string | null;
|
|
181
191
|
readonly order?: number | null;
|
|
@@ -82,6 +82,7 @@ import { spacing } from "@stapel/tokens";
|
|
|
82
82
|
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
83
83
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
84
84
|
import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
85
|
+
import type { FeatureCopySource } from "../model/features.js";
|
|
85
86
|
import { lifecycleCaption } from "../model/status.js";
|
|
86
87
|
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
87
88
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -89,7 +90,7 @@ import { HeartIcon } from "./icons.js";
|
|
|
89
90
|
import { SignInLink } from "./SignInLink.js";
|
|
90
91
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
91
92
|
import { ListingPrice } from "./ListingPrice.js";
|
|
92
|
-
import type { ThemeModeProp } from "./types.js";
|
|
93
|
+
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
93
94
|
|
|
94
95
|
/**
|
|
95
96
|
* How the card opens — ONE of three, and the type says so.
|
|
@@ -195,7 +196,10 @@ const BUTTON_TARGET_STYLE: CSSProperties = {
|
|
|
195
196
|
cursor: "pointer",
|
|
196
197
|
};
|
|
197
198
|
|
|
198
|
-
export interface ListingCardBaseProps
|
|
199
|
+
export interface ListingCardBaseProps
|
|
200
|
+
extends ThemeModeProp,
|
|
201
|
+
SignInCtaProp,
|
|
202
|
+
CategoryFeaturesProp {
|
|
199
203
|
readonly listing: ListingCardData;
|
|
200
204
|
/** See {@link ListingCardBlockedReason}. Default `"text"`. */
|
|
201
205
|
readonly blockedReason?: ListingCardBlockedReason;
|
|
@@ -309,7 +313,14 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
309
313
|
const { token } = antdTheme.useToken();
|
|
310
314
|
|
|
311
315
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
312
|
-
|
|
316
|
+
// The category's own option table, when the surface has it: a stored
|
|
317
|
+
// `select` carries no table of its own, so without this a badge prints the
|
|
318
|
+
// storage slug. Absent on a mixed grid, which is why it is optional.
|
|
319
|
+
const copy: FeatureCopySource =
|
|
320
|
+
props.categoryFeatures !== undefined
|
|
321
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
322
|
+
: {};
|
|
323
|
+
const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
|
|
313
324
|
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
314
325
|
const titleDaos = asFeatureDaoList(listing.features_title);
|
|
315
326
|
|
|
@@ -370,7 +381,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
370
381
|
{titleDaos.length > 0 ? (
|
|
371
382
|
<Typography.Text type="secondary" ellipsis>
|
|
372
383
|
<FeatureBadges
|
|
373
|
-
features={featuresFromDaoList(titleDaos).map(
|
|
384
|
+
features={featuresFromDaoList(titleDaos, copy).map(
|
|
385
|
+
(view) => view.feature
|
|
386
|
+
)}
|
|
374
387
|
values={featuresDtoFromDaoList(titleDaos)}
|
|
375
388
|
/>
|
|
376
389
|
</Typography.Text>
|
|
@@ -50,6 +50,22 @@
|
|
|
50
50
|
* do not import each other; the container is the seam. A library that picked
|
|
51
51
|
* one would pick it for every host.
|
|
52
52
|
*
|
|
53
|
+
* ── On a narrow form the CHARACTERISTICS come before the photos ────────────
|
|
54
|
+
*
|
|
55
|
+
* The details of the chosen category are the questions only that category
|
|
56
|
+
* asks, and they are the reason a category is chosen at all. On a wide form
|
|
57
|
+
* they can sit after the photos, because the whole form is one screen. On a
|
|
58
|
+
* 390px one they cannot: measured on a live classified deployment, choosing
|
|
59
|
+
* Mobile phones grew the page to 7292px and put the first attribute control at
|
|
60
|
+
* y=1596 — nearly two viewports below the fold, behind a ~700px photo dropzone
|
|
61
|
+
* — while the footer said "10 required details not filled in" with none of
|
|
62
|
+
* them on screen. So on a narrow form the section moves directly under the
|
|
63
|
+
* category that produced it, and the photo dropzone follows it.
|
|
64
|
+
*
|
|
65
|
+
* The measurement is the FORM's own width (`useElementWidth`, the fleet's one
|
|
66
|
+
* observer), not the viewport's: a composer is not a viewport, and a viewport
|
|
67
|
+
* query would call a 360px pane on a desktop "wide".
|
|
68
|
+
*
|
|
53
69
|
* ── Every blocked control says which of six reasons it is ──────────────────
|
|
54
70
|
*
|
|
55
71
|
* The publish button is the most-gated control in the fleet, and that is the
|
|
@@ -60,9 +76,11 @@
|
|
|
60
76
|
* would be told, and `<GatedButton>` renders the reason beside the button —
|
|
61
77
|
* never a grey rectangle, never a hover.
|
|
62
78
|
*/
|
|
79
|
+
import { useRef } from "react";
|
|
63
80
|
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
64
81
|
import {
|
|
65
82
|
Alert,
|
|
83
|
+
Button,
|
|
66
84
|
Checkbox,
|
|
67
85
|
Divider,
|
|
68
86
|
Flex,
|
|
@@ -76,6 +94,7 @@ import {
|
|
|
76
94
|
GatedButton,
|
|
77
95
|
PaneGate,
|
|
78
96
|
SkinTheme,
|
|
97
|
+
useElementWidth,
|
|
79
98
|
} from "@stapel/tokens-antd/skin";
|
|
80
99
|
import {
|
|
81
100
|
SlotPlaceholder,
|
|
@@ -85,11 +104,12 @@ import {
|
|
|
85
104
|
useI18n,
|
|
86
105
|
useT,
|
|
87
106
|
} from "@stapel/core";
|
|
88
|
-
import { spacing } from "@stapel/tokens";
|
|
107
|
+
import { breakpoints, spacing } from "@stapel/tokens";
|
|
89
108
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
90
109
|
import {
|
|
91
110
|
BUILTIN_VALUE_EDITOR_TYPES,
|
|
92
111
|
FeatureFields,
|
|
112
|
+
featureControlId,
|
|
93
113
|
} from "@stapel/attributes-react/default";
|
|
94
114
|
import { useListingComposer } from "../headless/ListingComposer.js";
|
|
95
115
|
import type { ListingLocation } from "../model/draft.js";
|
|
@@ -114,6 +134,61 @@ import type { ThemeModeProp } from "./types.js";
|
|
|
114
134
|
*/
|
|
115
135
|
export const COMPOSER_MEASURE = "44rem";
|
|
116
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Below this FORM width the composer is a one-thumb column, and the order of
|
|
139
|
+
* its sections has to change — see {@link ListingComposerPage}'s header on why
|
|
140
|
+
* the characteristics move up.
|
|
141
|
+
*
|
|
142
|
+
* The `tablet` breakpoint, which is what "narrow" means everywhere else in the
|
|
143
|
+
* skin, measured against the form's OWN width and never the viewport's (§83).
|
|
144
|
+
* A composer drawn in a 360px settings pane on a 1440px desktop is narrow; a
|
|
145
|
+
* viewport query would call it wide and bury its questions.
|
|
146
|
+
*/
|
|
147
|
+
export const COMPOSER_STACKED_BELOW: number = breakpoints.tablet;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The DOM id of the control that answers one of the composer's own fields —
|
|
151
|
+
* the names `mirrorListingFields` refuses by (`title`, `description`, …).
|
|
152
|
+
*
|
|
153
|
+
* A person told "10 required details are still empty" needs to be taken to
|
|
154
|
+
* one, and taking them there needs an ADDRESS. Features already have one
|
|
155
|
+
* (`featureControlId`); the composer's own fields had none, and a test id is
|
|
156
|
+
* not an address — it is a test's handle, and reaching for it in product code
|
|
157
|
+
* makes every test id load-bearing.
|
|
158
|
+
*/
|
|
159
|
+
export function composerFieldId(field: string): string {
|
|
160
|
+
return `listings-composer-field-${field}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What counts as a control a person can be put in front of. A slot's control
|
|
164
|
+
* belongs to the container, so the field is asked for its first focusable
|
|
165
|
+
* descendant rather than assumed to be an `<input>`. */
|
|
166
|
+
const FOCUSABLE =
|
|
167
|
+
"input,select,textarea,button,[href],[tabindex]:not([tabindex='-1'])";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Put the person in front of one field: bring it into view, and focus what
|
|
171
|
+
* they are meant to answer.
|
|
172
|
+
*
|
|
173
|
+
* Both halves are guarded rather than assumed. `scrollIntoView` does not exist
|
|
174
|
+
* in every environment this renders in (jsdom, older embedded engines), and a
|
|
175
|
+
* field whose control came from a slot may have nothing focusable in it at
|
|
176
|
+
* all — in which case scrolling to it is still the whole of the help that can
|
|
177
|
+
* honestly be given.
|
|
178
|
+
*/
|
|
179
|
+
function revealField(id: string): void {
|
|
180
|
+
if (typeof document === "undefined") return;
|
|
181
|
+
const anchor = document.getElementById(id);
|
|
182
|
+
if (anchor === null) return;
|
|
183
|
+
if (typeof anchor.scrollIntoView === "function") {
|
|
184
|
+
anchor.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
185
|
+
}
|
|
186
|
+
const control = anchor.matches(FOCUSABLE)
|
|
187
|
+
? anchor
|
|
188
|
+
: anchor.querySelector<HTMLElement>(FOCUSABLE);
|
|
189
|
+
control?.focus();
|
|
190
|
+
}
|
|
191
|
+
|
|
117
192
|
/**
|
|
118
193
|
* What `renderCategoryPicker` is handed: the current category and the ONLY
|
|
119
194
|
* function that changes it. Same shape as any other bag in this monorepo — the
|
|
@@ -318,16 +393,23 @@ function SlotField(props: {
|
|
|
318
393
|
readonly control: ReactNode | undefined;
|
|
319
394
|
readonly status: { help: ReactNode; validateStatus: "error" } | Record<string, never>;
|
|
320
395
|
readonly testId: string;
|
|
396
|
+
/** The address `revealField` aims at — see {@link composerFieldId}. */
|
|
397
|
+
readonly anchorId: string;
|
|
321
398
|
}): ReactElement | null {
|
|
322
399
|
const filled = props.control !== undefined;
|
|
323
400
|
if (!filled && !props.named) return null;
|
|
401
|
+
// A refusal REPLACES the hint rather than stacking under it. "Choose a
|
|
402
|
+
// category — the rest of the form depends on it" and "a category is
|
|
403
|
+
// required" are one fact printed twice, one line apart, and the second is
|
|
404
|
+
// the one the person just earned.
|
|
405
|
+
const refused = "validateStatus" in props.status;
|
|
324
406
|
return (
|
|
325
407
|
<Form.Item
|
|
326
408
|
label={props.label}
|
|
327
|
-
{...(props.extra !== undefined ? { extra: props.extra } : {})}
|
|
409
|
+
{...(props.extra !== undefined && !refused ? { extra: props.extra } : {})}
|
|
328
410
|
{...props.status}
|
|
329
411
|
>
|
|
330
|
-
<div data-testid={props.testId}>
|
|
412
|
+
<div id={props.anchorId} data-testid={props.testId}>
|
|
331
413
|
{props.control ?? <SlotPlaceholder name={props.slot} visibility="visible" />}
|
|
332
414
|
</div>
|
|
333
415
|
</Form.Item>
|
|
@@ -401,6 +483,76 @@ export function ListingComposerPage(
|
|
|
401
483
|
);
|
|
402
484
|
|
|
403
485
|
const LocationPicker = props.locationPicker;
|
|
486
|
+
// The FORM's width, which is what decides the section order — see the
|
|
487
|
+
// header. `?? false` because an unmeasured box is not a narrow one (the rule
|
|
488
|
+
// `useElementWidth` states for every caller): the wide order is the one that
|
|
489
|
+
// reflows gracefully, so it is what the first frame draws.
|
|
490
|
+
const form = useRef<HTMLDivElement>(null);
|
|
491
|
+
const { below } = useElementWidth(form, {
|
|
492
|
+
thresholds: { stacked: COMPOSER_STACKED_BELOW },
|
|
493
|
+
});
|
|
494
|
+
const stacked = below.stacked ?? false;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The characteristics of the chosen category — built once and rendered in
|
|
498
|
+
* exactly one of two places, so neither arm can drift from the other.
|
|
499
|
+
*
|
|
500
|
+
* The four states are four different sentences, and the one that used to be
|
|
501
|
+
* missing is the FIRST: with no category chosen there is no request in
|
|
502
|
+
* flight and none will be made, so "loading the category's characteristics"
|
|
503
|
+
* was simply untrue — a spinner-shaped sentence over a form that was waiting
|
|
504
|
+
* for the person, not for the network.
|
|
505
|
+
*/
|
|
506
|
+
const details = (
|
|
507
|
+
<div
|
|
508
|
+
data-testid="listings-composer-details"
|
|
509
|
+
data-placement={stacked ? "after-category" : "after-photos"}
|
|
510
|
+
>
|
|
511
|
+
<Divider />
|
|
512
|
+
<Typography.Title level={5}>
|
|
513
|
+
{t(LISTINGS_I18N_KEYS.composeDetails)}
|
|
514
|
+
</Typography.Title>
|
|
515
|
+
{props.featuresError !== undefined ? (
|
|
516
|
+
<ErrorAlert
|
|
517
|
+
testId="listings-composer-features-failed"
|
|
518
|
+
message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
|
|
519
|
+
/>
|
|
520
|
+
) : bag.values.categoryId.length === 0 ? (
|
|
521
|
+
<Typography.Text
|
|
522
|
+
type="secondary"
|
|
523
|
+
data-testid="listings-composer-features-no-category"
|
|
524
|
+
>
|
|
525
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsNoCategory)}
|
|
526
|
+
</Typography.Text>
|
|
527
|
+
) : props.featuresLoading === true ? (
|
|
528
|
+
<Typography.Text type="secondary" data-testid="listings-composer-features-loading">
|
|
529
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
|
|
530
|
+
</Typography.Text>
|
|
531
|
+
) : props.features.length === 0 ? (
|
|
532
|
+
<Typography.Text type="secondary" data-testid="listings-composer-features-empty">
|
|
533
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
|
|
534
|
+
</Typography.Text>
|
|
535
|
+
) : (
|
|
536
|
+
<FeatureFields
|
|
537
|
+
features={props.features}
|
|
538
|
+
values={bag.values.features}
|
|
539
|
+
errors={bag.fieldErrors}
|
|
540
|
+
disabled={bag.publishing}
|
|
541
|
+
onChange={bag.setFeature}
|
|
542
|
+
/>
|
|
543
|
+
)}
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
// Where the "take me to it" control aims. A feature answers at its own
|
|
548
|
+
// control id; everything else at the composer's.
|
|
549
|
+
const missing = bag.firstUnsatisfied;
|
|
550
|
+
const missingAnchor =
|
|
551
|
+
missing === undefined
|
|
552
|
+
? undefined
|
|
553
|
+
: props.features.some((feature) => feature.slug === missing)
|
|
554
|
+
? featureControlId(missing)
|
|
555
|
+
: composerFieldId(missing);
|
|
404
556
|
// Whether an unfilled slot draws its named placeholder — and therefore
|
|
405
557
|
// whether the field it belongs to exists at all.
|
|
406
558
|
const namedSlots = props.slotVisibility === "visible" || isDevBuild();
|
|
@@ -451,6 +603,11 @@ export function ListingComposerPage(
|
|
|
451
603
|
/>
|
|
452
604
|
) : null}
|
|
453
605
|
|
|
606
|
+
{/* The measured box is the FORM, so a host that renders this page in
|
|
607
|
+
a narrow pane gets the narrow order without the viewport agreeing.
|
|
608
|
+
A plain block wrapper: antd's `Form` forwards its ref to the form
|
|
609
|
+
INSTANCE, not to a node. */}
|
|
610
|
+
<div ref={form}>
|
|
454
611
|
<Form layout="vertical" data-testid="listings-composer-form">
|
|
455
612
|
<SlotField
|
|
456
613
|
label={t(LISTINGS_I18N_KEYS.composeCategory)}
|
|
@@ -459,6 +616,7 @@ export function ListingComposerPage(
|
|
|
459
616
|
named={namedSlots}
|
|
460
617
|
status={errorOf(CATEGORY_FIELD)}
|
|
461
618
|
testId="listings-composer-category"
|
|
619
|
+
anchorId={composerFieldId(CATEGORY_FIELD)}
|
|
462
620
|
control={props.renderCategoryPicker?.({
|
|
463
621
|
value: bag.values.categoryId,
|
|
464
622
|
setCategory: bag.setCategory,
|
|
@@ -476,11 +634,16 @@ export function ListingComposerPage(
|
|
|
476
634
|
/>
|
|
477
635
|
) : null}
|
|
478
636
|
|
|
637
|
+
{/* Narrow: the category's own questions, directly under the choice
|
|
638
|
+
that produced them and above the photo dropzone. */}
|
|
639
|
+
{stacked ? details : null}
|
|
640
|
+
|
|
479
641
|
<Form.Item
|
|
480
642
|
label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
|
|
481
643
|
{...errorOf(TITLE_FIELD)}
|
|
482
644
|
>
|
|
483
645
|
<Input
|
|
646
|
+
id={composerFieldId(TITLE_FIELD)}
|
|
484
647
|
value={bag.values.title}
|
|
485
648
|
aria-label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
|
|
486
649
|
data-testid="listings-composer-title"
|
|
@@ -496,6 +659,7 @@ export function ListingComposerPage(
|
|
|
496
659
|
{...errorOf(DESCRIPTION_FIELD)}
|
|
497
660
|
>
|
|
498
661
|
<Input.TextArea
|
|
662
|
+
id={composerFieldId(DESCRIPTION_FIELD)}
|
|
499
663
|
rows={5}
|
|
500
664
|
value={bag.values.description}
|
|
501
665
|
aria-label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
|
|
@@ -518,6 +682,7 @@ export function ListingComposerPage(
|
|
|
518
682
|
{...errorOf(PRICE_FIELD)}
|
|
519
683
|
>
|
|
520
684
|
<Input
|
|
685
|
+
id={composerFieldId(PRICE_FIELD)}
|
|
521
686
|
inputMode="decimal"
|
|
522
687
|
value={bag.values.price}
|
|
523
688
|
{...(props.renderCurrencyPicker === undefined
|
|
@@ -538,6 +703,7 @@ export function ListingComposerPage(
|
|
|
538
703
|
named={namedSlots}
|
|
539
704
|
status={{}}
|
|
540
705
|
testId="listings-composer-currency"
|
|
706
|
+
anchorId={composerFieldId("currency")}
|
|
541
707
|
control={props.renderCurrencyPicker?.({
|
|
542
708
|
value: bag.values.currency,
|
|
543
709
|
setCurrency: (code) => {
|
|
@@ -562,6 +728,7 @@ export function ListingComposerPage(
|
|
|
562
728
|
named={namedSlots}
|
|
563
729
|
status={errorOf(LOCATION_FIELD)}
|
|
564
730
|
testId="listings-composer-location"
|
|
731
|
+
anchorId={composerFieldId(LOCATION_FIELD)}
|
|
565
732
|
control={
|
|
566
733
|
props.renderLocationPicker !== undefined ? (
|
|
567
734
|
<div data-testid="listings-composer-location-slot">
|
|
@@ -608,9 +775,11 @@ export function ListingComposerPage(
|
|
|
608
775
|
<Typography.Title level={5}>
|
|
609
776
|
{t(LISTINGS_I18N_KEYS.composePhotos)}
|
|
610
777
|
</Typography.Title>
|
|
611
|
-
{
|
|
612
|
-
|
|
613
|
-
|
|
778
|
+
<div id={composerFieldId(IMAGES_FIELD)}>
|
|
779
|
+
{props.gallerySlot ?? (
|
|
780
|
+
<SlotPlaceholder name="gallerySlot" visibility="visible" />
|
|
781
|
+
)}
|
|
782
|
+
</div>
|
|
614
783
|
{bag.fieldErrors[IMAGES_FIELD] ? (
|
|
615
784
|
<Typography.Text type="danger" data-testid="listings-composer-images-error">
|
|
616
785
|
{describe(bag.fieldErrors[IMAGES_FIELD]).message}
|
|
@@ -619,33 +788,9 @@ export function ListingComposerPage(
|
|
|
619
788
|
</>
|
|
620
789
|
) : null}
|
|
621
790
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
{t(LISTINGS_I18N_KEYS.composeDetails)}
|
|
626
|
-
</Typography.Title>
|
|
627
|
-
{props.featuresError !== undefined ? (
|
|
628
|
-
<ErrorAlert
|
|
629
|
-
testId="listings-composer-features-failed"
|
|
630
|
-
message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
|
|
631
|
-
/>
|
|
632
|
-
) : props.featuresLoading === true ? (
|
|
633
|
-
<Typography.Text type="secondary" data-testid="listings-composer-features-loading">
|
|
634
|
-
{t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
|
|
635
|
-
</Typography.Text>
|
|
636
|
-
) : props.features.length === 0 ? (
|
|
637
|
-
<Typography.Text type="secondary" data-testid="listings-composer-features-empty">
|
|
638
|
-
{t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
|
|
639
|
-
</Typography.Text>
|
|
640
|
-
) : (
|
|
641
|
-
<FeatureFields
|
|
642
|
-
features={props.features}
|
|
643
|
-
values={bag.values.features}
|
|
644
|
-
errors={bag.fieldErrors}
|
|
645
|
-
disabled={bag.publishing}
|
|
646
|
-
onChange={bag.setFeature}
|
|
647
|
-
/>
|
|
648
|
-
)}
|
|
791
|
+
{/* Wide: the whole form is one screen, so the details keep their
|
|
792
|
+
place after the photos. */}
|
|
793
|
+
{stacked ? null : details}
|
|
649
794
|
|
|
650
795
|
<Divider />
|
|
651
796
|
|
|
@@ -687,6 +832,7 @@ export function ListingComposerPage(
|
|
|
687
832
|
</Checkbox>
|
|
688
833
|
</Form.Item>
|
|
689
834
|
</Form>
|
|
835
|
+
</div>
|
|
690
836
|
|
|
691
837
|
{bag.refusal?.kind === "invalid_draft" ? (
|
|
692
838
|
<ErrorAlert
|
|
@@ -749,6 +895,30 @@ export function ListingComposerPage(
|
|
|
749
895
|
{bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
|
|
750
896
|
</GatedButton>
|
|
751
897
|
|
|
898
|
+
{/* A count with nowhere to go is a dead end: "10 required details
|
|
899
|
+
are still empty" is printed by the gate above with not one of
|
|
900
|
+
them on screen, because the attribute region starts below the
|
|
901
|
+
fold. This is the way to the first of them — a real button, with
|
|
902
|
+
its own accessible name, and not a click handler stuck on the
|
|
903
|
+
sentence (which announces as text and cannot be tabbed to). It
|
|
904
|
+
appears only while the gate is closed, so it never stands under
|
|
905
|
+
a button that is ready to press. */}
|
|
906
|
+
{!bag.publishGate.available && missingAnchor !== undefined ? (
|
|
907
|
+
<Button
|
|
908
|
+
type="link"
|
|
909
|
+
size="small"
|
|
910
|
+
style={{ paddingInline: 0 }}
|
|
911
|
+
data-testid="listings-composer-goto-missing"
|
|
912
|
+
data-analytics="none"
|
|
913
|
+
data-analytics-reason="navigation within the page — the host app wraps business actions with its own tracked()"
|
|
914
|
+
onClick={() => {
|
|
915
|
+
revealField(missingAnchor);
|
|
916
|
+
}}
|
|
917
|
+
>
|
|
918
|
+
{t(LISTINGS_I18N_KEYS.composeShowFirstMissing)}
|
|
919
|
+
</Button>
|
|
920
|
+
) : null}
|
|
921
|
+
|
|
752
922
|
<Flex gap={spacing[3]} wrap align="flex-start">
|
|
753
923
|
<GatedButton
|
|
754
924
|
gate={bag.saveGate}
|
|
@@ -59,7 +59,7 @@ import { HeartIcon } from "./icons.js";
|
|
|
59
59
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
60
60
|
import { ListingPrice } from "./ListingPrice.js";
|
|
61
61
|
import { ListingStatusBlock } from "./StatusTags.js";
|
|
62
|
-
import type { ThemeModeProp } from "./types.js";
|
|
62
|
+
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
63
63
|
|
|
64
64
|
/** The reading measure of the page body. A detail page is prose plus a spec
|
|
65
65
|
* table; past this it stops being one column and starts being a stripe across
|
|
@@ -73,7 +73,9 @@ export const DETAIL_MEASURE = "60rem";
|
|
|
73
73
|
* postage stamp on the other. */
|
|
74
74
|
export const DETAIL_PHOTO_MIN = "14rem";
|
|
75
75
|
|
|
76
|
-
export interface ListingDetailPaneProps
|
|
76
|
+
export interface ListingDetailPaneProps
|
|
77
|
+
extends ThemeModeProp,
|
|
78
|
+
CategoryFeaturesProp {
|
|
77
79
|
readonly id: number;
|
|
78
80
|
/** The reader's own uuid, when the host knows it. Enables the owner view —
|
|
79
81
|
* the only place the moderation axis is shown, because it is the only
|
|
@@ -98,10 +100,12 @@ export interface ListingDetailPaneProps extends ThemeModeProp {
|
|
|
98
100
|
export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
99
101
|
const t = useT();
|
|
100
102
|
const { locale } = useI18n();
|
|
101
|
-
const bag = useListingDetail(
|
|
102
|
-
props.
|
|
103
|
-
props.
|
|
104
|
-
|
|
103
|
+
const bag = useListingDetail(props.id, {
|
|
104
|
+
...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
|
|
105
|
+
...(props.categoryFeatures !== undefined
|
|
106
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
107
|
+
: {}),
|
|
108
|
+
});
|
|
105
109
|
const owner = bag.viewerIsOwner === true;
|
|
106
110
|
const actions = useListingActions(props.id, bag.status?.lifecycle.status);
|
|
107
111
|
const editGate = actions.editGate(props.onEdit !== undefined);
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
featuresDtoFromDaoList,
|
|
75
75
|
featuresFromDaoList,
|
|
76
76
|
} from "../model/features.js";
|
|
77
|
+
import type { FeatureCopySource } from "../model/features.js";
|
|
77
78
|
import { lifecycleCaption } from "../model/status.js";
|
|
78
79
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
79
80
|
import { PriceTrendIcon } from "./icons.js";
|
|
@@ -86,7 +87,7 @@ import {
|
|
|
86
87
|
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
87
88
|
import { LISTING_PHOTO_ASPECT, ListingPhoto } from "./ListingPhoto.js";
|
|
88
89
|
import { ListingPrice } from "./ListingPrice.js";
|
|
89
|
-
import type { ThemeModeProp } from "./types.js";
|
|
90
|
+
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
93
|
* A price that moved, as the ref draws it: the old figure struck through and
|
|
@@ -103,7 +104,9 @@ export interface ListingPriceTrend {
|
|
|
103
104
|
readonly direction: "down" | "up";
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
export interface ListingSerpCardBaseProps
|
|
107
|
+
export interface ListingSerpCardBaseProps
|
|
108
|
+
extends ThemeModeProp,
|
|
109
|
+
CategoryFeaturesProp {
|
|
107
110
|
readonly listing: ListingCardData;
|
|
108
111
|
/**
|
|
109
112
|
* The seller's own one-line summary — "Petrol 1.5 (147 hp), robot, front".
|
|
@@ -149,7 +152,13 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
149
152
|
const { token } = antdTheme.useToken();
|
|
150
153
|
|
|
151
154
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
152
|
-
|
|
155
|
+
// See `CategoryFeaturesProp`: the option table a stored `select` does not
|
|
156
|
+
// carry, when this surface knows which category it is drawing.
|
|
157
|
+
const copy: FeatureCopySource =
|
|
158
|
+
props.categoryFeatures !== undefined
|
|
159
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
160
|
+
: {};
|
|
161
|
+
const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
|
|
153
162
|
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
154
163
|
// The fallback spec line when the host derived none: the row's own
|
|
155
164
|
// `features_title` projection, exactly the line `ListingCard` draws.
|
|
@@ -315,7 +324,7 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
315
324
|
data-testid="listings-serp-specs"
|
|
316
325
|
>
|
|
317
326
|
<FeatureBadges
|
|
318
|
-
features={featuresFromDaoList(titleDaos).map(
|
|
327
|
+
features={featuresFromDaoList(titleDaos, copy).map(
|
|
319
328
|
(view) => view.feature,
|
|
320
329
|
)}
|
|
321
330
|
values={featuresDtoFromDaoList(titleDaos)}
|
package/src/default/index.ts
CHANGED
|
@@ -55,7 +55,12 @@ export { FeedGrid, FEED_GRID_COLUMNS } from "./FeedGrid.js";
|
|
|
55
55
|
export type { FeedGridProps } from "./FeedGrid.js";
|
|
56
56
|
export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDetailPane.js";
|
|
57
57
|
export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
|
|
58
|
-
export {
|
|
58
|
+
export {
|
|
59
|
+
ListingComposerPage,
|
|
60
|
+
COMPOSER_MEASURE,
|
|
61
|
+
COMPOSER_STACKED_BELOW,
|
|
62
|
+
composerFieldId,
|
|
63
|
+
} from "./ListingComposerPage.js";
|
|
59
64
|
export type {
|
|
60
65
|
ListingComposerPageProps,
|
|
61
66
|
ComposerCategorySlot,
|
|
@@ -78,4 +83,4 @@ export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
|
|
|
78
83
|
export type { ListingPhotoProps } from "./ListingPhoto.js";
|
|
79
84
|
export { SignInLink } from "./SignInLink.js";
|
|
80
85
|
export type { SignInLinkProps } from "./SignInLink.js";
|
|
81
|
-
export type { ThemeModeProp } from "./types.js";
|
|
86
|
+
export type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
package/src/default/types.ts
CHANGED
|
@@ -4,9 +4,31 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export type { FlowError } from "@stapel/core";
|
|
6
6
|
import type { ThemeMode } from "@stapel/tokens-antd";
|
|
7
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
7
8
|
|
|
8
9
|
/** Every `/default` surface accepts a theme mode; absent means "whatever the
|
|
9
10
|
* host document declares" (`resolveThemeMode()`), never a hardcoded side. */
|
|
10
11
|
export interface ThemeModeProp {
|
|
11
12
|
readonly mode?: ThemeMode;
|
|
12
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every surface that DISPLAYS stored feature values takes this, and it is
|
|
17
|
+
* optional on all of them.
|
|
18
|
+
*
|
|
19
|
+
* A stored `select` carries its chosen values and no option table (the table
|
|
20
|
+
* lives on the category), so a row written before the label snapshot existed
|
|
21
|
+
* prints its storage slug — "b-u" where the catalogue holds the copy. A
|
|
22
|
+
* surface that knows which category it is drawing can hand the category's own
|
|
23
|
+
* defs over and the copy is repaired; a mixed grid of forty categories knows
|
|
24
|
+
* no such thing, passes nothing, and renders exactly what it renders today.
|
|
25
|
+
*
|
|
26
|
+
* The precedence between these defs and what the row itself stored is
|
|
27
|
+
* `model/features.ts`' business — see {@link FeatureCopySource}.
|
|
28
|
+
*/
|
|
29
|
+
export interface CategoryFeaturesProp {
|
|
30
|
+
/** The category's features, as
|
|
31
|
+
* `GET /categories/api/v1/categories/{id}/features/` answers — the same
|
|
32
|
+
* array `<ListingComposerPage>` takes. */
|
|
33
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
34
|
+
}
|
|
@@ -40,6 +40,7 @@ import type { ListingDraftValues, ListingLocation } from "../model/draft.js";
|
|
|
40
40
|
import { asFeatureDaoList } from "../model/features.js";
|
|
41
41
|
import { featuresDtoFromDaoList } from "../model/features.js";
|
|
42
42
|
import {
|
|
43
|
+
LISTING_FIELD_ORDER,
|
|
43
44
|
envelopeFieldErrors,
|
|
44
45
|
mirrorDraft,
|
|
45
46
|
publishRefusal,
|
|
@@ -201,6 +202,17 @@ export interface ListingComposerBag {
|
|
|
201
202
|
readonly fieldErrors: Readonly<Record<string, FlowError>>;
|
|
202
203
|
/** The server's last publish refusal, unrouted, for a summary line. */
|
|
203
204
|
readonly refusal: PublishRefusal | undefined;
|
|
205
|
+
/**
|
|
206
|
+
* The first field the mirror is refusing, in the order the form asks for it
|
|
207
|
+
* (`LISTING_FIELD_ORDER`, then the category's features in schema order), or
|
|
208
|
+
* `undefined` when nothing is refused.
|
|
209
|
+
*
|
|
210
|
+
* The publish gate can say "10 required details are still empty" while not
|
|
211
|
+
* one of them is on screen — on a phone the attribute region starts nearly
|
|
212
|
+
* two viewports below the fold — and a count with nowhere to go is a dead
|
|
213
|
+
* end. This is what a skin's "take me there" control aims at.
|
|
214
|
+
*/
|
|
215
|
+
readonly firstUnsatisfied: string | undefined;
|
|
204
216
|
/** Value types this build cannot draw — the fact, from attributes-react. */
|
|
205
217
|
readonly unsupported: readonly string[];
|
|
206
218
|
|
|
@@ -378,6 +390,21 @@ export function useListingComposer(
|
|
|
378
390
|
return { ...shown, ...envelopeFieldErrors(saveThrown), ...routed };
|
|
379
391
|
}, [showErrors, mirror, refusal, saveThrown]);
|
|
380
392
|
|
|
393
|
+
// The order is the FORM's, not the record's — see `LISTING_FIELD_ORDER`.
|
|
394
|
+
// A key in neither list (a server field with no control of its own) is still
|
|
395
|
+
// named rather than dropped: something is refusing, and saying which is
|
|
396
|
+
// better than saying nothing.
|
|
397
|
+
const firstUnsatisfied: string | undefined = useMemo(() => {
|
|
398
|
+
const refused = mirror;
|
|
399
|
+
for (const field of LISTING_FIELD_ORDER) {
|
|
400
|
+
if (refused[field] !== undefined) return field;
|
|
401
|
+
}
|
|
402
|
+
for (const feature of options.features) {
|
|
403
|
+
if (refused[feature.slug] !== undefined) return feature.slug;
|
|
404
|
+
}
|
|
405
|
+
return Object.keys(refused)[0];
|
|
406
|
+
}, [mirror, options.features]);
|
|
407
|
+
|
|
381
408
|
const listingState: LoadState<ListingDetailData> | undefined =
|
|
382
409
|
options.listingId === undefined
|
|
383
410
|
? undefined
|
|
@@ -571,6 +598,7 @@ export function useListingComposer(
|
|
|
571
598
|
mirror,
|
|
572
599
|
fieldErrors,
|
|
573
600
|
refusal,
|
|
601
|
+
firstUnsatisfied,
|
|
574
602
|
unsupported,
|
|
575
603
|
|
|
576
604
|
saveGate,
|