@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,476 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import {
4
+ actionAvailable,
5
+ actionBlocked,
6
+ firstBlock,
7
+ loadFailed,
8
+ loadLoading,
9
+ loadReady,
10
+ } from "@stapel/core";
11
+ import type { ActionAvailability, FlowError, LoadState } from "@stapel/core";
12
+ import type { FeatureDef, FeaturesDto } from "@stapel/attributes-react";
13
+ import { toFeaturesDto, unsupportedTypes } from "@stapel/attributes-react";
14
+ import type {
15
+ ListingDetail as ListingDetailData,
16
+ ListingDraft,
17
+ PublishResponse,
18
+ } from "../api/types.js";
19
+ import { useListingsRuntime } from "../model/context.js";
20
+ import { useListing } from "../model/queries.js";
21
+ import {
22
+ useCreateDraft,
23
+ usePublishListing,
24
+ useSaveDraft,
25
+ } from "../model/mutations.js";
26
+ import {
27
+ createDraftBody,
28
+ draftPatchFromValues,
29
+ draftValuesFromDetail,
30
+ droppedFeatureSlugs,
31
+ emptyDraftValues,
32
+ retainKnownFeatureValues,
33
+ } from "../model/draft.js";
34
+ import type { ListingDraftValues, ListingLocation } from "../model/draft.js";
35
+ import { asFeatureDaoList } from "../model/features.js";
36
+ import { featuresDtoFromDaoList } from "../model/features.js";
37
+ import { mirrorDraft, publishRefusal } from "../model/validation.js";
38
+ import type { PublishRefusal } from "../model/validation.js";
39
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
40
+ import { useMandateGate } from "./useMandateGate.js";
41
+
42
+ /**
43
+ * The submission flow — the pair's long pole, and the place four contracts
44
+ * meet: the draft twin (listings), the value editors (attributes), the upload
45
+ * queue (cdn) and the category schema (categories).
46
+ *
47
+ * ── The steps, and why they are not a `@flow_step` funnel ──────────────────
48
+ *
49
+ * pick a category → create the draft row → save into it → publish
50
+ *
51
+ * The server does not sequence these (see `flows/registry.ts`). What DOES
52
+ * sequence them is here, and its stages are named so analytics can follow the
53
+ * funnel without the backend having declared one.
54
+ *
55
+ * ── The three seams, and why none of them is an import ─────────────────────
56
+ *
57
+ * `@stapel/listings-react`, `@stapel/cdn-react` and `@stapel/categories-react`
58
+ * are all L2 pairs, and L2 pairs never import each other (the monorepo README
59
+ * states the direction). So:
60
+ *
61
+ * - **the category schema** arrives as `features` — a plain
62
+ * `readonly FeatureDef[]`. The container gets it from
63
+ * `@stapel/categories-react`'s `useCategoryFeatures`;
64
+ * - **the gallery** arrives as {@link ListingImagesBag} — two members,
65
+ * satisfied STRUCTURALLY by `useUploadQueue()`'s bag from
66
+ * `@stapel/cdn-react`. `bag.refs` IS the value of `images_draft` (same
67
+ * order, first tile is the cover) and `bag.settled` is the submit gate
68
+ * that stops a publish while photos are in flight. That pair wrote its bag
69
+ * to this contract on purpose (its §13.6 note 9);
70
+ * - **the value editors** come from `@stapel/attributes-react`, which is L0
71
+ * and therefore a legitimate dependency. Only `editorTypes` crosses back:
72
+ * the headless half must judge renderability without importing a skin, so
73
+ * the skin's `BUILTIN_VALUE_EDITOR_TYPES` is passed IN.
74
+ *
75
+ * ── Reopening a draft is NOT possible on stapel-listings 0.6.0 ─────────────
76
+ *
77
+ * No read returns the `*_draft` twin: `GET /{pk}/` serializes the PUBLISHED
78
+ * fields (`ListingDetailSerializer`), and `ListingDraftSerializer` appears
79
+ * only as the RESPONSE of create / save-draft. So editing a live listing
80
+ * works completely — the composer seeds from the published half, which is
81
+ * exactly what the person sees on the page — while a draft abandoned and
82
+ * reopened later comes back empty. The composer says so
83
+ * (`draftNotReadable`) instead of showing a blank form as if nothing had been
84
+ * typed. Upstream ask, recorded in MODULE.md: put the draft twin on the
85
+ * retrieve, or add `GET /{pk}/draft/`.
86
+ */
87
+
88
+ /** The two members of `@stapel/cdn-react`'s upload bag this composer needs.
89
+ * Declared structurally so the dependency stays a wiring decision. */
90
+ export interface ListingImagesBag {
91
+ /** Settled CDN references in display order — the value of `images_draft`. */
92
+ readonly refs: readonly string[];
93
+ /** Available when every pick has a reference; blocked WITH the reason
94
+ * (still uploading / one failed) otherwise. */
95
+ readonly settled: ActionAvailability;
96
+ }
97
+
98
+ /** Where the submission has got to. Named so a funnel can be measured. */
99
+ export type ComposeStage =
100
+ | "choosing_category"
101
+ | "editing"
102
+ | "saving"
103
+ | "publishing"
104
+ | "published";
105
+
106
+ /** What a successful publish turned out to be — the 0.5.0 distinction, taken
107
+ * from the server's own answer rather than guessed from what we sent. */
108
+ export type PublishOutcome = "submitted_for_review" | "live_edit_under_review";
109
+
110
+ export interface UseListingComposerOptions {
111
+ /** An existing listing to edit. Absent: a new one, whose row is created on
112
+ * the first save. */
113
+ readonly listingId?: number;
114
+ /** The chosen category's features. Empty while none is chosen. */
115
+ readonly features: readonly FeatureDef[];
116
+ /** The schema read is in flight. Blocks the submit with its own reason —
117
+ * publishing against a half-known schema is how a mandatory attribute gets
118
+ * skipped. */
119
+ readonly featuresLoading?: boolean;
120
+ /** The schema read failed. Same block, different sentence. */
121
+ readonly featuresError?: unknown;
122
+ /** Value types the rendering half can draw — pass
123
+ * `BUILTIN_VALUE_EDITOR_TYPES` from `@stapel/attributes-react/default`. */
124
+ readonly editorTypes?: readonly string[];
125
+ /** The upload queue. Absent: no gallery, and `images` is set through
126
+ * `setValue` by whatever the host uses instead. */
127
+ readonly images?: ListingImagesBag;
128
+ /** Seed for a brand-new draft (a category preselected from the URL, say). */
129
+ readonly initialValues?: Partial<ListingDraftValues>;
130
+ onDraftCreated?: (draft: ListingDraft) => void;
131
+ onPublished?: (response: PublishResponse, outcome: PublishOutcome) => void;
132
+ }
133
+
134
+ export interface ListingComposerBag {
135
+ readonly stage: ComposeStage;
136
+ readonly listingId: number | undefined;
137
+ readonly values: ListingDraftValues;
138
+ /** The listing being edited, when there is one. `loading` until it lands —
139
+ a composer must not let someone publish over a listing it has not read. */
140
+ readonly listingState: LoadState<ListingDetailData> | undefined;
141
+ /** Editing something already PUBLISHED: the submit becomes "send changes",
142
+ * and the listing stays visible throughout. */
143
+ readonly isLiveEdit: boolean;
144
+ /** The reopened draft came back empty because no read returns the draft
145
+ * twin — see this module's header. */
146
+ readonly draftNotReadable: boolean;
147
+
148
+ setValue<K extends keyof ListingDraftValues>(
149
+ key: K,
150
+ value: ListingDraftValues[K]
151
+ ): void;
152
+ setLocation(location: ListingLocation): void;
153
+ setFeature(slug: string, value: unknown): void;
154
+ /** Changing category keeps the answers the new schema also asks for. */
155
+ setCategory(categoryId: string): void;
156
+ /** Slugs cleared by the last category change — named, not silently lost. */
157
+ readonly droppedOnCategoryChange: readonly string[];
158
+
159
+ /** The client mirror, always current. */
160
+ readonly mirror: Readonly<Record<string, FlowError>>;
161
+ /** What a control should actually show: the mirror once the person has
162
+ * tried to submit, overlaid by the server's verdict when there is one. */
163
+ readonly fieldErrors: Readonly<Record<string, FlowError>>;
164
+ /** The server's last publish refusal, unrouted, for a summary line. */
165
+ readonly refusal: PublishRefusal | undefined;
166
+ /** Value types this build cannot draw — the fact, from attributes-react. */
167
+ readonly unsupported: readonly string[];
168
+
169
+ readonly saveGate: ActionAvailability;
170
+ readonly publishGate: ActionAvailability;
171
+ save(): void;
172
+ publish(): void;
173
+ readonly saving: boolean;
174
+ readonly publishing: boolean;
175
+ readonly saved: boolean;
176
+ readonly saveError: unknown;
177
+ readonly outcome: PublishOutcome | undefined;
178
+ }
179
+
180
+ export function useListingComposer(
181
+ options: UseListingComposerOptions
182
+ ): ListingComposerBag {
183
+ const runtime = useListingsRuntime();
184
+ const mandate = useMandateGate();
185
+
186
+ const [listingId, setListingId] = useState<number | undefined>(
187
+ options.listingId
188
+ );
189
+ const [values, setValues] = useState<ListingDraftValues>(() => ({
190
+ ...emptyDraftValues({ currency: runtime.currency }),
191
+ ...options.initialValues,
192
+ }));
193
+ const [showErrors, setShowErrors] = useState(false);
194
+ const [dropped, setDropped] = useState<readonly string[]>([]);
195
+ const [refusal, setRefusal] = useState<PublishRefusal | undefined>(undefined);
196
+ const [outcome, setOutcome] = useState<PublishOutcome | undefined>(undefined);
197
+ const [saved, setSaved] = useState(false);
198
+
199
+ const existing = useListing(options.listingId);
200
+ const createDraft = useCreateDraft();
201
+ const saveDraft = useSaveDraft();
202
+ const publishListing = usePublishListing();
203
+
204
+ // Seed once from the listing being edited. `seeded` is a ref rather than a
205
+ // dependency guard because re-seeding on a refetch would throw away
206
+ // everything typed since — a background invalidation must never rewrite a
207
+ // form somebody is in the middle of.
208
+ const seeded = useRef(false);
209
+ useEffect(() => {
210
+ if (seeded.current) return;
211
+ const detail = existing.data;
212
+ if (detail === undefined) return;
213
+ seeded.current = true;
214
+ setListingId(detail.id);
215
+ setValues(
216
+ draftValuesFromDetail(
217
+ detail,
218
+ featuresDtoFromDaoList(asFeatureDaoList(detail.features)) as FeaturesDto,
219
+ { currency: runtime.currency }
220
+ )
221
+ );
222
+ }, [existing.data, runtime.currency]);
223
+
224
+ // Pruning runs on the SCHEMA arriving, one render after the category
225
+ // changed: `setCategory` cannot prune against features it has not been
226
+ // handed. Gated on the schema being settled, because pruning against a
227
+ // half-loaded (or previous) category's features would delete answers that
228
+ // are perfectly valid for the one now chosen.
229
+ const schemaSettled =
230
+ options.featuresLoading !== true && options.featuresError === undefined;
231
+ const { features } = options;
232
+ useEffect(() => {
233
+ if (!schemaSettled || features.length === 0) return;
234
+ setValues((current) => {
235
+ const gone = droppedFeatureSlugs(current.features, features);
236
+ if (gone.length === 0) return current;
237
+ setDropped(gone);
238
+ return {
239
+ ...current,
240
+ features: retainKnownFeatureValues(current.features, features),
241
+ };
242
+ });
243
+ }, [schemaSettled, features]);
244
+
245
+ // The gallery is the upload bag's, whenever there is one: two sources of
246
+ // truth for the same list is how a publish sends photos the person removed.
247
+ const images = options.images?.refs ?? values.images;
248
+ const effectiveValues: ListingDraftValues = useMemo(
249
+ () => ({ ...values, images }),
250
+ [values, images]
251
+ );
252
+
253
+ const featuresDto = useMemo(
254
+ () => toFeaturesDto(options.features, effectiveValues.features),
255
+ [options.features, effectiveValues.features]
256
+ );
257
+
258
+ const mirror = useMemo(
259
+ () =>
260
+ mirrorDraft(
261
+ effectiveValues,
262
+ options.features,
263
+ featuresDto,
264
+ runtime.limits
265
+ ),
266
+ [effectiveValues, options.features, featuresDto, runtime.limits]
267
+ );
268
+
269
+ const unsupported = useMemo(
270
+ () => unsupportedTypes(options.features, options.editorTypes ?? []),
271
+ [options.features, options.editorTypes]
272
+ );
273
+
274
+ const isLiveEdit = existing.data?.status === "published";
275
+ const draftNotReadable =
276
+ existing.data !== undefined &&
277
+ existing.data.status !== "published" &&
278
+ (existing.data.title ?? "").length === 0 &&
279
+ (existing.data.description ?? "").length === 0;
280
+
281
+ const fieldErrors: Readonly<Record<string, FlowError>> = useMemo(() => {
282
+ const shown = showErrors ? mirror : {};
283
+ return refusal?.kind === "invalid_draft"
284
+ ? { ...shown, ...refusal.fieldErrors }
285
+ : shown;
286
+ }, [showErrors, mirror, refusal]);
287
+
288
+ const listingState: LoadState<ListingDetailData> | undefined =
289
+ options.listingId === undefined
290
+ ? undefined
291
+ : existing.status === "error"
292
+ ? loadFailed(existing.error)
293
+ : existing.data !== undefined
294
+ ? loadReady(existing.data)
295
+ : loadLoading();
296
+
297
+ const busy = createDraft.isPending || saveDraft.isPending;
298
+
299
+ const schemaGate: ActionAvailability =
300
+ options.featuresError !== undefined
301
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedDetailsUnavailable)
302
+ : options.featuresLoading === true
303
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeDetailsLoading)
304
+ : actionAvailable();
305
+
306
+ const categoryGate: ActionAvailability =
307
+ effectiveValues.categoryId.length > 0
308
+ ? actionAvailable()
309
+ : actionBlocked(LISTINGS_I18N_KEYS.composeBlockedNoCategory);
310
+
311
+ const busyGate: ActionAvailability = busy
312
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedBusy)
313
+ : actionAvailable();
314
+
315
+ const saveGate = firstBlock(mandate, categoryGate, busyGate);
316
+
317
+ // The publish gate is the save gate PLUS everything that must be true for a
318
+ // submission to be complete. Order is the order a person would be told:
319
+ // may you act at all → is the form even answerable → are the photos in →
320
+ // is the form itself clean.
321
+ const publishGate = firstBlock(
322
+ mandate,
323
+ categoryGate,
324
+ schemaGate,
325
+ unsupported.length > 0
326
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedUnsupportedType, {
327
+ types: unsupported.join(", "),
328
+ })
329
+ : actionAvailable(),
330
+ options.images?.settled ?? actionAvailable(),
331
+ busyGate,
332
+ publishListing.isPending
333
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedBusy)
334
+ : actionAvailable(),
335
+ Object.keys(mirror).length > 0
336
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedMirror)
337
+ : actionAvailable()
338
+ );
339
+
340
+ /** Create the row on demand: the id is what every other write needs. */
341
+ const ensureListingId = useCallback(async (): Promise<number | undefined> => {
342
+ if (listingId !== undefined && listingId > 0) return listingId;
343
+ if (effectiveValues.categoryId.length === 0) return undefined;
344
+ const draft = await createDraft.mutateAsync(
345
+ createDraftBody(effectiveValues.categoryId)
346
+ );
347
+ setListingId(draft.id);
348
+ options.onDraftCreated?.(draft);
349
+ return draft.id;
350
+ }, [listingId, effectiveValues.categoryId, createDraft, options]);
351
+
352
+ const persist = useCallback(async (): Promise<number | undefined> => {
353
+ const id = await ensureListingId();
354
+ if (id === undefined) return undefined;
355
+ // The id comes from `ensureListingId`, not from this closure's render:
356
+ // creating the row and saving into it happen in ONE gesture, and the
357
+ // state update that records the new id has not committed yet.
358
+ await saveDraft.mutateAsync({
359
+ id,
360
+ body: draftPatchFromValues(effectiveValues, options.features),
361
+ });
362
+ setSaved(true);
363
+ return id;
364
+ }, [ensureListingId, saveDraft, effectiveValues, options.features]);
365
+
366
+ const save = useCallback((): void => {
367
+ setShowErrors(true);
368
+ if (!saveGate.available) return;
369
+ setRefusal(undefined);
370
+ void persist().catch(() => {
371
+ // The mutation's own `error` carries it; swallowing the rejection here
372
+ // only stops an unhandled-rejection warning for a failure the bag
373
+ // already reports.
374
+ });
375
+ }, [saveGate.available, persist]);
376
+
377
+ const publish = useCallback((): void => {
378
+ setShowErrors(true);
379
+ if (!publishGate.available) return;
380
+ setRefusal(undefined);
381
+ void (async () => {
382
+ try {
383
+ // Always save first: publish reads the STORED draft, so publishing
384
+ // without saving would promote whatever was there before the last
385
+ // keystroke — and the person would be told their listing is fine
386
+ // while a field they just fixed is still wrong on the server.
387
+ const id = await persist();
388
+ if (id === undefined) return;
389
+ const response = await publishListing.mutateAsync(id);
390
+ const result: PublishOutcome =
391
+ response.status === "published"
392
+ ? "live_edit_under_review"
393
+ : "submitted_for_review";
394
+ setOutcome(result);
395
+ options.onPublished?.(response, result);
396
+ } catch (thrown) {
397
+ setRefusal(publishRefusal(thrown));
398
+ }
399
+ })();
400
+ }, [publishGate.available, persist, publishListing, options]);
401
+
402
+ const stage: ComposeStage =
403
+ outcome !== undefined
404
+ ? "published"
405
+ : publishListing.isPending
406
+ ? "publishing"
407
+ : busy
408
+ ? "saving"
409
+ : effectiveValues.categoryId.length === 0
410
+ ? "choosing_category"
411
+ : "editing";
412
+
413
+ return {
414
+ stage,
415
+ listingId,
416
+ values: effectiveValues,
417
+ listingState,
418
+ isLiveEdit,
419
+ draftNotReadable,
420
+
421
+ setValue: (key, value) => {
422
+ setSaved(false);
423
+ setValues((current) => ({ ...current, [key]: value }));
424
+ },
425
+ setLocation: (location) => {
426
+ setSaved(false);
427
+ setValues((current) => ({ ...current, location }));
428
+ },
429
+ setFeature: (slug, value) => {
430
+ setSaved(false);
431
+ setValues((current) => ({
432
+ ...current,
433
+ features: { ...current.features, [slug]: value },
434
+ }));
435
+ },
436
+ setCategory: (categoryId) => {
437
+ setSaved(false);
438
+ setValues((current) => {
439
+ if (current.categoryId === categoryId) return current;
440
+ // The features of the NEW category are not known yet (the container's
441
+ // schema read has not run), so nothing is pruned here — pruning
442
+ // happens in the effect below, once they arrive. Recording the
443
+ // intent, not guessing the outcome.
444
+ return { ...current, categoryId };
445
+ });
446
+ setDropped([]);
447
+ setRefusal(undefined);
448
+ },
449
+ droppedOnCategoryChange: dropped,
450
+
451
+ mirror,
452
+ fieldErrors,
453
+ refusal,
454
+ unsupported,
455
+
456
+ saveGate,
457
+ publishGate,
458
+ save,
459
+ publish,
460
+ saving: busy,
461
+ publishing: publishListing.isPending,
462
+ saved,
463
+ saveError: saveDraft.error ?? createDraft.error,
464
+ outcome,
465
+ };
466
+ }
467
+
468
+ /** Renderless: the bag, handed to a render prop. */
469
+ export function ListingComposer(
470
+ props: UseListingComposerOptions & {
471
+ children: (bag: ListingComposerBag) => ReactNode;
472
+ }
473
+ ): ReactElement {
474
+ const bag = useListingComposer(props);
475
+ return <>{props.children(bag)}</>;
476
+ }