@stapel/listings-react 0.4.0 → 0.8.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 +180 -0
- package/dist/api/generated/schema.d.ts +94 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FavoritesPane.d.ts +40 -3
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +30 -18
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +81 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +97 -27
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +163 -23
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +85 -43
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +41 -4
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +38 -25
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +13 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +47 -9
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -0
- package/dist/default/ListingPrice.d.ts.map +1 -0
- package/dist/default/ListingPrice.js +60 -0
- package/dist/default/ListingPrice.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +7 -28
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +136 -67
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/SignInLink.js +8 -7
- package/dist/default/SignInLink.js.map +1 -1
- package/dist/default/StatusTags.d.ts +16 -5
- package/dist/default/StatusTags.d.ts.map +1 -1
- package/dist/default/StatusTags.js +19 -13
- package/dist/default/StatusTags.js.map +1 -1
- package/dist/default/index.d.ts +15 -9
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -6
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Favorites.d.ts +6 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +23 -7
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +12 -0
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +3 -0
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +23 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +33 -7
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +7 -5
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/headless/useMandateGate.d.ts +33 -2
- package/dist/headless/useMandateGate.d.ts.map +1 -1
- package/dist/headless/useMandateGate.js +42 -4
- package/dist/headless/useMandateGate.js.map +1 -1
- package/dist/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +23 -5
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +30 -3
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +47 -7
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +16 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +35 -5
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/draft.d.ts +9 -6
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +3 -1
- package/dist/model/draft.js.map +1 -1
- package/dist/model/features.d.ts +21 -7
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +26 -0
- package/dist/model/features.js.map +1 -1
- package/llms.txt +9 -8
- package/manifest.json +82 -31
- package/nav-manifest.json +1 -1
- package/package.json +19 -14
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +94 -4
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +283 -116
- package/src/default/ListingComposerPage.tsx +403 -187
- package/src/default/ListingDetailPane.tsx +200 -98
- package/src/default/ListingPhoto.tsx +89 -16
- package/src/default/ListingPrice.tsx +77 -0
- package/src/default/MyListingsPane.tsx +286 -228
- package/src/default/SignInLink.tsx +4 -4
- package/src/default/StatusTags.tsx +36 -18
- package/src/default/index.ts +18 -7
- package/src/headless/Favorites.tsx +36 -7
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/ListingDetail.tsx +12 -5
- package/src/headless/useMandateGate.ts +49 -5
- package/src/i18n/es.ts +29 -5
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +7 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +54 -7
- package/src/i18n/ru.ts +41 -5
- package/src/index.ts +5 -1
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -25
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -40
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -67
|
@@ -1,32 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `<ListingComposerPage>` — the submission screen.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five contracts meet on this page and each arrives through its own seam, so
|
|
5
5
|
* the component can be read top to bottom without knowing any of the other
|
|
6
6
|
* pairs:
|
|
7
7
|
*
|
|
8
8
|
* category `renderCategoryPicker` — the container's `<CategoryPickerField>`,
|
|
9
9
|
* given `setCategory` to call
|
|
10
|
+
* where `locationPicker` — the container's `<LocationPickerField>`
|
|
11
|
+
* (`@stapel/geo-react`)
|
|
12
|
+
* currency `renderCurrencyPicker` — whatever vocabulary the deployment sells in
|
|
10
13
|
* details `features` — the schema, drawn by `<FeatureFields>` (L0)
|
|
11
14
|
* photos `gallerySlot` — the container's `<MediaGalleryField bag>`,
|
|
12
15
|
* whose bag is handed here as `images`
|
|
13
16
|
* the draft this pair
|
|
14
17
|
*
|
|
15
|
-
* ──
|
|
18
|
+
* ── An unfilled slot is NAMED, never improvised ────────────────────────────
|
|
16
19
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* named gap G-1).
|
|
20
|
+
* Every one of those four slots used to degrade into a control this pair could
|
|
21
|
+
* write on its own, and every one of those controls asked a question no seller
|
|
22
|
+
* can answer: a numeric category id typed into a text box, a currency CODE
|
|
23
|
+
* typed into a text box, and — the defect the owner named — two decimal boxes
|
|
24
|
+
* labelled Latitude and Longitude. A gallery heading with nothing under it was
|
|
25
|
+
* the same defect with the improvisation left out.
|
|
24
26
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* So an unfilled slot renders `<SlotPlaceholder name="…"/>`: a labelled dashed
|
|
28
|
+
* region in a dev build, nothing at all in production. The rule is the one the
|
|
29
|
+
* rest of this codebase already lives by — `matchList`'s required empty arm,
|
|
30
|
+
* `ActionAvailability` with no "disabled for unknown reasons", `LoadState`
|
|
31
|
+
* refusing to fold failed into empty. A slot was the last place absence could
|
|
32
|
+
* still be silent, and `stapel/no-silent-slot` now says so at lint time.
|
|
33
|
+
*
|
|
34
|
+
* ── …and its LABEL goes with it ────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* The placeholder is nothing in production — but the `Form.Item` around it
|
|
37
|
+
* still drew its label, so a production composer with three unfilled slots
|
|
38
|
+
* rendered three labelled voids: "Category", "Currency", "Where it is" over
|
|
39
|
+
* empty space, and a "Photos" heading over air. A label is a promise that a
|
|
40
|
+
* control follows it. {@link SlotField} therefore renders the WHOLE field —
|
|
41
|
+
* label, help text and control — or nothing at all, and the `Photos` section
|
|
42
|
+
* does the same with its heading. `slotVisibility` pins the dev view on so a
|
|
43
|
+
* production-built showcase can still photograph the named placeholders.
|
|
44
|
+
*
|
|
45
|
+
* ── Why the pickers are slots and not dependencies ─────────────────────────
|
|
46
|
+
*
|
|
47
|
+
* A category tree, a geocoder and a currency vocabulary are all DEPLOYMENT
|
|
48
|
+
* knowledge, and all three live in sibling L2 pairs
|
|
49
|
+
* (`@stapel/categories-react`, `@stapel/geo-react`, the host's own). L2 pairs
|
|
50
|
+
* do not import each other; the container is the seam. A library that picked
|
|
51
|
+
* one would pick it for every host.
|
|
30
52
|
*
|
|
31
53
|
* ── Every blocked control says which of six reasons it is ──────────────────
|
|
32
54
|
*
|
|
@@ -35,29 +57,42 @@
|
|
|
35
57
|
* category asks for", "this build cannot draw one of these details", "wait
|
|
36
58
|
* for the photos", "fix the highlighted fields" are six different problems
|
|
37
59
|
* with six different next actions. `firstBlock` orders them the way a person
|
|
38
|
-
* would be told, and the reason
|
|
39
|
-
*
|
|
60
|
+
* would be told, and `<GatedButton>` renders the reason beside the button —
|
|
61
|
+
* never a grey rectangle, never a hover.
|
|
40
62
|
*/
|
|
41
|
-
import type { ReactElement, ReactNode } from "react";
|
|
63
|
+
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
42
64
|
import {
|
|
43
65
|
Alert,
|
|
44
|
-
Button,
|
|
45
66
|
Checkbox,
|
|
46
67
|
Divider,
|
|
47
68
|
Flex,
|
|
48
69
|
Form,
|
|
49
70
|
Input,
|
|
50
71
|
InputNumber,
|
|
51
|
-
Space,
|
|
52
72
|
Typography,
|
|
53
73
|
} from "antd";
|
|
54
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
ErrorAlert,
|
|
76
|
+
GatedButton,
|
|
77
|
+
PaneGate,
|
|
78
|
+
SkinTheme,
|
|
79
|
+
} from "@stapel/tokens-antd/skin";
|
|
80
|
+
import {
|
|
81
|
+
SlotPlaceholder,
|
|
82
|
+
actionAvailable,
|
|
83
|
+
isDevBuild,
|
|
84
|
+
useDescribeFlowError,
|
|
85
|
+
useI18n,
|
|
86
|
+
useT,
|
|
87
|
+
} from "@stapel/core";
|
|
88
|
+
import { spacing } from "@stapel/tokens";
|
|
55
89
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
56
90
|
import {
|
|
57
91
|
BUILTIN_VALUE_EDITOR_TYPES,
|
|
58
92
|
FeatureFields,
|
|
59
93
|
} from "@stapel/attributes-react/default";
|
|
60
94
|
import { useListingComposer } from "../headless/ListingComposer.js";
|
|
95
|
+
import type { ListingLocation } from "../model/draft.js";
|
|
61
96
|
import type { ListingImagesBag } from "../headless/ListingComposer.js";
|
|
62
97
|
import {
|
|
63
98
|
CATEGORY_FIELD,
|
|
@@ -68,10 +103,15 @@ import {
|
|
|
68
103
|
failedResults,
|
|
69
104
|
} from "../model/validation.js";
|
|
70
105
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
71
|
-
import { ErrorAlert } from "./ErrorAlert.js";
|
|
72
|
-
import { ListingsSkinTheme } from "./theme.js";
|
|
73
106
|
import type { ThemeModeProp } from "./types.js";
|
|
74
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A form is read in one column, and a column has a width past which the eye
|
|
110
|
+
* loses the line — the reason a 2560px pane once rendered a 1640px Title box.
|
|
111
|
+
* A measure, not a pixel guess: `ch` scales with the type the theme is set to.
|
|
112
|
+
*/
|
|
113
|
+
export const COMPOSER_MEASURE = "44rem";
|
|
114
|
+
|
|
75
115
|
/**
|
|
76
116
|
* What `renderCategoryPicker` is handed: the current category and the ONLY
|
|
77
117
|
* function that changes it. Same shape as any other bag in this monorepo — the
|
|
@@ -85,6 +125,96 @@ export interface ComposerCategorySlot {
|
|
|
85
125
|
readonly setCategory: (categoryId: string) => void;
|
|
86
126
|
}
|
|
87
127
|
|
|
128
|
+
/**
|
|
129
|
+
* What `renderCurrencyPicker` is handed: the code the draft carries and the
|
|
130
|
+
* one function that changes it.
|
|
131
|
+
*
|
|
132
|
+
* The vocabulary is not this pair's: `Listing.currency` is a free
|
|
133
|
+
* `maxLength: 8` string on the wire (stapel-listings has no currency enum —
|
|
134
|
+
* the list lives in stapel-currencies), so the pair holds a DEFAULT from
|
|
135
|
+
* `createListingsRuntime` and asks the container for a chooser. Unfilled, the
|
|
136
|
+
* price field simply states which currency it is in.
|
|
137
|
+
*/
|
|
138
|
+
export interface ComposerCurrencySlot {
|
|
139
|
+
/** ISO-4217-ish code, e.g. `"RUB"`. Never empty — the runtime's default. */
|
|
140
|
+
readonly value: string;
|
|
141
|
+
readonly setCurrency: (code: string) => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What a location picker component is handed — the contract
|
|
146
|
+
* `@stapel/geo-react`'s `<LocationPickerField>` was built to fill.
|
|
147
|
+
*
|
|
148
|
+
* `{ value, onChange }`, and `value` is the pin plus the address a resolver
|
|
149
|
+
* found for it. `lat`/`lon` are NUMBERS here (a picker works in numbers) while
|
|
150
|
+
* the draft keeps decimal STRINGS, because a float round-trips `55.796100`
|
|
151
|
+
* into `55.7961` and changes what was submitted; the conversion happens in
|
|
152
|
+
* this component, once, at the seam.
|
|
153
|
+
*
|
|
154
|
+
* Geo-react's own props are `value?: LatLon` and
|
|
155
|
+
* `onChange?: (picked: PickedLocation) => void`, so the adapter a container
|
|
156
|
+
* writes is three lines and no state:
|
|
157
|
+
*
|
|
158
|
+
* ```tsx
|
|
159
|
+
* import { LocationPickerField } from "@stapel/geo-react/default";
|
|
160
|
+
*
|
|
161
|
+
* <ListingComposerPage
|
|
162
|
+
* locationPicker={({ value, onChange }) => (
|
|
163
|
+
* <LocationPickerField
|
|
164
|
+
* {...(value.lat !== null && value.lon !== null
|
|
165
|
+
* ? { value: { lat: value.lat, lon: value.lon } }
|
|
166
|
+
* : {})}
|
|
167
|
+
* onChange={(picked) => {
|
|
168
|
+
* onChange({ lat: picked.point.lat, lon: picked.point.lon,
|
|
169
|
+
* address: picked.address ?? undefined });
|
|
170
|
+
* }}
|
|
171
|
+
* />
|
|
172
|
+
* )}
|
|
173
|
+
* />
|
|
174
|
+
* ```
|
|
175
|
+
*
|
|
176
|
+
* `geohash` is absent from this contract on purpose: since stapel-listings
|
|
177
|
+
* 0.7.1 the server computes it from the coordinates and ignores anything sent
|
|
178
|
+
* in the body, so a picker that has one has nowhere to put it and no reason
|
|
179
|
+
* to try (`model/draft.ts`).
|
|
180
|
+
*/
|
|
181
|
+
export interface ComposerLocationValue {
|
|
182
|
+
/** Decimal degrees, or `null` when no place has been chosen yet. */
|
|
183
|
+
readonly lat: number | null;
|
|
184
|
+
readonly lon: number | null;
|
|
185
|
+
/** The one-line label a person reads: "Kazan, Vahitovsky district". */
|
|
186
|
+
readonly address?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ComposerLocationPickerProps {
|
|
190
|
+
readonly value: ComposerLocationValue;
|
|
191
|
+
readonly onChange: (next: ComposerLocationValue) => void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The lower-level location seam: the WHOLE draft composite, and the `save`
|
|
196
|
+
* the plain fields call on blur.
|
|
197
|
+
*
|
|
198
|
+
* Kept beside {@link ComposerLocationPickerProps} for a host whose picker
|
|
199
|
+
* also resolves a `location_id` out of its own place directory — the id is
|
|
200
|
+
* this pair's wire field and no generic picker knows about it. A container
|
|
201
|
+
* that only has a pin and an address wants `locationPicker` instead.
|
|
202
|
+
*/
|
|
203
|
+
export interface ComposerLocationSlot {
|
|
204
|
+
/** The location the draft currently carries. */
|
|
205
|
+
readonly value: ListingLocation;
|
|
206
|
+
/** Write the whole composite. `geohash` is read-only on the wire (0.7.1) —
|
|
207
|
+
* whatever is written here is kept for display and never sent. */
|
|
208
|
+
readonly setLocation: (location: ListingLocation) => void;
|
|
209
|
+
/**
|
|
210
|
+
* Persist the draft now. The plain fields save on blur, and a picker has no
|
|
211
|
+
* blur to speak of: choosing a suggestion IS the commit, so the slot is
|
|
212
|
+
* handed the same `save` those fields call rather than being left to hope
|
|
213
|
+
* the next unrelated blur carries its value.
|
|
214
|
+
*/
|
|
215
|
+
readonly save: () => void;
|
|
216
|
+
}
|
|
217
|
+
|
|
88
218
|
export interface ListingComposerPageProps extends ThemeModeProp {
|
|
89
219
|
/** Editing an existing listing; absent for a new one. */
|
|
90
220
|
readonly listingId?: number;
|
|
@@ -105,6 +235,9 @@ export interface ListingComposerPageProps extends ThemeModeProp {
|
|
|
105
235
|
* />
|
|
106
236
|
* )}
|
|
107
237
|
* ```
|
|
238
|
+
*
|
|
239
|
+
* Unfilled: a named placeholder. The composer will NOT ask a person to type
|
|
240
|
+
* a category id.
|
|
108
241
|
*/
|
|
109
242
|
readonly renderCategoryPicker?: (slot: ComposerCategorySlot) => ReactNode;
|
|
110
243
|
/**
|
|
@@ -115,28 +248,97 @@ export interface ListingComposerPageProps extends ThemeModeProp {
|
|
|
115
248
|
/** Called whenever the category changes, controlled or not. */
|
|
116
249
|
readonly onCategoryChange?: (categoryId: string) => void;
|
|
117
250
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
|
|
121
|
-
|
|
251
|
+
* The currency chooser. Unfilled, the price field states the deployment's
|
|
252
|
+
* currency and nothing asks the seller to type a code.
|
|
253
|
+
*/
|
|
254
|
+
readonly renderCurrencyPicker?: (slot: ComposerCurrencySlot) => ReactNode;
|
|
255
|
+
/**
|
|
256
|
+
* WHERE the thing is, asked the host's way — the slot
|
|
257
|
+
* `@stapel/geo-react`'s `<LocationPickerField>` fills (see
|
|
258
|
+
* {@link ComposerLocationPickerProps} for the three-line adapter).
|
|
259
|
+
*
|
|
260
|
+
* Unfilled: a named placeholder. The composer will NOT fall back to two
|
|
261
|
+
* decimal boxes — a seller does not know their latitude, and those boxes
|
|
262
|
+
* were the only reason `location` was ever left empty on this fleet.
|
|
263
|
+
*/
|
|
264
|
+
readonly locationPicker?: ComponentType<ComposerLocationPickerProps>;
|
|
265
|
+
/**
|
|
266
|
+
* The whole-composite form of the same seam, for a picker that also resolves
|
|
267
|
+
* this pair's `location_id`. Wins over `locationPicker` when both are given.
|
|
122
268
|
*/
|
|
123
|
-
readonly
|
|
124
|
-
/** The photo grid
|
|
269
|
+
readonly renderLocationPicker?: (slot: ComposerLocationSlot) => ReactNode;
|
|
270
|
+
/** The photo grid — `@stapel/cdn-react`'s `<MediaGalleryField bag>`. Its bag
|
|
271
|
+
* is what `images` carries. Unfilled: a named placeholder, not a heading
|
|
272
|
+
* over nothing. */
|
|
125
273
|
readonly gallerySlot?: ReactNode;
|
|
126
274
|
/** `useUploadQueue()`'s bag from `@stapel/cdn-react` — `refs` becomes
|
|
127
275
|
* `images_draft`, `settled` gates the submit. */
|
|
128
276
|
readonly images?: ListingImagesBag;
|
|
277
|
+
/**
|
|
278
|
+
* Whether an unfilled slot shows its named placeholder. `"auto"` (default)
|
|
279
|
+
* follows the build: named in development, the whole field absent in
|
|
280
|
+
* production. `"visible"` pins it on — for a showcase built in production
|
|
281
|
+
* mode, which is the only way to photograph an integration defect.
|
|
282
|
+
*/
|
|
283
|
+
readonly slotVisibility?: "auto" | "visible";
|
|
129
284
|
readonly onPublished?: (listingId: number) => void;
|
|
130
285
|
}
|
|
131
286
|
|
|
287
|
+
/**
|
|
288
|
+
* A labelled field whose control comes from a slot — or NOTHING.
|
|
289
|
+
*
|
|
290
|
+
* The composer's orphan-field bug in one component: a `Form.Item` renders its
|
|
291
|
+
* label unconditionally, so an unfilled slot (which is `null` in a production
|
|
292
|
+
* build) left the label standing over empty space. Here the label and the
|
|
293
|
+
* control are one decision. `named` says whether the dev placeholder will
|
|
294
|
+
* draw; when it will not and the host filled nothing, the field is not part of
|
|
295
|
+
* the form at all.
|
|
296
|
+
*/
|
|
297
|
+
function SlotField(props: {
|
|
298
|
+
readonly label: string;
|
|
299
|
+
readonly extra?: string;
|
|
300
|
+
readonly slot: string;
|
|
301
|
+
readonly named: boolean;
|
|
302
|
+
readonly control: ReactNode | undefined;
|
|
303
|
+
readonly status: { help: ReactNode; validateStatus: "error" } | Record<string, never>;
|
|
304
|
+
readonly testId: string;
|
|
305
|
+
}): ReactElement | null {
|
|
306
|
+
const filled = props.control !== undefined;
|
|
307
|
+
if (!filled && !props.named) return null;
|
|
308
|
+
return (
|
|
309
|
+
<Form.Item
|
|
310
|
+
label={props.label}
|
|
311
|
+
{...(props.extra !== undefined ? { extra: props.extra } : {})}
|
|
312
|
+
{...props.status}
|
|
313
|
+
>
|
|
314
|
+
<div data-testid={props.testId}>
|
|
315
|
+
{props.control ?? <SlotPlaceholder name={props.slot} visibility="visible" />}
|
|
316
|
+
</div>
|
|
317
|
+
</Form.Item>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A decimal string → the number a picker works in, or `null`. */
|
|
322
|
+
function toNumber(value: string | null): number | null {
|
|
323
|
+
if (value === null || value.length === 0) return null;
|
|
324
|
+
const parsed = Number(value);
|
|
325
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
326
|
+
}
|
|
327
|
+
|
|
132
328
|
export function ListingComposerPage(
|
|
133
329
|
props: ListingComposerPageProps
|
|
134
330
|
): ReactElement {
|
|
135
331
|
const t = useT();
|
|
332
|
+
const { locale } = useI18n();
|
|
136
333
|
const describe = useDescribeFlowError();
|
|
137
334
|
const bag = useListingComposer({
|
|
138
335
|
...(props.listingId !== undefined ? { listingId: props.listingId } : {}),
|
|
139
336
|
features: props.features,
|
|
337
|
+
// A new draft carries the language it was WRITTEN in, and the only honest
|
|
338
|
+
// guess is the locale the composer is being read in. It was left empty and
|
|
339
|
+
// the server's default decided instead — a Spanish seller filing a listing
|
|
340
|
+
// stamped `ru` because that is the deployment default (gap L-9).
|
|
341
|
+
language: locale,
|
|
140
342
|
...(props.featuresLoading !== undefined
|
|
141
343
|
? { featuresLoading: props.featuresLoading }
|
|
142
344
|
: {}),
|
|
@@ -177,9 +379,23 @@ export function ListingComposerPage(
|
|
|
177
379
|
: LISTINGS_I18N_KEYS.composePublish
|
|
178
380
|
);
|
|
179
381
|
|
|
382
|
+
const LocationPicker = props.locationPicker;
|
|
383
|
+
// Whether an unfilled slot draws its named placeholder — and therefore
|
|
384
|
+
// whether the field it belongs to exists at all.
|
|
385
|
+
const namedSlots = props.slotVisibility === "visible" || isDevBuild();
|
|
386
|
+
|
|
180
387
|
return (
|
|
181
|
-
<
|
|
182
|
-
|
|
388
|
+
<SkinTheme
|
|
389
|
+
surface="base"
|
|
390
|
+
style={{ maxWidth: COMPOSER_MEASURE, padding: spacing[4] }}
|
|
391
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
392
|
+
>
|
|
393
|
+
<Flex
|
|
394
|
+
vertical
|
|
395
|
+
gap={spacing[4]}
|
|
396
|
+
data-testid="listings-composer"
|
|
397
|
+
data-stage={bag.stage}
|
|
398
|
+
>
|
|
183
399
|
<Typography.Title level={3}>
|
|
184
400
|
{t(
|
|
185
401
|
props.listingId === undefined
|
|
@@ -188,7 +404,7 @@ export function ListingComposerPage(
|
|
|
188
404
|
)}
|
|
189
405
|
</Typography.Title>
|
|
190
406
|
|
|
191
|
-
{/* stapel-listings 0.
|
|
407
|
+
{/* stapel-listings 0.7.1 exposes no read of the `*_draft` twin, so a
|
|
192
408
|
draft reopened in a new session comes back empty. Saying so beats
|
|
193
409
|
a blank form that looks like lost work. */}
|
|
194
410
|
{bag.draftNotReadable ? (
|
|
@@ -196,7 +412,7 @@ export function ListingComposerPage(
|
|
|
196
412
|
type="warning"
|
|
197
413
|
showIcon
|
|
198
414
|
data-testid="listings-composer-draft-unreadable"
|
|
199
|
-
|
|
415
|
+
title={t(LISTINGS_I18N_KEYS.composeBlockedNoDraft)}
|
|
200
416
|
/>
|
|
201
417
|
) : null}
|
|
202
418
|
|
|
@@ -206,7 +422,7 @@ export function ListingComposerPage(
|
|
|
206
422
|
showIcon
|
|
207
423
|
data-testid="listings-composer-published"
|
|
208
424
|
data-outcome={bag.outcome}
|
|
209
|
-
|
|
425
|
+
title={t(
|
|
210
426
|
bag.outcome === "live_edit_under_review"
|
|
211
427
|
? LISTINGS_I18N_KEYS.composePublishedLive
|
|
212
428
|
: LISTINGS_I18N_KEYS.composePublishedFirst
|
|
@@ -215,34 +431,25 @@ export function ListingComposerPage(
|
|
|
215
431
|
) : null}
|
|
216
432
|
|
|
217
433
|
<Form layout="vertical" data-testid="listings-composer-form">
|
|
218
|
-
<
|
|
434
|
+
<SlotField
|
|
219
435
|
label={t(LISTINGS_I18N_KEYS.composeCategory)}
|
|
220
436
|
extra={t(LISTINGS_I18N_KEYS.composeCategoryHelp)}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
{
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
value={bag.values.categoryId}
|
|
231
|
-
aria-label={t(LISTINGS_I18N_KEYS.composeCategory)}
|
|
232
|
-
data-testid="listings-composer-category"
|
|
233
|
-
onChange={(event) => {
|
|
234
|
-
bag.setCategory(event.target.value);
|
|
235
|
-
}}
|
|
236
|
-
/>
|
|
237
|
-
))}
|
|
238
|
-
</Form.Item>
|
|
437
|
+
slot="renderCategoryPicker"
|
|
438
|
+
named={namedSlots}
|
|
439
|
+
status={errorOf(CATEGORY_FIELD)}
|
|
440
|
+
testId="listings-composer-category"
|
|
441
|
+
control={props.renderCategoryPicker?.({
|
|
442
|
+
value: bag.values.categoryId,
|
|
443
|
+
setCategory: bag.setCategory,
|
|
444
|
+
})}
|
|
445
|
+
/>
|
|
239
446
|
|
|
240
447
|
{bag.droppedOnCategoryChange.length > 0 ? (
|
|
241
448
|
<Alert
|
|
242
449
|
type="info"
|
|
243
450
|
showIcon
|
|
244
451
|
data-testid="listings-composer-dropped"
|
|
245
|
-
|
|
452
|
+
title={t(LISTINGS_I18N_KEYS.composeCategoryChangedDropped, {
|
|
246
453
|
count: bag.droppedOnCategoryChange.length,
|
|
247
454
|
})}
|
|
248
455
|
/>
|
|
@@ -279,106 +486,116 @@ export function ListingComposerPage(
|
|
|
279
486
|
/>
|
|
280
487
|
</Form.Item>
|
|
281
488
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
onChange={(event) => {
|
|
293
|
-
bag.setValue("price", event.target.value);
|
|
294
|
-
}}
|
|
295
|
-
onBlur={bag.save}
|
|
296
|
-
/>
|
|
297
|
-
</Form.Item>
|
|
298
|
-
|
|
299
|
-
<Form.Item label={t(LISTINGS_I18N_KEYS.composeCurrencyLabel)}>
|
|
300
|
-
<Input
|
|
301
|
-
value={bag.values.currency}
|
|
302
|
-
aria-label={t(LISTINGS_I18N_KEYS.composeCurrencyLabel)}
|
|
303
|
-
data-testid="listings-composer-currency"
|
|
304
|
-
onChange={(event) => {
|
|
305
|
-
bag.setValue("currency", event.target.value);
|
|
306
|
-
}}
|
|
307
|
-
onBlur={bag.save}
|
|
308
|
-
/>
|
|
309
|
-
</Form.Item>
|
|
310
|
-
</Space>
|
|
311
|
-
|
|
312
|
-
<Form.Item label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}>
|
|
489
|
+
{/* Price and currency are ONE question, asked ONCE. The currency is
|
|
490
|
+
not a text box: the vocabulary is the deployment's
|
|
491
|
+
(stapel-currencies). With no chooser the code rides along as the
|
|
492
|
+
field's addon so the price still says what it is in; with one,
|
|
493
|
+
the addon would be a second currency control saying the same
|
|
494
|
+
thing beside the first. */}
|
|
495
|
+
<Form.Item
|
|
496
|
+
label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
|
|
497
|
+
{...errorOf(PRICE_FIELD)}
|
|
498
|
+
>
|
|
313
499
|
<Input
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
500
|
+
inputMode="decimal"
|
|
501
|
+
value={bag.values.price}
|
|
502
|
+
{...(props.renderCurrencyPicker === undefined
|
|
503
|
+
? { addonAfter: bag.values.currency }
|
|
504
|
+
: {})}
|
|
505
|
+
aria-label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
|
|
506
|
+
data-testid="listings-composer-price"
|
|
317
507
|
onChange={(event) => {
|
|
318
|
-
bag.
|
|
319
|
-
...bag.values.location,
|
|
320
|
-
locationLabel: event.target.value,
|
|
321
|
-
});
|
|
508
|
+
bag.setValue("price", event.target.value);
|
|
322
509
|
}}
|
|
323
510
|
onBlur={bag.save}
|
|
324
511
|
/>
|
|
325
512
|
</Form.Item>
|
|
326
513
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
aria-label={t(LISTINGS_I18N_KEYS.composeLatLabel)}
|
|
342
|
-
data-testid="listings-composer-lat"
|
|
343
|
-
onChange={(event) => {
|
|
344
|
-
bag.setLocation({
|
|
345
|
-
...bag.values.location,
|
|
346
|
-
lat: event.target.value.length > 0 ? event.target.value : null,
|
|
347
|
-
});
|
|
348
|
-
}}
|
|
349
|
-
onBlur={bag.save}
|
|
350
|
-
/>
|
|
351
|
-
</Form.Item>
|
|
352
|
-
<Form.Item
|
|
353
|
-
label={t(LISTINGS_I18N_KEYS.composeLonLabel)}
|
|
354
|
-
{...errorOf("location")}
|
|
355
|
-
>
|
|
356
|
-
<Input
|
|
357
|
-
inputMode="decimal"
|
|
358
|
-
value={bag.values.location.lon ?? ""}
|
|
359
|
-
aria-label={t(LISTINGS_I18N_KEYS.composeLonLabel)}
|
|
360
|
-
data-testid="listings-composer-lon"
|
|
361
|
-
onChange={(event) => {
|
|
362
|
-
bag.setLocation({
|
|
363
|
-
...bag.values.location,
|
|
364
|
-
lon: event.target.value.length > 0 ? event.target.value : null,
|
|
365
|
-
});
|
|
366
|
-
}}
|
|
367
|
-
onBlur={bag.save}
|
|
368
|
-
/>
|
|
369
|
-
</Form.Item>
|
|
370
|
-
</Space>
|
|
514
|
+
<SlotField
|
|
515
|
+
label={t(LISTINGS_I18N_KEYS.composeCurrencyLabel)}
|
|
516
|
+
slot="renderCurrencyPicker"
|
|
517
|
+
named={namedSlots}
|
|
518
|
+
status={{}}
|
|
519
|
+
testId="listings-composer-currency"
|
|
520
|
+
control={props.renderCurrencyPicker?.({
|
|
521
|
+
value: bag.values.currency,
|
|
522
|
+
setCurrency: (code) => {
|
|
523
|
+
bag.setValue("currency", code);
|
|
524
|
+
bag.saveSoon();
|
|
525
|
+
},
|
|
526
|
+
})}
|
|
527
|
+
/>
|
|
371
528
|
|
|
372
|
-
|
|
529
|
+
{/* WHERE, asked once, by whoever can resolve places.
|
|
530
|
+
A seller does not know their latitude — two decimal boxes are a
|
|
531
|
+
question no advert-poster on any marketplace has ever been
|
|
532
|
+
asked. This pair cannot ask for an ADDRESS on its own either:
|
|
533
|
+
that needs a geocoder, and a geocoder is a deployment's
|
|
534
|
+
(`@stapel/geo-react` over stapel-geo, on this fleet). So the
|
|
535
|
+
question is a slot, and an unfilled slot says its own name
|
|
536
|
+
instead of improvising a control nobody can use. */}
|
|
537
|
+
<SlotField
|
|
538
|
+
label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
|
|
539
|
+
extra={t(LISTINGS_I18N_KEYS.composeLocationHelp)}
|
|
540
|
+
slot="locationPicker"
|
|
541
|
+
named={namedSlots}
|
|
542
|
+
status={errorOf("location")}
|
|
543
|
+
testId="listings-composer-location"
|
|
544
|
+
control={
|
|
545
|
+
props.renderLocationPicker !== undefined ? (
|
|
546
|
+
<div data-testid="listings-composer-location-slot">
|
|
547
|
+
{props.renderLocationPicker({
|
|
548
|
+
value: bag.values.location,
|
|
549
|
+
setLocation: (location) => {
|
|
550
|
+
bag.setLocation(location);
|
|
551
|
+
},
|
|
552
|
+
save: bag.saveSoon,
|
|
553
|
+
})}
|
|
554
|
+
</div>
|
|
555
|
+
) : LocationPicker !== undefined ? (
|
|
556
|
+
<div data-testid="listings-composer-location-slot">
|
|
557
|
+
<LocationPicker
|
|
558
|
+
value={{
|
|
559
|
+
lat: toNumber(bag.values.location.lat),
|
|
560
|
+
lon: toNumber(bag.values.location.lon),
|
|
561
|
+
...(bag.values.location.locationLabel.length > 0
|
|
562
|
+
? { address: bag.values.location.locationLabel }
|
|
563
|
+
: {}),
|
|
564
|
+
}}
|
|
565
|
+
onChange={(next) => {
|
|
566
|
+
// Numbers in, decimal strings out — the wire's spelling,
|
|
567
|
+
// written at the seam so no other module has to know.
|
|
568
|
+
bag.setLocation({
|
|
569
|
+
...bag.values.location,
|
|
570
|
+
lat: next.lat === null ? null : String(next.lat),
|
|
571
|
+
lon: next.lon === null ? null : String(next.lon),
|
|
572
|
+
locationLabel: next.address ?? "",
|
|
573
|
+
});
|
|
574
|
+
bag.saveSoon();
|
|
575
|
+
}}
|
|
576
|
+
/>
|
|
577
|
+
</div>
|
|
578
|
+
) : undefined
|
|
579
|
+
}
|
|
580
|
+
/>
|
|
373
581
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
582
|
+
{/* A heading is a promise that something follows it. With no gallery
|
|
583
|
+
and no named placeholder there is nothing to head. */}
|
|
584
|
+
{props.gallerySlot !== undefined || namedSlots ? (
|
|
585
|
+
<>
|
|
586
|
+
<Divider />
|
|
587
|
+
<Typography.Title level={5}>
|
|
588
|
+
{t(LISTINGS_I18N_KEYS.composePhotos)}
|
|
589
|
+
</Typography.Title>
|
|
590
|
+
{props.gallerySlot ?? (
|
|
591
|
+
<SlotPlaceholder name="gallerySlot" visibility="visible" />
|
|
592
|
+
)}
|
|
593
|
+
{bag.fieldErrors[IMAGES_FIELD] ? (
|
|
594
|
+
<Typography.Text type="danger" data-testid="listings-composer-images-error">
|
|
595
|
+
{describe(bag.fieldErrors[IMAGES_FIELD]).message}
|
|
596
|
+
</Typography.Text>
|
|
597
|
+
) : null}
|
|
598
|
+
</>
|
|
382
599
|
) : null}
|
|
383
600
|
|
|
384
601
|
<Divider />
|
|
@@ -387,10 +604,8 @@ export function ListingComposerPage(
|
|
|
387
604
|
{t(LISTINGS_I18N_KEYS.composeDetails)}
|
|
388
605
|
</Typography.Title>
|
|
389
606
|
{props.featuresError !== undefined ? (
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
showIcon
|
|
393
|
-
data-testid="listings-composer-features-failed"
|
|
607
|
+
<ErrorAlert
|
|
608
|
+
testId="listings-composer-features-failed"
|
|
394
609
|
message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
|
|
395
610
|
/>
|
|
396
611
|
) : props.featuresLoading === true ? (
|
|
@@ -453,10 +668,8 @@ export function ListingComposerPage(
|
|
|
453
668
|
</Form>
|
|
454
669
|
|
|
455
670
|
{bag.refusal?.kind === "invalid_draft" ? (
|
|
456
|
-
<
|
|
457
|
-
|
|
458
|
-
showIcon
|
|
459
|
-
data-testid="listings-composer-invalid"
|
|
671
|
+
<ErrorAlert
|
|
672
|
+
testId="listings-composer-invalid"
|
|
460
673
|
message={t(LISTINGS_I18N_KEYS.composeInvalidSummary, {
|
|
461
674
|
count: failedResults(bag.refusal.batch).length,
|
|
462
675
|
})}
|
|
@@ -479,53 +692,56 @@ export function ListingComposerPage(
|
|
|
479
692
|
{bag.saveError !== undefined && bag.saveError !== null ? (
|
|
480
693
|
<ErrorAlert
|
|
481
694
|
testId="listings-composer-save-error"
|
|
482
|
-
|
|
483
|
-
code: LISTINGS_I18N_KEYS.unknownError,
|
|
484
|
-
params: {},
|
|
485
|
-
status: 0,
|
|
486
|
-
message: undefined,
|
|
487
|
-
language: undefined,
|
|
488
|
-
})}
|
|
695
|
+
thrown={bag.saveError}
|
|
489
696
|
/>
|
|
490
697
|
) : null}
|
|
491
698
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
699
|
+
{/* One primary, and it LEADS. Two same-weight boxes side by side, the
|
|
700
|
+
filled one second, is a footer with no primary in it — which is
|
|
701
|
+
how it read on a phone, where the eye takes the first control as
|
|
702
|
+
the action. `Save draft` is the quiet way out, so it is quiet.
|
|
703
|
+
Each keeps its own reason directly under it, never a grey slab
|
|
704
|
+
under the pair. */}
|
|
705
|
+
{/* The footer is one SCOPE, so the reason both buttons are off is
|
|
706
|
+
written once and both point at it. "Choose a category" under
|
|
707
|
+
Publish and again under Save draft is one fact printed twice. */}
|
|
708
|
+
<PaneGate gate={actionAvailable()} testId="listings-composer-footer">
|
|
709
|
+
<Flex vertical gap={spacing[3]} align="flex-start">
|
|
710
|
+
<GatedButton
|
|
711
|
+
gate={bag.publishGate}
|
|
505
712
|
type="primary"
|
|
506
|
-
|
|
713
|
+
size="large"
|
|
507
714
|
loading={bag.publishing}
|
|
508
|
-
|
|
715
|
+
testId="listings-composer-publish"
|
|
509
716
|
data-analytics="none"
|
|
510
717
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
511
718
|
onClick={bag.publish}
|
|
512
719
|
>
|
|
513
720
|
{bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
|
|
514
|
-
</
|
|
721
|
+
</GatedButton>
|
|
515
722
|
|
|
516
|
-
{
|
|
517
|
-
<
|
|
518
|
-
{
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
723
|
+
<Flex gap={spacing[3]} wrap align="flex-start">
|
|
724
|
+
<GatedButton
|
|
725
|
+
gate={bag.saveGate}
|
|
726
|
+
type="text"
|
|
727
|
+
loading={bag.saving}
|
|
728
|
+
testId="listings-composer-save"
|
|
729
|
+
data-analytics="none"
|
|
730
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
731
|
+
onClick={bag.save}
|
|
732
|
+
>
|
|
733
|
+
{t(bag.saving ? LISTINGS_I18N_KEYS.composeSaving : LISTINGS_I18N_KEYS.composeSave)}
|
|
734
|
+
</GatedButton>
|
|
522
735
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
736
|
+
{bag.saved ? (
|
|
737
|
+
<Typography.Text type="success" data-testid="listings-composer-saved">
|
|
738
|
+
{t(LISTINGS_I18N_KEYS.composeSaved)}
|
|
739
|
+
</Typography.Text>
|
|
740
|
+
) : null}
|
|
741
|
+
</Flex>
|
|
742
|
+
</Flex>
|
|
743
|
+
</PaneGate>
|
|
528
744
|
</Flex>
|
|
529
|
-
</
|
|
745
|
+
</SkinTheme>
|
|
530
746
|
);
|
|
531
747
|
}
|