@stapel/listings-react 0.3.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/MODULE.md +12 -6
  3. package/README.md +33 -17
  4. package/dist/api/generated/schema.d.ts +129 -3
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +35 -22
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js +17 -0
  9. package/dist/api/listingsApi.js.map +1 -1
  10. package/dist/api/types.d.ts +43 -2
  11. package/dist/api/types.d.ts.map +1 -1
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/FavoritesPane.d.ts +40 -3
  14. package/dist/default/FavoritesPane.d.ts.map +1 -1
  15. package/dist/default/FavoritesPane.js +30 -18
  16. package/dist/default/FavoritesPane.js.map +1 -1
  17. package/dist/default/ListingCard.d.ts +25 -4
  18. package/dist/default/ListingCard.d.ts.map +1 -1
  19. package/dist/default/ListingCard.js +19 -12
  20. package/dist/default/ListingCard.js.map +1 -1
  21. package/dist/default/ListingComposerPage.d.ts +163 -23
  22. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  23. package/dist/default/ListingComposerPage.js +85 -43
  24. package/dist/default/ListingComposerPage.js.map +1 -1
  25. package/dist/default/ListingDetailPane.d.ts +41 -4
  26. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  27. package/dist/default/ListingDetailPane.js +38 -25
  28. package/dist/default/ListingDetailPane.js.map +1 -1
  29. package/dist/default/ListingPhoto.d.ts +13 -1
  30. package/dist/default/ListingPhoto.d.ts.map +1 -1
  31. package/dist/default/ListingPhoto.js +47 -9
  32. package/dist/default/ListingPhoto.js.map +1 -1
  33. package/dist/default/ListingPrice.d.ts +13 -0
  34. package/dist/default/ListingPrice.d.ts.map +1 -0
  35. package/dist/default/ListingPrice.js +60 -0
  36. package/dist/default/ListingPrice.js.map +1 -0
  37. package/dist/default/MyListingsPane.d.ts +9 -17
  38. package/dist/default/MyListingsPane.d.ts.map +1 -1
  39. package/dist/default/MyListingsPane.js +148 -63
  40. package/dist/default/MyListingsPane.js.map +1 -1
  41. package/dist/default/SignInLink.js +8 -7
  42. package/dist/default/SignInLink.js.map +1 -1
  43. package/dist/default/StatusTags.d.ts +16 -5
  44. package/dist/default/StatusTags.d.ts.map +1 -1
  45. package/dist/default/StatusTags.js +19 -13
  46. package/dist/default/StatusTags.js.map +1 -1
  47. package/dist/default/index.d.ts +15 -9
  48. package/dist/default/index.d.ts.map +1 -1
  49. package/dist/default/index.js +13 -6
  50. package/dist/default/index.js.map +1 -1
  51. package/dist/headless/Favorites.d.ts +6 -0
  52. package/dist/headless/Favorites.d.ts.map +1 -1
  53. package/dist/headless/Favorites.js +4 -0
  54. package/dist/headless/Favorites.js.map +1 -1
  55. package/dist/headless/ListingActions.d.ts +12 -0
  56. package/dist/headless/ListingActions.d.ts.map +1 -1
  57. package/dist/headless/ListingActions.js +3 -0
  58. package/dist/headless/ListingActions.js.map +1 -1
  59. package/dist/headless/ListingComposer.d.ts +23 -0
  60. package/dist/headless/ListingComposer.d.ts.map +1 -1
  61. package/dist/headless/ListingComposer.js +33 -7
  62. package/dist/headless/ListingComposer.js.map +1 -1
  63. package/dist/headless/MyListings.d.ts +27 -12
  64. package/dist/headless/MyListings.d.ts.map +1 -1
  65. package/dist/headless/MyListings.js +29 -7
  66. package/dist/headless/MyListings.js.map +1 -1
  67. package/dist/i18n/es.d.ts +6 -1
  68. package/dist/i18n/es.d.ts.map +1 -1
  69. package/dist/i18n/es.js +28 -6
  70. package/dist/i18n/es.js.map +1 -1
  71. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  72. package/dist/i18n/generated/errors.es.gen.js +1 -1
  73. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  74. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  75. package/dist/i18n/generated/errors.gen.js +3 -0
  76. package/dist/i18n/generated/errors.gen.js.map +1 -1
  77. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  78. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  79. package/dist/i18n/keys.d.ts +28 -4
  80. package/dist/i18n/keys.d.ts.map +1 -1
  81. package/dist/i18n/keys.js +49 -8
  82. package/dist/i18n/keys.js.map +1 -1
  83. package/dist/i18n/ru.d.ts +16 -1
  84. package/dist/i18n/ru.d.ts.map +1 -1
  85. package/dist/i18n/ru.js +40 -6
  86. package/dist/i18n/ru.js.map +1 -1
  87. package/dist/index.d.ts +8 -6
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +8 -6
  90. package/dist/index.js.map +1 -1
  91. package/dist/model/draft.d.ts +9 -6
  92. package/dist/model/draft.d.ts.map +1 -1
  93. package/dist/model/draft.js +3 -1
  94. package/dist/model/draft.js.map +1 -1
  95. package/dist/model/features.d.ts +21 -7
  96. package/dist/model/features.d.ts.map +1 -1
  97. package/dist/model/features.js +26 -0
  98. package/dist/model/features.js.map +1 -1
  99. package/dist/model/mine.d.ts +44 -0
  100. package/dist/model/mine.d.ts.map +1 -0
  101. package/dist/model/mine.js +52 -0
  102. package/dist/model/mine.js.map +1 -0
  103. package/dist/model/mineSource.d.ts +39 -34
  104. package/dist/model/mineSource.d.ts.map +1 -1
  105. package/dist/model/mineSource.js +10 -41
  106. package/dist/model/mineSource.js.map +1 -1
  107. package/dist/model/mutations.d.ts.map +1 -1
  108. package/dist/model/mutations.js +11 -4
  109. package/dist/model/mutations.js.map +1 -1
  110. package/dist/model/queryKeys.d.ts +7 -2
  111. package/dist/model/queryKeys.d.ts.map +1 -1
  112. package/dist/model/queryKeys.js +2 -0
  113. package/dist/model/queryKeys.js.map +1 -1
  114. package/dist/model/status.d.ts +12 -31
  115. package/dist/model/status.d.ts.map +1 -1
  116. package/dist/model/status.js +44 -0
  117. package/dist/model/status.js.map +1 -1
  118. package/llms.txt +11 -8
  119. package/manifest.json +88 -14
  120. package/nav-manifest.json +1 -1
  121. package/package.json +17 -12
  122. package/src/analytics/generated/events.json +1 -1
  123. package/src/api/generated/schema.ts +129 -3
  124. package/src/api/listingsApi.ts +60 -21
  125. package/src/api/types.ts +46 -2
  126. package/src/default/FavoritesPane.tsx +156 -82
  127. package/src/default/ListingCard.tsx +88 -46
  128. package/src/default/ListingComposerPage.tsx +403 -187
  129. package/src/default/ListingDetailPane.tsx +200 -98
  130. package/src/default/ListingPhoto.tsx +89 -16
  131. package/src/default/ListingPrice.tsx +77 -0
  132. package/src/default/MyListingsPane.tsx +344 -222
  133. package/src/default/SignInLink.tsx +4 -4
  134. package/src/default/StatusTags.tsx +36 -18
  135. package/src/default/index.ts +18 -7
  136. package/src/headless/Favorites.tsx +10 -0
  137. package/src/headless/ListingActions.tsx +19 -0
  138. package/src/headless/ListingComposer.tsx +56 -5
  139. package/src/headless/MyListings.tsx +62 -22
  140. package/src/i18n/es.ts +36 -7
  141. package/src/i18n/generated/errors.es.gen.ts +1 -1
  142. package/src/i18n/generated/errors.gen.ts +3 -0
  143. package/src/i18n/generated/errors.json +9 -0
  144. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  145. package/src/i18n/keys.ts +58 -9
  146. package/src/i18n/ru.ts +48 -7
  147. package/src/index.ts +16 -5
  148. package/src/model/draft.ts +12 -7
  149. package/src/model/features.ts +48 -7
  150. package/src/model/mine.ts +70 -0
  151. package/src/model/mineSource.ts +46 -41
  152. package/src/model/mutations.ts +11 -4
  153. package/src/model/queryKeys.ts +9 -2
  154. package/src/model/status.ts +15 -0
  155. package/dist/default/ErrorAlert.d.ts +0 -9
  156. package/dist/default/ErrorAlert.d.ts.map +0 -1
  157. package/dist/default/ErrorAlert.js +0 -27
  158. package/dist/default/ErrorAlert.js.map +0 -1
  159. package/dist/default/theme.d.ts +0 -25
  160. package/dist/default/theme.d.ts.map +0 -1
  161. package/dist/default/theme.js +0 -40
  162. package/dist/default/theme.js.map +0 -1
  163. package/src/default/ErrorAlert.tsx +0 -46
  164. package/src/default/theme.tsx +0 -67
@@ -1,32 +1,54 @@
1
1
  /**
2
2
  * `<ListingComposerPage>` — the submission screen.
3
3
  *
4
- * Four contracts meet on this page and each arrives through its own seam, so
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
- * ── Why the category slot is a render prop and not a node ──────────────────
18
+ * ── An unfilled slot is NAMED, never improvised ────────────────────────────
16
19
  *
17
- * It was a node (`categorySlot`), and a node cannot be mounted: the composer's
18
- * category moves only through `bag.setCategory`, and a `ReactNode` handed in
19
- * from outside has no way to reach it. There was no `onCategoryChange`either,
20
- * so a container could neither set the category nor learn itand `features`,
21
- * the schema of the chosen category, was therefore unreachable rather than
22
- * merely withheld. The screen could not be mounted at all (storefront Wave D,
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 namedtwo 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
- * `renderCategoryPicker({ value, setCategory })` is the shape
26
- * `<CategoryPage renderListings>` already uses in the sibling pair, and the
27
- * controlled pair `category` / `onCategoryChange` is there for the container
28
- * that holds the id anyway it must, because the schema read
29
- * (`useCategoryFeatures(id)`) that fills `features` is keyed by it.
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 is rendered beside the button — never left
39
- * as a grey rectangle.
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 { useDescribeFlowError, useT } from "@stapel/core";
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
- * @deprecated A node cannot reach `setCategory`, so a picker rendered into
119
- * this slot could never tell the composer what was chosen. Use
120
- * `renderCategoryPicker` (or the controlled `category` /
121
- * `onCategoryChange` pair). Still rendered, so nothing that passed it breaks.
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 categorySlot?: ReactNode;
124
- /** The photo grid. Its bag is what `images` carries. */
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
- <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
182
- <Flex vertical gap={16} data-testid="listings-composer" data-stage={bag.stage}>
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.6.1 exposes no read of the `*_draft` twin, so a
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
- message={t(LISTINGS_I18N_KEYS.composeBlockedNoDraft)}
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
- message={t(
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
- <Form.Item
434
+ <SlotField
219
435
  label={t(LISTINGS_I18N_KEYS.composeCategory)}
220
436
  extra={t(LISTINGS_I18N_KEYS.composeCategoryHelp)}
221
- {...errorOf(CATEGORY_FIELD)}
222
- >
223
- {props.renderCategoryPicker !== undefined
224
- ? props.renderCategoryPicker({
225
- value: bag.values.categoryId,
226
- setCategory: bag.setCategory,
227
- })
228
- : (props.categorySlot ?? (
229
- <Input
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
- message={t(LISTINGS_I18N_KEYS.composeCategoryChangedDropped, {
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
- <Space align="start" wrap>
283
- <Form.Item
284
- label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
285
- {...errorOf(PRICE_FIELD)}
286
- >
287
- <Input
288
- inputMode="decimal"
289
- value={bag.values.price}
290
- aria-label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
291
- data-testid="listings-composer-price"
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
- value={bag.values.location.locationLabel}
315
- aria-label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
316
- data-testid="listings-composer-location"
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.setLocation({
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
- {/* Latitude and longitude are ONE value: half a coordinate points
328
- nowhere, and the mirror refuses it under `location`. `geohash`
329
- is not typed here at all — it comes from whatever resolved the
330
- place, because a geohash computed at a precision of our own
331
- choosing would bucket the pin somewhere the indexer does not
332
- expect (model/draft.ts). */}
333
- <Space align="start" wrap>
334
- <Form.Item
335
- label={t(LISTINGS_I18N_KEYS.composeLatLabel)}
336
- {...(bag.fieldErrors["location"] ? { validateStatus: "error" as const } : {})}
337
- >
338
- <Input
339
- inputMode="decimal"
340
- value={bag.values.location.lat ?? ""}
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
- <Divider />
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
- <Typography.Title level={5}>
375
- {t(LISTINGS_I18N_KEYS.composePhotos)}
376
- </Typography.Title>
377
- {props.gallerySlot}
378
- {bag.fieldErrors[IMAGES_FIELD] ? (
379
- <Typography.Text type="danger" data-testid="listings-composer-images-error">
380
- {describe(bag.fieldErrors[IMAGES_FIELD]).message}
381
- </Typography.Text>
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
- <Alert
391
- type="error"
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
- <Alert
457
- type="error"
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
- error={describe({
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
- <Space>
493
- <Button
494
- disabled={!bag.saveGate.available}
495
- loading={bag.saving}
496
- data-testid="listings-composer-save"
497
- data-analytics="none"
498
- data-analytics-reason="business action host app wraps with its own tracked()"
499
- onClick={bag.save}
500
- >
501
- {t(bag.saving ? LISTINGS_I18N_KEYS.composeSaving : LISTINGS_I18N_KEYS.composeSave)}
502
- </Button>
503
-
504
- <Button
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
- disabled={!bag.publishGate.available}
713
+ size="large"
507
714
  loading={bag.publishing}
508
- data-testid="listings-composer-publish"
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
- </Button>
721
+ </GatedButton>
515
722
 
516
- {bag.saved ? (
517
- <Typography.Text type="success" data-testid="listings-composer-saved">
518
- {t(LISTINGS_I18N_KEYS.composeSaved)}
519
- </Typography.Text>
520
- ) : null}
521
- </Space>
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
- {!bag.publishGate.available ? (
524
- <Typography.Text type="secondary" data-testid="listings-composer-publish-blocked">
525
- {t(bag.publishGate.block.code, bag.publishGate.block.params)}
526
- </Typography.Text>
527
- ) : null}
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
- </ListingsSkinTheme>
745
+ </SkinTheme>
530
746
  );
531
747
  }