@stapel/listings-react 0.1.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 (225) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +191 -0
  3. package/README.md +169 -0
  4. package/dist/api/generated/schema.d.ts +1253 -0
  5. package/dist/api/generated/schema.d.ts.map +1 -0
  6. package/dist/api/generated/schema.js +2 -0
  7. package/dist/api/generated/schema.js.map +1 -0
  8. package/dist/api/listingsApi.d.ts +145 -0
  9. package/dist/api/listingsApi.d.ts.map +1 -0
  10. package/dist/api/listingsApi.js +38 -0
  11. package/dist/api/listingsApi.js.map +1 -0
  12. package/dist/api/types.d.ts +156 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +37 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/ErrorAlert.d.ts +9 -0
  17. package/dist/default/ErrorAlert.d.ts.map +1 -0
  18. package/dist/default/ErrorAlert.js +27 -0
  19. package/dist/default/ErrorAlert.js.map +1 -0
  20. package/dist/default/FavoritesPane.d.ts +18 -0
  21. package/dist/default/FavoritesPane.d.ts.map +1 -0
  22. package/dist/default/FavoritesPane.js +28 -0
  23. package/dist/default/FavoritesPane.js.map +1 -0
  24. package/dist/default/ListingCard.d.ts +42 -0
  25. package/dist/default/ListingCard.d.ts.map +1 -0
  26. package/dist/default/ListingCard.js +37 -0
  27. package/dist/default/ListingCard.js.map +1 -0
  28. package/dist/default/ListingComposerPage.d.ts +46 -0
  29. package/dist/default/ListingComposerPage.d.ts.map +1 -0
  30. package/dist/default/ListingComposerPage.js +99 -0
  31. package/dist/default/ListingComposerPage.js.map +1 -0
  32. package/dist/default/ListingDetailPane.d.ts +33 -0
  33. package/dist/default/ListingDetailPane.d.ts.map +1 -0
  34. package/dist/default/ListingDetailPane.js +48 -0
  35. package/dist/default/ListingDetailPane.js.map +1 -0
  36. package/dist/default/ListingPhoto.d.ts +25 -0
  37. package/dist/default/ListingPhoto.d.ts.map +1 -0
  38. package/dist/default/ListingPhoto.js +20 -0
  39. package/dist/default/ListingPhoto.js.map +1 -0
  40. package/dist/default/MyListingsPane.d.ts +25 -0
  41. package/dist/default/MyListingsPane.d.ts.map +1 -0
  42. package/dist/default/MyListingsPane.js +84 -0
  43. package/dist/default/MyListingsPane.js.map +1 -0
  44. package/dist/default/StatusTags.d.ts +31 -0
  45. package/dist/default/StatusTags.d.ts.map +1 -0
  46. package/dist/default/StatusTags.js +40 -0
  47. package/dist/default/StatusTags.js.map +1 -0
  48. package/dist/default/icons.d.ts +16 -0
  49. package/dist/default/icons.d.ts.map +1 -0
  50. package/dist/default/icons.js +5 -0
  51. package/dist/default/icons.js.map +1 -0
  52. package/dist/default/index.d.ts +35 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +26 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/theme.d.ts +25 -0
  57. package/dist/default/theme.d.ts.map +1 -0
  58. package/dist/default/theme.js +40 -0
  59. package/dist/default/theme.js.map +1 -0
  60. package/dist/default/types.d.ts +12 -0
  61. package/dist/default/types.d.ts.map +1 -0
  62. package/dist/default/types.js +2 -0
  63. package/dist/default/types.js.map +1 -0
  64. package/dist/flows/registry.d.ts +35 -0
  65. package/dist/flows/registry.d.ts.map +1 -0
  66. package/dist/flows/registry.js +33 -0
  67. package/dist/flows/registry.js.map +1 -0
  68. package/dist/headless/Favorites.d.ts +52 -0
  69. package/dist/headless/Favorites.d.ts.map +1 -0
  70. package/dist/headless/Favorites.js +78 -0
  71. package/dist/headless/Favorites.js.map +1 -0
  72. package/dist/headless/ListingActions.d.ts +30 -0
  73. package/dist/headless/ListingActions.d.ts.map +1 -0
  74. package/dist/headless/ListingActions.js +54 -0
  75. package/dist/headless/ListingActions.js.map +1 -0
  76. package/dist/headless/ListingComposer.d.ts +133 -0
  77. package/dist/headless/ListingComposer.d.ts.map +1 -0
  78. package/dist/headless/ListingComposer.js +246 -0
  79. package/dist/headless/ListingComposer.js.map +1 -0
  80. package/dist/headless/ListingDetail.d.ts +81 -0
  81. package/dist/headless/ListingDetail.d.ts.map +1 -0
  82. package/dist/headless/ListingDetail.js +101 -0
  83. package/dist/headless/ListingDetail.js.map +1 -0
  84. package/dist/headless/ListingsProvider.d.ts +17 -0
  85. package/dist/headless/ListingsProvider.d.ts.map +1 -0
  86. package/dist/headless/ListingsProvider.js +16 -0
  87. package/dist/headless/ListingsProvider.js.map +1 -0
  88. package/dist/headless/MyListings.d.ts +44 -0
  89. package/dist/headless/MyListings.d.ts.map +1 -0
  90. package/dist/headless/MyListings.js +82 -0
  91. package/dist/headless/MyListings.js.map +1 -0
  92. package/dist/headless/useMandateGate.d.ts +28 -0
  93. package/dist/headless/useMandateGate.d.ts.map +1 -0
  94. package/dist/headless/useMandateGate.js +39 -0
  95. package/dist/headless/useMandateGate.js.map +1 -0
  96. package/dist/i18n/errorsMap.d.ts +34 -0
  97. package/dist/i18n/errorsMap.d.ts.map +1 -0
  98. package/dist/i18n/errorsMap.js +45 -0
  99. package/dist/i18n/errorsMap.js.map +1 -0
  100. package/dist/i18n/es.d.ts +21 -0
  101. package/dist/i18n/es.d.ts.map +1 -0
  102. package/dist/i18n/es.js +146 -0
  103. package/dist/i18n/es.js.map +1 -0
  104. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  105. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  106. package/dist/i18n/generated/errors.es.gen.js +58 -0
  107. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  108. package/dist/i18n/generated/errors.gen.d.ts +413 -0
  109. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  110. package/dist/i18n/generated/errors.gen.js +210 -0
  111. package/dist/i18n/generated/errors.gen.js.map +1 -0
  112. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  113. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  114. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  115. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  116. package/dist/i18n/keys.d.ts +170 -0
  117. package/dist/i18n/keys.d.ts.map +1 -0
  118. package/dist/i18n/keys.js +295 -0
  119. package/dist/i18n/keys.js.map +1 -0
  120. package/dist/i18n/ru.d.ts +37 -0
  121. package/dist/i18n/ru.d.ts.map +1 -0
  122. package/dist/i18n/ru.js +162 -0
  123. package/dist/i18n/ru.js.map +1 -0
  124. package/dist/index.d.ts +103 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +98 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/model/context.d.ts +11 -0
  129. package/dist/model/context.d.ts.map +1 -0
  130. package/dist/model/context.js +16 -0
  131. package/dist/model/context.js.map +1 -0
  132. package/dist/model/draft.d.ts +138 -0
  133. package/dist/model/draft.d.ts.map +1 -0
  134. package/dist/model/draft.js +192 -0
  135. package/dist/model/draft.js.map +1 -0
  136. package/dist/model/features.d.ts +69 -0
  137. package/dist/model/features.d.ts.map +1 -0
  138. package/dist/model/features.js +73 -0
  139. package/dist/model/features.js.map +1 -0
  140. package/dist/model/mineSource.d.ts +47 -0
  141. package/dist/model/mineSource.d.ts.map +1 -0
  142. package/dist/model/mineSource.js +45 -0
  143. package/dist/model/mineSource.js.map +1 -0
  144. package/dist/model/mutations.d.ts +60 -0
  145. package/dist/model/mutations.d.ts.map +1 -0
  146. package/dist/model/mutations.js +165 -0
  147. package/dist/model/mutations.js.map +1 -0
  148. package/dist/model/queries.d.ts +75 -0
  149. package/dist/model/queries.d.ts.map +1 -0
  150. package/dist/model/queries.js +117 -0
  151. package/dist/model/queries.js.map +1 -0
  152. package/dist/model/queryKeys.d.ts +50 -0
  153. package/dist/model/queryKeys.d.ts.map +1 -0
  154. package/dist/model/queryKeys.js +24 -0
  155. package/dist/model/queryKeys.js.map +1 -0
  156. package/dist/model/runtime.d.ts +69 -0
  157. package/dist/model/runtime.d.ts.map +1 -0
  158. package/dist/model/runtime.js +14 -0
  159. package/dist/model/runtime.js.map +1 -0
  160. package/dist/model/status.d.ts +117 -0
  161. package/dist/model/status.d.ts.map +1 -0
  162. package/dist/model/status.js +164 -0
  163. package/dist/model/status.js.map +1 -0
  164. package/dist/model/transitions.d.ts +41 -0
  165. package/dist/model/transitions.d.ts.map +1 -0
  166. package/dist/model/transitions.js +25 -0
  167. package/dist/model/transitions.js.map +1 -0
  168. package/dist/model/validation.d.ts +135 -0
  169. package/dist/model/validation.d.ts.map +1 -0
  170. package/dist/model/validation.js +191 -0
  171. package/dist/model/validation.js.map +1 -0
  172. package/dist/nav/manifest.d.ts +46 -0
  173. package/dist/nav/manifest.d.ts.map +1 -0
  174. package/dist/nav/manifest.js +56 -0
  175. package/dist/nav/manifest.js.map +1 -0
  176. package/llms.txt +118 -0
  177. package/manifest.json +1044 -0
  178. package/nav-manifest.json +84 -0
  179. package/package.json +121 -0
  180. package/src/analytics/generated/events.json +7 -0
  181. package/src/api/generated/schema.ts +1255 -0
  182. package/src/api/listingsApi.ts +237 -0
  183. package/src/api/types.ts +193 -0
  184. package/src/default/ErrorAlert.tsx +46 -0
  185. package/src/default/FavoritesPane.tsx +123 -0
  186. package/src/default/ListingCard.tsx +178 -0
  187. package/src/default/ListingComposerPage.tsx +466 -0
  188. package/src/default/ListingDetailPane.tsx +271 -0
  189. package/src/default/ListingPhoto.tsx +62 -0
  190. package/src/default/MyListingsPane.tsx +333 -0
  191. package/src/default/StatusTags.tsx +86 -0
  192. package/src/default/icons.tsx +31 -0
  193. package/src/default/index.ts +35 -0
  194. package/src/default/theme.tsx +67 -0
  195. package/src/default/types.ts +12 -0
  196. package/src/flows/registry.ts +45 -0
  197. package/src/headless/Favorites.tsx +137 -0
  198. package/src/headless/ListingActions.tsx +101 -0
  199. package/src/headless/ListingComposer.tsx +476 -0
  200. package/src/headless/ListingDetail.tsx +231 -0
  201. package/src/headless/ListingsProvider.tsx +20 -0
  202. package/src/headless/MyListings.tsx +148 -0
  203. package/src/headless/useMandateGate.ts +40 -0
  204. package/src/i18n/errorsMap.ts +62 -0
  205. package/src/i18n/es.ts +198 -0
  206. package/src/i18n/generated/errors.es.gen.ts +64 -0
  207. package/src/i18n/generated/errors.gen.ts +241 -0
  208. package/src/i18n/generated/errors.json +499 -0
  209. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  210. package/src/i18n/keys.ts +356 -0
  211. package/src/i18n/ru.ts +215 -0
  212. package/src/index.ts +260 -0
  213. package/src/model/context.tsx +28 -0
  214. package/src/model/draft.ts +307 -0
  215. package/src/model/features.ts +122 -0
  216. package/src/model/mineSource.ts +55 -0
  217. package/src/model/mutations.ts +224 -0
  218. package/src/model/queries.ts +150 -0
  219. package/src/model/queryKeys.ts +76 -0
  220. package/src/model/runtime.ts +87 -0
  221. package/src/model/status.ts +279 -0
  222. package/src/model/transitions.ts +62 -0
  223. package/src/model/validation.ts +271 -0
  224. package/src/nav/manifest.ts +97 -0
  225. package/tsconfig.json +26 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `<ListingCard>` — the one component of this pair that another pair renders.
3
+ *
4
+ * `@stapel/search-react` takes a `renderCard` slot and the container fills it
5
+ * with this (spec §3.7 / §6.2 item 1). The two pairs never import each other;
6
+ * the CONTAINER is the seam, which is why this component takes a plain card
7
+ * row and a plain `href`/`onOpen` rather than reaching for a router.
8
+ *
9
+ * ── What it renders without asking the server anything else ────────────────
10
+ *
11
+ * Badges. `features_badges` is a stored DAO projection, and a DAO carries the
12
+ * display config beside the value, so `formatFeatureValue` can render
13
+ * "1200 W" from the row alone — no category fetch, no second request per
14
+ * card. That property is the whole reason the projection exists, and it is
15
+ * what makes a grid of forty cards cost one query.
16
+ *
17
+ * ── The heart is never hidden ──────────────────────────────────────────────
18
+ *
19
+ * A visitor sees it, blocked, with the reason and the sign-in CTA the
20
+ * container attaches (`?next=`). Hiding it would teach nobody that
21
+ * favourites exist (private-space canon §6.3, spec §6.2 item 6).
22
+ */
23
+ import type { ReactElement, ReactNode } from "react";
24
+ import { Button, Card, Flex, Tooltip, Typography } from "antd";
25
+ import { useT } from "@stapel/core";
26
+ import { FeatureBadges } from "@stapel/attributes-react/default";
27
+ import type { ListingCard as ListingCardData } from "../api/types.js";
28
+ import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
29
+ import { lifecycleCaption } from "../model/status.js";
30
+ import { useFavoriteToggle } from "../headless/Favorites.js";
31
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
32
+ import { HeartIcon } from "./icons.js";
33
+ import { ListingPhoto } from "./ListingPhoto.js";
34
+ import { ListingsSkinTheme } from "./theme.js";
35
+ import type { ThemeModeProp } from "./types.js";
36
+
37
+ export interface ListingCardProps extends ThemeModeProp {
38
+ readonly listing: ListingCardData;
39
+ /** Where the card leads. The container owns routing, so this is a plain
40
+ * href a `<Link>` or an `<a>` can carry — the pair never calls
41
+ * `window.location`. */
42
+ readonly href?: string;
43
+ /** Called instead of following `href`, for a host with its own navigation. */
44
+ readonly onOpen?: (id: number) => void;
45
+ /** Extra chrome the container adds (a `promoted` tag from search, say —
46
+ * DSA Art. 26 marking belongs to the pair that receives it). */
47
+ readonly badge?: ReactNode;
48
+ /** Hide the favourite control entirely — for a context where it makes no
49
+ * sense (the owner's own dashboard). NOT a way to hide it from visitors. */
50
+ readonly showFavorite?: boolean;
51
+ }
52
+
53
+ export function ListingCard(props: ListingCardProps): ReactElement {
54
+ const t = useT();
55
+ const { listing } = props;
56
+ const favorite = useFavoriteToggle(listing.id, listing.is_favorited);
57
+
58
+ const badgeDaos = asFeatureDaoList(listing.features_badges);
59
+ const badgeFeatures = featuresFromDaoList(badgeDaos);
60
+ const badgeValues = featuresDtoFromDaoList(badgeDaos);
61
+ const titleDaos = asFeatureDaoList(listing.features_title);
62
+
63
+ const status = listing.status === undefined ? undefined : lifecycleCaption(listing.status);
64
+ const price =
65
+ listing.price !== undefined && listing.price.length > 0
66
+ ? `${listing.price} ${listing.currency ?? ""}`.trim()
67
+ : t(LISTINGS_I18N_KEYS.cardPriceAbsent);
68
+
69
+ const favoriteLabel = t(
70
+ favorite.favorited
71
+ ? LISTINGS_I18N_KEYS.cardFavoriteRemove
72
+ : LISTINGS_I18N_KEYS.cardFavoriteAdd
73
+ );
74
+
75
+ return (
76
+ <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
77
+ <Card
78
+ size="small"
79
+ data-testid="listings-card"
80
+ data-listing-id={listing.id}
81
+ {...(status !== undefined
82
+ ? { "data-listing-status": status.status }
83
+ : {})}
84
+ cover={
85
+ <ListingPhoto
86
+ imageRef={listing.images?.[0]}
87
+ alt={listing.title ?? String(listing.id)}
88
+ style={{ aspectRatio: "4 / 3" }}
89
+ />
90
+ }
91
+ >
92
+ <Flex vertical gap={4}>
93
+ {props.badge}
94
+
95
+ <Typography.Text strong data-testid="listings-card-price">
96
+ {price}
97
+ </Typography.Text>
98
+
99
+ <Typography.Text ellipsis data-testid="listings-card-title">
100
+ {listing.title ?? ""}
101
+ </Typography.Text>
102
+
103
+ {/* The title features are a stored projection too — the seller's
104
+ "1.5 TB, black" line, already ordered by the server. */}
105
+ {titleDaos.length > 0 ? (
106
+ <Typography.Text type="secondary" ellipsis>
107
+ <FeatureBadges
108
+ features={featuresFromDaoList(titleDaos)
109
+ .map((view) => view.feature)}
110
+ values={featuresDtoFromDaoList(titleDaos)}
111
+ />
112
+ </Typography.Text>
113
+ ) : null}
114
+
115
+ {badgeFeatures.length > 0 ? (
116
+ <FeatureBadges
117
+ features={badgeFeatures.map((view) => view.feature)}
118
+ values={badgeValues}
119
+ />
120
+ ) : null}
121
+
122
+ {listing.location_label !== undefined &&
123
+ listing.location_label.length > 0 ? (
124
+ <Typography.Text type="secondary" data-testid="listings-card-location">
125
+ {listing.location_label}
126
+ </Typography.Text>
127
+ ) : null}
128
+
129
+ <Flex gap={8} align="center">
130
+ {props.href !== undefined || props.onOpen !== undefined ? (
131
+ <Button
132
+ size="small"
133
+ type="link"
134
+ {...(props.href !== undefined ? { href: props.href } : {})}
135
+ data-testid="listings-card-open"
136
+ data-analytics="none"
137
+ data-analytics-reason="business action — host app wraps with its own tracked()"
138
+ onClick={() => {
139
+ props.onOpen?.(listing.id);
140
+ }}
141
+ >
142
+ {t(LISTINGS_I18N_KEYS.cardOpen)}
143
+ </Button>
144
+ ) : null}
145
+
146
+ {props.showFavorite === false ? null : (
147
+ <Tooltip
148
+ title={
149
+ favorite.gate.available
150
+ ? favoriteLabel
151
+ : t(favorite.gate.block.code, favorite.gate.block.params)
152
+ }
153
+ >
154
+ {/* A disabled antd Button swallows pointer events, so the
155
+ tooltip needs the wrapper to hear them — which is also
156
+ what makes the REASON reachable by keyboard. */}
157
+ <span data-testid="listings-card-favorite-wrap">
158
+ <Button
159
+ size="small"
160
+ disabled={!favorite.gate.available}
161
+ aria-label={favoriteLabel}
162
+ aria-pressed={favorite.favorited}
163
+ data-testid="listings-card-favorite"
164
+ data-favorited={String(favorite.favorited)}
165
+ data-analytics="none"
166
+ data-analytics-reason="business action — host app wraps with its own tracked()"
167
+ onClick={favorite.toggle}
168
+ icon={<HeartIcon filled={favorite.favorited} />}
169
+ />
170
+ </span>
171
+ </Tooltip>
172
+ )}
173
+ </Flex>
174
+ </Flex>
175
+ </Card>
176
+ </ListingsSkinTheme>
177
+ );
178
+ }
@@ -0,0 +1,466 @@
1
+ /**
2
+ * `<ListingComposerPage>` — the submission screen.
3
+ *
4
+ * Four contracts meet on this page and each arrives through its own seam, so
5
+ * the component can be read top to bottom without knowing any of the other
6
+ * pairs:
7
+ *
8
+ * category `categorySlot` — the container's `<CategoryPickerField>`
9
+ * details `features` — the schema, drawn by `<FeatureFields>` (L0)
10
+ * photos `gallerySlot` — the container's `<MediaGalleryField>`, whose
11
+ * bag is handed to the composer as `images`
12
+ * the draft this pair
13
+ *
14
+ * ── Every blocked control says which of six reasons it is ──────────────────
15
+ *
16
+ * The publish button is the most-gated control in the fleet, and that is the
17
+ * point: "sign in", "choose a category", "we could not load what this
18
+ * category asks for", "this build cannot draw one of these details", "wait
19
+ * for the photos", "fix the highlighted fields" are six different problems
20
+ * with six different next actions. `firstBlock` orders them the way a person
21
+ * would be told, and the reason is rendered beside the button — never left
22
+ * as a grey rectangle.
23
+ */
24
+ import type { ReactElement, ReactNode } from "react";
25
+ import {
26
+ Alert,
27
+ Button,
28
+ Checkbox,
29
+ Divider,
30
+ Flex,
31
+ Form,
32
+ Input,
33
+ InputNumber,
34
+ Space,
35
+ Typography,
36
+ } from "antd";
37
+ import { useDescribeFlowError, useT } from "@stapel/core";
38
+ import type { FeatureDef } from "@stapel/attributes-react";
39
+ import {
40
+ BUILTIN_VALUE_EDITOR_TYPES,
41
+ FeatureFields,
42
+ } from "@stapel/attributes-react/default";
43
+ import { useListingComposer } from "../headless/ListingComposer.js";
44
+ import type { ListingImagesBag } from "../headless/ListingComposer.js";
45
+ import {
46
+ CATEGORY_FIELD,
47
+ DESCRIPTION_FIELD,
48
+ IMAGES_FIELD,
49
+ PRICE_FIELD,
50
+ TITLE_FIELD,
51
+ failedResults,
52
+ } from "../model/validation.js";
53
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
54
+ import { ErrorAlert } from "./ErrorAlert.js";
55
+ import { ListingsSkinTheme } from "./theme.js";
56
+ import type { ThemeModeProp } from "./types.js";
57
+
58
+ export interface ListingComposerPageProps extends ThemeModeProp {
59
+ /** Editing an existing listing; absent for a new one. */
60
+ readonly listingId?: number;
61
+ /** The chosen category's features — from `@stapel/categories-react` via the
62
+ * container (L2 pairs do not import each other). */
63
+ readonly features: readonly FeatureDef[];
64
+ readonly featuresLoading?: boolean;
65
+ readonly featuresError?: unknown;
66
+ /** The category chooser. A slot for the same reason. */
67
+ readonly categorySlot?: ReactNode;
68
+ /** The photo grid. Its bag is what `images` carries. */
69
+ readonly gallerySlot?: ReactNode;
70
+ /** `useUploadQueue()`'s bag from `@stapel/cdn-react` — `refs` becomes
71
+ * `images_draft`, `settled` gates the submit. */
72
+ readonly images?: ListingImagesBag;
73
+ readonly onPublished?: (listingId: number) => void;
74
+ }
75
+
76
+ export function ListingComposerPage(
77
+ props: ListingComposerPageProps
78
+ ): ReactElement {
79
+ const t = useT();
80
+ const describe = useDescribeFlowError();
81
+ const bag = useListingComposer({
82
+ ...(props.listingId !== undefined ? { listingId: props.listingId } : {}),
83
+ features: props.features,
84
+ ...(props.featuresLoading !== undefined
85
+ ? { featuresLoading: props.featuresLoading }
86
+ : {}),
87
+ ...(props.featuresError !== undefined
88
+ ? { featuresError: props.featuresError }
89
+ : {}),
90
+ editorTypes: BUILTIN_VALUE_EDITOR_TYPES,
91
+ ...(props.images !== undefined ? { images: props.images } : {}),
92
+ ...(props.onPublished !== undefined
93
+ ? {
94
+ // `listing_id` comes back in the response, so the callback never
95
+ // has to reach for a piece of state that may be one render behind.
96
+ onPublished: (response) => {
97
+ props.onPublished?.(response.listing_id);
98
+ },
99
+ }
100
+ : {}),
101
+ });
102
+
103
+ /** Spread onto a `Form.Item`: the two props exist only when there IS a
104
+ * refusal, which is what `exactOptionalPropertyTypes` asks for and what
105
+ * keeps a clean field from rendering an empty help line. */
106
+ const errorOf = (
107
+ field: string
108
+ ): { help: ReactNode; validateStatus: "error" } | Record<string, never> => {
109
+ const error = bag.fieldErrors[field];
110
+ if (error === undefined) return {};
111
+ return { help: describe(error).message, validateStatus: "error" };
112
+ };
113
+
114
+ const publishLabel = t(
115
+ bag.isLiveEdit
116
+ ? LISTINGS_I18N_KEYS.composeRepublish
117
+ : LISTINGS_I18N_KEYS.composePublish
118
+ );
119
+
120
+ return (
121
+ <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
122
+ <Flex vertical gap={16} data-testid="listings-composer" data-stage={bag.stage}>
123
+ <Typography.Title level={3}>
124
+ {t(
125
+ props.listingId === undefined
126
+ ? LISTINGS_I18N_KEYS.composeNewTitle
127
+ : LISTINGS_I18N_KEYS.composeEditTitle
128
+ )}
129
+ </Typography.Title>
130
+
131
+ {/* stapel-listings 0.6.1 exposes no read of the `*_draft` twin, so a
132
+ draft reopened in a new session comes back empty. Saying so beats
133
+ a blank form that looks like lost work. */}
134
+ {bag.draftNotReadable ? (
135
+ <Alert
136
+ type="warning"
137
+ showIcon
138
+ data-testid="listings-composer-draft-unreadable"
139
+ message={t(LISTINGS_I18N_KEYS.composeBlockedNoDraft)}
140
+ />
141
+ ) : null}
142
+
143
+ {bag.outcome !== undefined ? (
144
+ <Alert
145
+ type="success"
146
+ showIcon
147
+ data-testid="listings-composer-published"
148
+ data-outcome={bag.outcome}
149
+ message={t(
150
+ bag.outcome === "live_edit_under_review"
151
+ ? LISTINGS_I18N_KEYS.composePublishedLive
152
+ : LISTINGS_I18N_KEYS.composePublishedFirst
153
+ )}
154
+ />
155
+ ) : null}
156
+
157
+ <Form layout="vertical" data-testid="listings-composer-form">
158
+ <Form.Item
159
+ label={t(LISTINGS_I18N_KEYS.composeCategory)}
160
+ extra={t(LISTINGS_I18N_KEYS.composeCategoryHelp)}
161
+ {...errorOf(CATEGORY_FIELD)}
162
+ >
163
+ {props.categorySlot ?? (
164
+ <Input
165
+ value={bag.values.categoryId}
166
+ aria-label={t(LISTINGS_I18N_KEYS.composeCategory)}
167
+ data-testid="listings-composer-category"
168
+ onChange={(event) => {
169
+ bag.setCategory(event.target.value);
170
+ }}
171
+ />
172
+ )}
173
+ </Form.Item>
174
+
175
+ {bag.droppedOnCategoryChange.length > 0 ? (
176
+ <Alert
177
+ type="info"
178
+ showIcon
179
+ data-testid="listings-composer-dropped"
180
+ message={t(LISTINGS_I18N_KEYS.composeCategoryChangedDropped, {
181
+ count: bag.droppedOnCategoryChange.length,
182
+ })}
183
+ />
184
+ ) : null}
185
+
186
+ <Form.Item
187
+ label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
188
+ {...errorOf(TITLE_FIELD)}
189
+ >
190
+ <Input
191
+ value={bag.values.title}
192
+ aria-label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
193
+ data-testid="listings-composer-title"
194
+ onChange={(event) => {
195
+ bag.setValue("title", event.target.value);
196
+ }}
197
+ onBlur={bag.save}
198
+ />
199
+ </Form.Item>
200
+
201
+ <Form.Item
202
+ label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
203
+ {...errorOf(DESCRIPTION_FIELD)}
204
+ >
205
+ <Input.TextArea
206
+ rows={5}
207
+ value={bag.values.description}
208
+ aria-label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
209
+ data-testid="listings-composer-description"
210
+ onChange={(event) => {
211
+ bag.setValue("description", event.target.value);
212
+ }}
213
+ onBlur={bag.save}
214
+ />
215
+ </Form.Item>
216
+
217
+ <Space align="start" wrap>
218
+ <Form.Item
219
+ label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
220
+ {...errorOf(PRICE_FIELD)}
221
+ >
222
+ <Input
223
+ inputMode="decimal"
224
+ value={bag.values.price}
225
+ aria-label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
226
+ data-testid="listings-composer-price"
227
+ onChange={(event) => {
228
+ bag.setValue("price", event.target.value);
229
+ }}
230
+ onBlur={bag.save}
231
+ />
232
+ </Form.Item>
233
+
234
+ <Form.Item label={t(LISTINGS_I18N_KEYS.composeCurrencyLabel)}>
235
+ <Input
236
+ value={bag.values.currency}
237
+ aria-label={t(LISTINGS_I18N_KEYS.composeCurrencyLabel)}
238
+ data-testid="listings-composer-currency"
239
+ onChange={(event) => {
240
+ bag.setValue("currency", event.target.value);
241
+ }}
242
+ onBlur={bag.save}
243
+ />
244
+ </Form.Item>
245
+ </Space>
246
+
247
+ <Form.Item label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}>
248
+ <Input
249
+ value={bag.values.location.locationLabel}
250
+ aria-label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
251
+ data-testid="listings-composer-location"
252
+ onChange={(event) => {
253
+ bag.setLocation({
254
+ ...bag.values.location,
255
+ locationLabel: event.target.value,
256
+ });
257
+ }}
258
+ onBlur={bag.save}
259
+ />
260
+ </Form.Item>
261
+
262
+ {/* Latitude and longitude are ONE value: half a coordinate points
263
+ nowhere, and the mirror refuses it under `location`. `geohash`
264
+ is not typed here at all — it comes from whatever resolved the
265
+ place, because a geohash computed at a precision of our own
266
+ choosing would bucket the pin somewhere the indexer does not
267
+ expect (model/draft.ts). */}
268
+ <Space align="start" wrap>
269
+ <Form.Item
270
+ label={t(LISTINGS_I18N_KEYS.composeLatLabel)}
271
+ {...(bag.fieldErrors["location"] ? { validateStatus: "error" as const } : {})}
272
+ >
273
+ <Input
274
+ inputMode="decimal"
275
+ value={bag.values.location.lat ?? ""}
276
+ aria-label={t(LISTINGS_I18N_KEYS.composeLatLabel)}
277
+ data-testid="listings-composer-lat"
278
+ onChange={(event) => {
279
+ bag.setLocation({
280
+ ...bag.values.location,
281
+ lat: event.target.value.length > 0 ? event.target.value : null,
282
+ });
283
+ }}
284
+ onBlur={bag.save}
285
+ />
286
+ </Form.Item>
287
+ <Form.Item
288
+ label={t(LISTINGS_I18N_KEYS.composeLonLabel)}
289
+ {...errorOf("location")}
290
+ >
291
+ <Input
292
+ inputMode="decimal"
293
+ value={bag.values.location.lon ?? ""}
294
+ aria-label={t(LISTINGS_I18N_KEYS.composeLonLabel)}
295
+ data-testid="listings-composer-lon"
296
+ onChange={(event) => {
297
+ bag.setLocation({
298
+ ...bag.values.location,
299
+ lon: event.target.value.length > 0 ? event.target.value : null,
300
+ });
301
+ }}
302
+ onBlur={bag.save}
303
+ />
304
+ </Form.Item>
305
+ </Space>
306
+
307
+ <Divider />
308
+
309
+ <Typography.Title level={5}>
310
+ {t(LISTINGS_I18N_KEYS.composePhotos)}
311
+ </Typography.Title>
312
+ {props.gallerySlot}
313
+ {bag.fieldErrors[IMAGES_FIELD] ? (
314
+ <Typography.Text type="danger" data-testid="listings-composer-images-error">
315
+ {describe(bag.fieldErrors[IMAGES_FIELD]).message}
316
+ </Typography.Text>
317
+ ) : null}
318
+
319
+ <Divider />
320
+
321
+ <Typography.Title level={5}>
322
+ {t(LISTINGS_I18N_KEYS.composeDetails)}
323
+ </Typography.Title>
324
+ {props.featuresError !== undefined ? (
325
+ <Alert
326
+ type="error"
327
+ showIcon
328
+ data-testid="listings-composer-features-failed"
329
+ message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
330
+ />
331
+ ) : props.featuresLoading === true ? (
332
+ <Typography.Text type="secondary" data-testid="listings-composer-features-loading">
333
+ {t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
334
+ </Typography.Text>
335
+ ) : props.features.length === 0 ? (
336
+ <Typography.Text type="secondary" data-testid="listings-composer-features-empty">
337
+ {t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
338
+ </Typography.Text>
339
+ ) : (
340
+ <FeatureFields
341
+ features={props.features}
342
+ values={bag.values.features}
343
+ errors={bag.fieldErrors}
344
+ disabled={bag.publishing}
345
+ onChange={bag.setFeature}
346
+ />
347
+ )}
348
+
349
+ <Divider />
350
+
351
+ <Form.Item>
352
+ <Checkbox
353
+ checked={bag.values.countable}
354
+ data-testid="listings-composer-countable"
355
+ onChange={(event) => {
356
+ bag.setValue("countable", event.target.checked);
357
+ }}
358
+ >
359
+ {t(LISTINGS_I18N_KEYS.composeCountable)}
360
+ </Checkbox>
361
+ </Form.Item>
362
+
363
+ {bag.values.countable ? (
364
+ <Form.Item label={t(LISTINGS_I18N_KEYS.composeStock)}>
365
+ <InputNumber
366
+ min={0}
367
+ value={bag.values.stockQuantity}
368
+ aria-label={t(LISTINGS_I18N_KEYS.composeStock)}
369
+ data-testid="listings-composer-stock"
370
+ onChange={(value) => {
371
+ bag.setValue("stockQuantity", value ?? null);
372
+ }}
373
+ />
374
+ </Form.Item>
375
+ ) : null}
376
+
377
+ <Form.Item>
378
+ <Checkbox
379
+ checked={bag.values.autoRepublish}
380
+ data-testid="listings-composer-auto-republish"
381
+ onChange={(event) => {
382
+ bag.setValue("autoRepublish", event.target.checked);
383
+ }}
384
+ >
385
+ {t(LISTINGS_I18N_KEYS.composeAutoRepublish)}
386
+ </Checkbox>
387
+ </Form.Item>
388
+ </Form>
389
+
390
+ {bag.refusal?.kind === "invalid_draft" ? (
391
+ <Alert
392
+ type="error"
393
+ showIcon
394
+ data-testid="listings-composer-invalid"
395
+ message={t(LISTINGS_I18N_KEYS.composeInvalidSummary, {
396
+ count: failedResults(bag.refusal.batch).length,
397
+ })}
398
+ />
399
+ ) : null}
400
+
401
+ {bag.refusal?.kind === "error" ? (
402
+ <ErrorAlert
403
+ testId="listings-composer-error"
404
+ error={describe({
405
+ code: bag.refusal.error.code,
406
+ params: bag.refusal.error.params,
407
+ status: bag.refusal.error.status,
408
+ message: bag.refusal.error.message,
409
+ language: bag.refusal.error.language,
410
+ })}
411
+ />
412
+ ) : null}
413
+
414
+ {bag.saveError !== undefined && bag.saveError !== null ? (
415
+ <ErrorAlert
416
+ testId="listings-composer-save-error"
417
+ error={describe({
418
+ code: LISTINGS_I18N_KEYS.unknownError,
419
+ params: {},
420
+ status: 0,
421
+ message: undefined,
422
+ language: undefined,
423
+ })}
424
+ />
425
+ ) : null}
426
+
427
+ <Space>
428
+ <Button
429
+ disabled={!bag.saveGate.available}
430
+ loading={bag.saving}
431
+ data-testid="listings-composer-save"
432
+ data-analytics="none"
433
+ data-analytics-reason="business action — host app wraps with its own tracked()"
434
+ onClick={bag.save}
435
+ >
436
+ {t(bag.saving ? LISTINGS_I18N_KEYS.composeSaving : LISTINGS_I18N_KEYS.composeSave)}
437
+ </Button>
438
+
439
+ <Button
440
+ type="primary"
441
+ disabled={!bag.publishGate.available}
442
+ loading={bag.publishing}
443
+ data-testid="listings-composer-publish"
444
+ data-analytics="none"
445
+ data-analytics-reason="business action — host app wraps with its own tracked()"
446
+ onClick={bag.publish}
447
+ >
448
+ {bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
449
+ </Button>
450
+
451
+ {bag.saved ? (
452
+ <Typography.Text type="success" data-testid="listings-composer-saved">
453
+ {t(LISTINGS_I18N_KEYS.composeSaved)}
454
+ </Typography.Text>
455
+ ) : null}
456
+ </Space>
457
+
458
+ {!bag.publishGate.available ? (
459
+ <Typography.Text type="secondary" data-testid="listings-composer-publish-blocked">
460
+ {t(bag.publishGate.block.code, bag.publishGate.block.params)}
461
+ </Typography.Text>
462
+ ) : null}
463
+ </Flex>
464
+ </ListingsSkinTheme>
465
+ );
466
+ }