@stapel/listings-react 0.11.0 → 0.13.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 +212 -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 +59 -0
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +103 -12
- 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 +221 -32
- 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
|
@@ -50,6 +50,45 @@
|
|
|
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
|
+
* ── The SECTION ORDER is this component's, and it is not a prop ────────────
|
|
54
|
+
*
|
|
55
|
+
* One order, at every width:
|
|
56
|
+
*
|
|
57
|
+
* category → title → description → price → currency → where → photos →
|
|
58
|
+
* the category's characteristics → the listing's own options
|
|
59
|
+
*
|
|
60
|
+
* The category comes first because everything after it depends on the choice.
|
|
61
|
+
* Then the five questions a seller came to answer — what it is, what it says,
|
|
62
|
+
* what it costs, where it is, what it looks like — and only then the questions
|
|
63
|
+
* the CATEGORY asks.
|
|
64
|
+
*
|
|
65
|
+
* This used to be two orders chosen by the form's width, and the narrow one
|
|
66
|
+
* put the characteristics directly under the category. The reason was real:
|
|
67
|
+
* measured on a live classified deployment, Mobile phones put the first
|
|
68
|
+
* attribute control at y=1596, two viewports below the fold, while the footer
|
|
69
|
+
* said "10 required details not filled in" with none of them on screen. The
|
|
70
|
+
* fix was not: the same leaf carries 32 imported fields, so on the next
|
|
71
|
+
* measurement of the same form the seller was asked for the parcel's weight,
|
|
72
|
+
* its length and "what the goods are measured in" BEFORE the title — title
|
|
73
|
+
* y=5575, price y=5871, photos y=6245, of a 7308px form. A composer that asks
|
|
74
|
+
* about postage before it asks what is being sold has no funnel left.
|
|
75
|
+
*
|
|
76
|
+
* So the discoverability problem is solved where it actually lives, and by two
|
|
77
|
+
* things that do not move the questions around:
|
|
78
|
+
*
|
|
79
|
+
* - the count of unfilled details is a CONTROL that goes to the first of them
|
|
80
|
+
* (`listings-composer-goto-missing`, below), so "10 required details" is
|
|
81
|
+
* never a dead end wherever the region sits;
|
|
82
|
+
* - the region itself is short, because `groupCollapse="auto"` opens the
|
|
83
|
+
* groups that ask something required and leaves the plumbing (delivery
|
|
84
|
+
* dimensions, wholesale terms) closed under its own heading.
|
|
85
|
+
*
|
|
86
|
+
* `detailsPlacement` is therefore gone as a decision — `data-placement` stays
|
|
87
|
+
* on the region as a constant, because an e2e that measured the regression
|
|
88
|
+
* needs something to read that is not a pixel count. A host that wants a
|
|
89
|
+
* different order does not want this page; it wants `<FeatureFields>` and its
|
|
90
|
+
* own form, which is exactly why that component is exported separately.
|
|
91
|
+
*
|
|
53
92
|
* ── Every blocked control says which of six reasons it is ──────────────────
|
|
54
93
|
*
|
|
55
94
|
* The publish button is the most-gated control in the fleet, and that is the
|
|
@@ -63,6 +102,7 @@
|
|
|
63
102
|
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
64
103
|
import {
|
|
65
104
|
Alert,
|
|
105
|
+
Button,
|
|
66
106
|
Checkbox,
|
|
67
107
|
Divider,
|
|
68
108
|
Flex,
|
|
@@ -90,6 +130,7 @@ import type { FeatureDef } from "@stapel/attributes-react";
|
|
|
90
130
|
import {
|
|
91
131
|
BUILTIN_VALUE_EDITOR_TYPES,
|
|
92
132
|
FeatureFields,
|
|
133
|
+
featureControlId,
|
|
93
134
|
} from "@stapel/attributes-react/default";
|
|
94
135
|
import { useListingComposer } from "../headless/ListingComposer.js";
|
|
95
136
|
import type { ListingLocation } from "../model/draft.js";
|
|
@@ -114,6 +155,73 @@ import type { ThemeModeProp } from "./types.js";
|
|
|
114
155
|
*/
|
|
115
156
|
export const COMPOSER_MEASURE = "44rem";
|
|
116
157
|
|
|
158
|
+
/**
|
|
159
|
+
* What `data-placement` says on the characteristics region.
|
|
160
|
+
*
|
|
161
|
+
* A constant, and deliberately so: the order is the composer's and does not
|
|
162
|
+
* depend on the width any more (see the header). The attribute stays because
|
|
163
|
+
* an e2e suite measured the regression through it and needs a reading that is
|
|
164
|
+
* not a pixel count.
|
|
165
|
+
*/
|
|
166
|
+
export const COMPOSER_DETAILS_PLACEMENT = "after-core-fields";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The DOM id of the control that answers one of the composer's own fields —
|
|
170
|
+
* the names `mirrorListingFields` refuses by (`title`, `description`, …).
|
|
171
|
+
*
|
|
172
|
+
* A person told "10 required details are still empty" needs to be taken to
|
|
173
|
+
* one, and taking them there needs an ADDRESS. Features already have one
|
|
174
|
+
* (`featureControlId`); the composer's own fields had none, and a test id is
|
|
175
|
+
* not an address — it is a test's handle, and reaching for it in product code
|
|
176
|
+
* makes every test id load-bearing.
|
|
177
|
+
*/
|
|
178
|
+
export function composerFieldId(field: string): string {
|
|
179
|
+
return `listings-composer-field-${field}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** What counts as a control a person can be put in front of. A slot's control
|
|
183
|
+
* belongs to the container, so the field is asked for its first focusable
|
|
184
|
+
* descendant rather than assumed to be an `<input>`. */
|
|
185
|
+
const FOCUSABLE =
|
|
186
|
+
"input,select,textarea,button,[href],[tabindex]:not([tabindex='-1'])";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Put the person in front of one field: open whatever it is folded inside,
|
|
190
|
+
* bring it into view, and focus what they are meant to answer.
|
|
191
|
+
*
|
|
192
|
+
* The disclosure step is the one that is easy to forget. A characteristic
|
|
193
|
+
* lives in a `<details>` section that may be closed (`groupCollapse="auto"`),
|
|
194
|
+
* and scrolling to a control inside a closed disclosure scrolls to nothing —
|
|
195
|
+
* the "take me to the first empty field" button would report success and move
|
|
196
|
+
* the page nowhere. Any depth of nesting is opened, because the answer to
|
|
197
|
+
* "where is it" must not depend on how the section was drawn.
|
|
198
|
+
*
|
|
199
|
+
* The rest is guarded rather than assumed. `scrollIntoView` does not exist in
|
|
200
|
+
* every environment this renders in (jsdom, older embedded engines), and a
|
|
201
|
+
* field whose control came from a slot may have nothing focusable in it at
|
|
202
|
+
* all — in which case scrolling to it is still the whole of the help that can
|
|
203
|
+
* honestly be given.
|
|
204
|
+
*/
|
|
205
|
+
function revealField(id: string): void {
|
|
206
|
+
if (typeof document === "undefined") return;
|
|
207
|
+
const anchor = document.getElementById(id);
|
|
208
|
+
if (anchor === null) return;
|
|
209
|
+
for (
|
|
210
|
+
let folded = anchor.closest("details");
|
|
211
|
+
folded !== null;
|
|
212
|
+
folded = folded.parentElement?.closest("details") ?? null
|
|
213
|
+
) {
|
|
214
|
+
folded.open = true;
|
|
215
|
+
}
|
|
216
|
+
if (typeof anchor.scrollIntoView === "function") {
|
|
217
|
+
anchor.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
218
|
+
}
|
|
219
|
+
const control = anchor.matches(FOCUSABLE)
|
|
220
|
+
? anchor
|
|
221
|
+
: anchor.querySelector<HTMLElement>(FOCUSABLE);
|
|
222
|
+
control?.focus();
|
|
223
|
+
}
|
|
224
|
+
|
|
117
225
|
/**
|
|
118
226
|
* What `renderCategoryPicker` is handed: the current category and the ONLY
|
|
119
227
|
* function that changes it. Same shape as any other bag in this monorepo — the
|
|
@@ -318,16 +426,23 @@ function SlotField(props: {
|
|
|
318
426
|
readonly control: ReactNode | undefined;
|
|
319
427
|
readonly status: { help: ReactNode; validateStatus: "error" } | Record<string, never>;
|
|
320
428
|
readonly testId: string;
|
|
429
|
+
/** The address `revealField` aims at — see {@link composerFieldId}. */
|
|
430
|
+
readonly anchorId: string;
|
|
321
431
|
}): ReactElement | null {
|
|
322
432
|
const filled = props.control !== undefined;
|
|
323
433
|
if (!filled && !props.named) return null;
|
|
434
|
+
// A refusal REPLACES the hint rather than stacking under it. "Choose a
|
|
435
|
+
// category — the rest of the form depends on it" and "a category is
|
|
436
|
+
// required" are one fact printed twice, one line apart, and the second is
|
|
437
|
+
// the one the person just earned.
|
|
438
|
+
const refused = "validateStatus" in props.status;
|
|
324
439
|
return (
|
|
325
440
|
<Form.Item
|
|
326
441
|
label={props.label}
|
|
327
|
-
{...(props.extra !== undefined ? { extra: props.extra } : {})}
|
|
442
|
+
{...(props.extra !== undefined && !refused ? { extra: props.extra } : {})}
|
|
328
443
|
{...props.status}
|
|
329
444
|
>
|
|
330
|
-
<div data-testid={props.testId}>
|
|
445
|
+
<div id={props.anchorId} data-testid={props.testId}>
|
|
331
446
|
{props.control ?? <SlotPlaceholder name={props.slot} visibility="visible" />}
|
|
332
447
|
</div>
|
|
333
448
|
</Form.Item>
|
|
@@ -401,6 +516,72 @@ export function ListingComposerPage(
|
|
|
401
516
|
);
|
|
402
517
|
|
|
403
518
|
const LocationPicker = props.locationPicker;
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The characteristics of the chosen category — built once and rendered in
|
|
522
|
+
* exactly one of two places, so neither arm can drift from the other.
|
|
523
|
+
*
|
|
524
|
+
* The four states are four different sentences, and the one that used to be
|
|
525
|
+
* missing is the FIRST: with no category chosen there is no request in
|
|
526
|
+
* flight and none will be made, so "loading the category's characteristics"
|
|
527
|
+
* was simply untrue — a spinner-shaped sentence over a form that was waiting
|
|
528
|
+
* for the person, not for the network.
|
|
529
|
+
*/
|
|
530
|
+
const details = (
|
|
531
|
+
<div
|
|
532
|
+
data-testid="listings-composer-details"
|
|
533
|
+
data-placement={COMPOSER_DETAILS_PLACEMENT}
|
|
534
|
+
>
|
|
535
|
+
<Divider />
|
|
536
|
+
<Typography.Title level={5}>
|
|
537
|
+
{t(LISTINGS_I18N_KEYS.composeDetails)}
|
|
538
|
+
</Typography.Title>
|
|
539
|
+
{props.featuresError !== undefined ? (
|
|
540
|
+
<ErrorAlert
|
|
541
|
+
testId="listings-composer-features-failed"
|
|
542
|
+
message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
|
|
543
|
+
/>
|
|
544
|
+
) : bag.values.categoryId.length === 0 ? (
|
|
545
|
+
<Typography.Text
|
|
546
|
+
type="secondary"
|
|
547
|
+
data-testid="listings-composer-features-no-category"
|
|
548
|
+
>
|
|
549
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsNoCategory)}
|
|
550
|
+
</Typography.Text>
|
|
551
|
+
) : props.featuresLoading === true ? (
|
|
552
|
+
<Typography.Text type="secondary" data-testid="listings-composer-features-loading">
|
|
553
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
|
|
554
|
+
</Typography.Text>
|
|
555
|
+
) : props.features.length === 0 ? (
|
|
556
|
+
<Typography.Text type="secondary" data-testid="listings-composer-features-empty">
|
|
557
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
|
|
558
|
+
</Typography.Text>
|
|
559
|
+
) : (
|
|
560
|
+
<FeatureFields
|
|
561
|
+
features={props.features}
|
|
562
|
+
values={bag.values.features}
|
|
563
|
+
errors={bag.fieldErrors}
|
|
564
|
+
disabled={bag.publishing}
|
|
565
|
+
onChange={bag.setFeature}
|
|
566
|
+
// An imported leaf is mostly plumbing: 32 fields across seven
|
|
567
|
+
// headings, four of them parcel dimensions. Open what is required or
|
|
568
|
+
// already answered, leave the rest one tap away under its own
|
|
569
|
+
// heading — see `FeatureGroupCollapse`.
|
|
570
|
+
groupCollapse="auto"
|
|
571
|
+
/>
|
|
572
|
+
)}
|
|
573
|
+
</div>
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// Where the "take me to it" control aims. A feature answers at its own
|
|
577
|
+
// control id; everything else at the composer's.
|
|
578
|
+
const missing = bag.firstUnsatisfied;
|
|
579
|
+
const missingAnchor =
|
|
580
|
+
missing === undefined
|
|
581
|
+
? undefined
|
|
582
|
+
: props.features.some((feature) => feature.slug === missing)
|
|
583
|
+
? featureControlId(missing)
|
|
584
|
+
: composerFieldId(missing);
|
|
404
585
|
// Whether an unfilled slot draws its named placeholder — and therefore
|
|
405
586
|
// whether the field it belongs to exists at all.
|
|
406
587
|
const namedSlots = props.slotVisibility === "visible" || isDevBuild();
|
|
@@ -459,6 +640,7 @@ export function ListingComposerPage(
|
|
|
459
640
|
named={namedSlots}
|
|
460
641
|
status={errorOf(CATEGORY_FIELD)}
|
|
461
642
|
testId="listings-composer-category"
|
|
643
|
+
anchorId={composerFieldId(CATEGORY_FIELD)}
|
|
462
644
|
control={props.renderCategoryPicker?.({
|
|
463
645
|
value: bag.values.categoryId,
|
|
464
646
|
setCategory: bag.setCategory,
|
|
@@ -481,6 +663,7 @@ export function ListingComposerPage(
|
|
|
481
663
|
{...errorOf(TITLE_FIELD)}
|
|
482
664
|
>
|
|
483
665
|
<Input
|
|
666
|
+
id={composerFieldId(TITLE_FIELD)}
|
|
484
667
|
value={bag.values.title}
|
|
485
668
|
aria-label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
|
|
486
669
|
data-testid="listings-composer-title"
|
|
@@ -496,6 +679,7 @@ export function ListingComposerPage(
|
|
|
496
679
|
{...errorOf(DESCRIPTION_FIELD)}
|
|
497
680
|
>
|
|
498
681
|
<Input.TextArea
|
|
682
|
+
id={composerFieldId(DESCRIPTION_FIELD)}
|
|
499
683
|
rows={5}
|
|
500
684
|
value={bag.values.description}
|
|
501
685
|
aria-label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
|
|
@@ -518,6 +702,7 @@ export function ListingComposerPage(
|
|
|
518
702
|
{...errorOf(PRICE_FIELD)}
|
|
519
703
|
>
|
|
520
704
|
<Input
|
|
705
|
+
id={composerFieldId(PRICE_FIELD)}
|
|
521
706
|
inputMode="decimal"
|
|
522
707
|
value={bag.values.price}
|
|
523
708
|
{...(props.renderCurrencyPicker === undefined
|
|
@@ -538,6 +723,7 @@ export function ListingComposerPage(
|
|
|
538
723
|
named={namedSlots}
|
|
539
724
|
status={{}}
|
|
540
725
|
testId="listings-composer-currency"
|
|
726
|
+
anchorId={composerFieldId("currency")}
|
|
541
727
|
control={props.renderCurrencyPicker?.({
|
|
542
728
|
value: bag.values.currency,
|
|
543
729
|
setCurrency: (code) => {
|
|
@@ -562,6 +748,7 @@ export function ListingComposerPage(
|
|
|
562
748
|
named={namedSlots}
|
|
563
749
|
status={errorOf(LOCATION_FIELD)}
|
|
564
750
|
testId="listings-composer-location"
|
|
751
|
+
anchorId={composerFieldId(LOCATION_FIELD)}
|
|
565
752
|
control={
|
|
566
753
|
props.renderLocationPicker !== undefined ? (
|
|
567
754
|
<div data-testid="listings-composer-location-slot">
|
|
@@ -608,9 +795,11 @@ export function ListingComposerPage(
|
|
|
608
795
|
<Typography.Title level={5}>
|
|
609
796
|
{t(LISTINGS_I18N_KEYS.composePhotos)}
|
|
610
797
|
</Typography.Title>
|
|
611
|
-
{
|
|
612
|
-
|
|
613
|
-
|
|
798
|
+
<div id={composerFieldId(IMAGES_FIELD)}>
|
|
799
|
+
{props.gallerySlot ?? (
|
|
800
|
+
<SlotPlaceholder name="gallerySlot" visibility="visible" />
|
|
801
|
+
)}
|
|
802
|
+
</div>
|
|
614
803
|
{bag.fieldErrors[IMAGES_FIELD] ? (
|
|
615
804
|
<Typography.Text type="danger" data-testid="listings-composer-images-error">
|
|
616
805
|
{describe(bag.fieldErrors[IMAGES_FIELD]).message}
|
|
@@ -619,33 +808,9 @@ export function ListingComposerPage(
|
|
|
619
808
|
</>
|
|
620
809
|
) : null}
|
|
621
810
|
|
|
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
|
-
)}
|
|
811
|
+
{/* The category's own questions, after the five the seller came to
|
|
812
|
+
answer. See the header on why this is one order and not two. */}
|
|
813
|
+
{details}
|
|
649
814
|
|
|
650
815
|
<Divider />
|
|
651
816
|
|
|
@@ -749,6 +914,30 @@ export function ListingComposerPage(
|
|
|
749
914
|
{bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
|
|
750
915
|
</GatedButton>
|
|
751
916
|
|
|
917
|
+
{/* A count with nowhere to go is a dead end: "10 required details
|
|
918
|
+
are still empty" is printed by the gate above with not one of
|
|
919
|
+
them on screen, because the attribute region starts below the
|
|
920
|
+
fold. This is the way to the first of them — a real button, with
|
|
921
|
+
its own accessible name, and not a click handler stuck on the
|
|
922
|
+
sentence (which announces as text and cannot be tabbed to). It
|
|
923
|
+
appears only while the gate is closed, so it never stands under
|
|
924
|
+
a button that is ready to press. */}
|
|
925
|
+
{!bag.publishGate.available && missingAnchor !== undefined ? (
|
|
926
|
+
<Button
|
|
927
|
+
type="link"
|
|
928
|
+
size="small"
|
|
929
|
+
style={{ paddingInline: 0 }}
|
|
930
|
+
data-testid="listings-composer-goto-missing"
|
|
931
|
+
data-analytics="none"
|
|
932
|
+
data-analytics-reason="navigation within the page — the host app wraps business actions with its own tracked()"
|
|
933
|
+
onClick={() => {
|
|
934
|
+
revealField(missingAnchor);
|
|
935
|
+
}}
|
|
936
|
+
>
|
|
937
|
+
{t(LISTINGS_I18N_KEYS.composeShowFirstMissing)}
|
|
938
|
+
</Button>
|
|
939
|
+
) : null}
|
|
940
|
+
|
|
752
941
|
<Flex gap={spacing[3]} wrap align="flex-start">
|
|
753
942
|
<GatedButton
|
|
754
943
|
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_DETAILS_PLACEMENT,
|
|
61
|
+
COMPOSER_MEASURE,
|
|
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,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@stapel/core";
|
|
12
12
|
import type { ActionAvailability, LoadState } from "@stapel/core";
|
|
13
13
|
import type {
|
|
14
|
+
FeatureDef,
|
|
14
15
|
ListingDetail as ListingDetailData,
|
|
15
16
|
ListingFeatureView,
|
|
16
17
|
ListingStatusInfo,
|
|
@@ -18,6 +19,7 @@ import type {
|
|
|
18
19
|
import { useListing, useListingStatus } from "../model/queries.js";
|
|
19
20
|
import { useFavoriteListing } from "../model/mutations.js";
|
|
20
21
|
import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from "../model/features.js";
|
|
22
|
+
import type { FeatureCopySource } from "../model/features.js";
|
|
21
23
|
import { listingStatusView } from "../model/status.js";
|
|
22
24
|
import type { ListingStatusView } from "../model/status.js";
|
|
23
25
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -104,6 +106,19 @@ export interface UseListingDetailOptions {
|
|
|
104
106
|
/** The reader's own id, when the host knows it — enables `viewerIsOwner`.
|
|
105
107
|
* A uuid string, matching `ListingDetail.owner` / `ListingStatus.owner_id`. */
|
|
106
108
|
readonly viewerId?: string;
|
|
109
|
+
/**
|
|
110
|
+
* The listing's category features, when the container has them — the same
|
|
111
|
+
* `readonly FeatureDef[]` the composer takes, from
|
|
112
|
+
* `@stapel/categories-react`'s `useCategoryFeatures`.
|
|
113
|
+
*
|
|
114
|
+
* A stored `select` carries its chosen VALUES and no option table, so a row
|
|
115
|
+
* written before labels were snapshotted prints its storage slug (`b-u`)
|
|
116
|
+
* where the category holds the copy. Handing the category's defs in repairs
|
|
117
|
+
* that; handing nothing in leaves every rendered value exactly as it is
|
|
118
|
+
* today. Which definition wins over which is `model/features.ts`' business —
|
|
119
|
+
* this is only the wire it travels on.
|
|
120
|
+
*/
|
|
121
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
export function useListingDetail(
|
|
@@ -166,17 +181,25 @@ export function useListingDetail(
|
|
|
166
181
|
? undefined
|
|
167
182
|
: owner === options.viewerId;
|
|
168
183
|
|
|
184
|
+
// One object, rebuilt only when the defs themselves change, so the three
|
|
185
|
+
// projections below keep their memo across renders that touched neither.
|
|
186
|
+
const categoryFeatures = options.categoryFeatures;
|
|
187
|
+
const copy: FeatureCopySource = useMemo(
|
|
188
|
+
() => (categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
189
|
+
[categoryFeatures]
|
|
190
|
+
);
|
|
191
|
+
|
|
169
192
|
const features = useMemo(
|
|
170
|
-
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features)),
|
|
171
|
-
[detail.data]
|
|
193
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features), copy),
|
|
194
|
+
[detail.data, copy]
|
|
172
195
|
);
|
|
173
196
|
const titleFeatures = useMemo(
|
|
174
|
-
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title)),
|
|
175
|
-
[detail.data]
|
|
197
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title), copy),
|
|
198
|
+
[detail.data, copy]
|
|
176
199
|
);
|
|
177
200
|
const badgeFeatures = useMemo(
|
|
178
|
-
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges)),
|
|
179
|
-
[detail.data]
|
|
201
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges), copy),
|
|
202
|
+
[detail.data, copy]
|
|
180
203
|
);
|
|
181
204
|
|
|
182
205
|
const isFavorited = detail.data?.is_favorited ?? undefined;
|
|
@@ -228,11 +251,15 @@ export function useListingDetail(
|
|
|
228
251
|
export function ListingDetail(props: {
|
|
229
252
|
id: number;
|
|
230
253
|
viewerId?: string;
|
|
254
|
+
/** See {@link UseListingDetailOptions.categoryFeatures}. */
|
|
255
|
+
categoryFeatures?: readonly FeatureDef[];
|
|
231
256
|
children: (bag: ListingDetailBag) => ReactNode;
|
|
232
257
|
}): ReactElement {
|
|
233
|
-
const bag = useListingDetail(
|
|
234
|
-
props.
|
|
235
|
-
props.
|
|
236
|
-
|
|
258
|
+
const bag = useListingDetail(props.id, {
|
|
259
|
+
...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
|
|
260
|
+
...(props.categoryFeatures !== undefined
|
|
261
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
262
|
+
: {}),
|
|
263
|
+
});
|
|
237
264
|
return <>{props.children(bag)}</>;
|
|
238
265
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -130,6 +130,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
130
130
|
"Un anuncio admite como mucho {max} fotos",
|
|
131
131
|
"listings.compose.details": "Detalles",
|
|
132
132
|
"listings.compose.details_loading": "Cargando lo que pide esta categoría…",
|
|
133
|
+
"listings.compose.details_no_category":
|
|
134
|
+
"Elige primero una categoría: aquí aparecerá lo que pide.",
|
|
133
135
|
"listings.compose.details_failed":
|
|
134
136
|
"No pudimos cargar lo que pide esta categoría",
|
|
135
137
|
"listings.compose.details_empty": "Esta categoría no pide más detalles",
|
|
@@ -159,6 +161,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
159
161
|
"listings.compose.blocked.busy":
|
|
160
162
|
"Un momento — el último cambio se está guardando",
|
|
161
163
|
"listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
|
|
164
|
+
"listings.compose.show_first_missing": "Ir al primer campo sin completar",
|
|
162
165
|
"listings.compose.blocked.mirror": "Corrige primero los campos marcados",
|
|
163
166
|
"listings.compose.blocked.details_unavailable":
|
|
164
167
|
"No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
|
|
@@ -218,7 +221,9 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
218
221
|
|
|
219
222
|
"listings.nav.detail": "Anuncio",
|
|
220
223
|
"listings.nav.compose": "Poner un anuncio",
|
|
224
|
+
"listings.nav.compose.short": "Poner",
|
|
221
225
|
"listings.nav.mine": "Mis anuncios",
|
|
226
|
+
"listings.nav.mine.short": "Míos",
|
|
222
227
|
"listings.nav.favorites": "Favoritos",
|
|
223
228
|
};
|
|
224
229
|
|