@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
package/src/index.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * `@stapel/listings-react` — the headless React pair for stapel-listings
3
+ * (frontend-standard §2). Business + state only, zero visual opinion; the
4
+ * antd skin lives behind the `./default` subpath, so a host that renders its
5
+ * own shop window carries none of it.
6
+ *
7
+ * ── The one-liner ──────────────────────────────────────────────────────────
8
+ *
9
+ * ```tsx
10
+ * const runtime = createListingsRuntime({
11
+ * baseUrl: "/listings/api/v1/",
12
+ * resolveImage: (ref) => myCdn.describe(ref),
13
+ * });
14
+ * <ListingsProvider runtime={runtime}>
15
+ * <ListingDetailPane id={id} />
16
+ * </ListingsProvider>
17
+ * ```
18
+ *
19
+ * ── The four properties this pair exists to guarantee ──────────────────────
20
+ *
21
+ * 1. **Two axes, both on screen, neither standing in for the other.**
22
+ * `status` decides visibility and nothing else does; `moderation_status`
23
+ * decides nothing about it. Since stapel-listings 0.5.0 they genuinely
24
+ * diverge — an edit to a LIVE listing keeps `status: published` and moves
25
+ * only the moderation axis — so "published, changes under review" is a
26
+ * sentence this pair can say, and a dashboard that computed either field
27
+ * from the other could not. `model/status.ts` produces both halves from
28
+ * both fields, once, and the 9 × 4 table is asserted.
29
+ * 2. **A refusal lands on the control that caused it.** `publish` answers an
30
+ * invalid draft with a BARE `ValidationBatchResult` (not an error
31
+ * envelope), and the engine's rows carry `{feature, slug}` and never
32
+ * `field`. `model/validation.ts` splits the two kinds of 400 apart and
33
+ * `featureErrorsBySlug` adds the routing key, so "this box is wrong" never
34
+ * degrades into "something is wrong".
35
+ * 3. **Every switched-off control states its reason.** Signing in, waiting
36
+ * for photos, a type this build cannot draw, a lifecycle move the server
37
+ * would refuse — each is an `ActionAvailability` with a named block, never
38
+ * a grey button and never a hidden one.
39
+ * 4. **An absence is never rendered as a zero.** A soft-deleted listing says
40
+ * it was removed rather than 404-ing like a typo; a stored feature this
41
+ * build cannot read is counted rather than dropped; and the owner's own
42
+ * listings — which stapel-listings 0.6.1 has NO endpoint for — arrive as a
43
+ * named failure rather than an empty grid (`headless/MyListings.tsx`).
44
+ *
45
+ * ── The seams, and why they are not imports ────────────────────────────────
46
+ *
47
+ * `@stapel/cdn-react` and `@stapel/categories-react` are L2 pairs, and L2
48
+ * pairs never import each other. The gallery therefore arrives as a
49
+ * structural {@link ListingImagesBag} (satisfied by cdn's upload bag: its
50
+ * `refs` IS `images_draft` and its `settled` is the submit gate), the
51
+ * category schema arrives as a plain `FeatureDef[]`, and a stored image
52
+ * reference is resolved by a host-supplied {@link ListingImageResolver} —
53
+ * because no contract in this fleet resolves a stranger's CDN reference, and
54
+ * a pair that invented a URL convention would be writing a contract nobody
55
+ * agreed to. `@stapel/attributes-react` IS a dependency; it is L0.
56
+ *
57
+ * Layers: api → model → flows → headless → i18n. Generated surfaces (the
58
+ * typed schema, the error map, the manifest, the nav manifest, llms.txt) are
59
+ * produced by the monorepo `gen:*` drivers from stapel-listings' own `docs/`
60
+ * artifacts and stand under drift gates.
61
+ */
62
+
63
+ // ── api ──────────────────────────────────────────────────────────────────────
64
+ export { createListingsApi } from "./api/listingsApi.js";
65
+ export type { ListingsApi } from "./api/listingsApi.js";
66
+ export {
67
+ DEFAULT_LISTING_CURRENCY,
68
+ LISTING_STATUSES,
69
+ MODERATION_STATUSES,
70
+ } from "./api/types.js";
71
+ export type {
72
+ DeleteResponse,
73
+ FavoriteToggleResponse,
74
+ FeatureConfig,
75
+ FeatureDef,
76
+ FeatureValueDto,
77
+ ListingActionResponse,
78
+ ListingCard,
79
+ ListingDetail as ListingDetailData,
80
+ ListingDraft,
81
+ ListingDraftPatch,
82
+ ListingFeatureDao,
83
+ ListingFeatureDaoUnion,
84
+ ListingFeatureType,
85
+ ListingFeatureView,
86
+ ListingLifecycleStatus,
87
+ ListingModerationStatus,
88
+ ListingPageParams,
89
+ ListingStatusInfo,
90
+ MyCounters,
91
+ PaginatedListingCards,
92
+ PublishResponse,
93
+ Schemas,
94
+ } from "./api/types.js";
95
+
96
+ // ── model: the two axes ──────────────────────────────────────────────────────
97
+ export {
98
+ MY_LISTINGS_TABS,
99
+ MY_LISTINGS_TAB_STATUSES,
100
+ isPubliclyVisible,
101
+ lifecycleCaption,
102
+ listingStatusView,
103
+ moderationNotice,
104
+ tabOf,
105
+ } from "./model/status.js";
106
+ export type {
107
+ LifecycleCaption,
108
+ ListingStatusTone,
109
+ ListingStatusView,
110
+ ModerationNotice,
111
+ MyListingsTab,
112
+ } from "./model/status.js";
113
+ export { LISTING_TRANSITIONS, canDelete, canTransition } from "./model/transitions.js";
114
+
115
+ // ── model: the owner-list gap, named ─────────────────────────────────────────
116
+ export { MY_LISTINGS_SOURCE_MISSING } from "./model/mineSource.js";
117
+ export type { MyListingsSource } from "./model/mineSource.js";
118
+
119
+ // ── model: the draft twin ────────────────────────────────────────────────────
120
+ export {
121
+ EMPTY_LOCATION,
122
+ createDraftBody,
123
+ draftPatchFromValues,
124
+ draftValuesFromDetail,
125
+ draftValuesFromWire,
126
+ droppedFeatureSlugs,
127
+ emptyDraftValues,
128
+ retainKnownFeatureValues,
129
+ } from "./model/draft.js";
130
+ export type {
131
+ EmptyDraftOptions,
132
+ ListingDraftValues,
133
+ ListingLocation,
134
+ } from "./model/draft.js";
135
+
136
+ // ── model: the stored feature projection ─────────────────────────────────────
137
+ export {
138
+ asFeatureDaoList,
139
+ featureFromDao,
140
+ featuresDtoFromDaoList,
141
+ featuresFromDaoList,
142
+ unreadableFeatureCount,
143
+ } from "./model/features.js";
144
+
145
+ // ── model: validation, the mirror and the publish-400 split ──────────────────
146
+ export {
147
+ CATEGORY_FIELD,
148
+ DEFAULT_DRAFT_LIMITS,
149
+ DESCRIPTION_FIELD,
150
+ IMAGES_FIELD,
151
+ PRICE_FIELD,
152
+ TITLE_FIELD,
153
+ failedResults,
154
+ isBatchValid,
155
+ listingFieldErrors,
156
+ mirrorDraft,
157
+ mirrorListingFields,
158
+ publishRefusal,
159
+ } from "./model/validation.js";
160
+ export type { ListingDraftLimits, PublishRefusal } from "./model/validation.js";
161
+
162
+ // ── model: runtime wiring, query keys, context, hooks ────────────────────────
163
+ export { createListingsRuntime } from "./model/runtime.js";
164
+ export type {
165
+ CreateListingsRuntimeOptions,
166
+ ListingImageResolver,
167
+ ListingsRuntime,
168
+ } from "./model/runtime.js";
169
+ export {
170
+ ListingsRuntimeContext,
171
+ useListingsAnalytics,
172
+ useListingsApi,
173
+ useListingsRuntime,
174
+ } from "./model/context.js";
175
+ export { listingsQueryKeys, pageKey } from "./model/queryKeys.js";
176
+ export type { ListingPageKey } from "./model/queryKeys.js";
177
+ export {
178
+ useListing,
179
+ useListingCards,
180
+ useListingStatus,
181
+ useMyCounters,
182
+ useMyFavorites,
183
+ useValidateDraft,
184
+ } from "./model/queries.js";
185
+ export {
186
+ useArchiveListing,
187
+ useCompleteListing,
188
+ useCreateDraft,
189
+ useDeleteListing,
190
+ useFavoriteListing,
191
+ usePublishListing,
192
+ useSaveDraft,
193
+ } from "./model/mutations.js";
194
+ export type { FavoriteInput, SaveDraftInput } from "./model/mutations.js";
195
+
196
+ // ── flows (zero-flow shim — stapel-listings annotates none) ──────────────────
197
+ export { LISTINGS_FLOWS, flowEndpoints } from "./flows/registry.js";
198
+ export type {
199
+ FlowEndpoint,
200
+ ListingsFlowId,
201
+ ListingsFlowSpec,
202
+ } from "./flows/registry.js";
203
+
204
+ // ── headless (renderless components + their bags) ────────────────────────────
205
+ export { ListingsProvider } from "./headless/ListingsProvider.js";
206
+ export { useMandateGate } from "./headless/useMandateGate.js";
207
+ export { ListingDetail, useListingDetail } from "./headless/ListingDetail.js";
208
+ export type {
209
+ ListingDetailBag,
210
+ UseListingDetailOptions,
211
+ } from "./headless/ListingDetail.js";
212
+ export { ListingComposer, useListingComposer } from "./headless/ListingComposer.js";
213
+ export type {
214
+ ComposeStage,
215
+ ListingComposerBag,
216
+ ListingImagesBag,
217
+ PublishOutcome,
218
+ UseListingComposerOptions,
219
+ } from "./headless/ListingComposer.js";
220
+ export { MyListings, useMyListings } from "./headless/MyListings.js";
221
+ export type {
222
+ MyListingsBag,
223
+ UseMyListingsOptions,
224
+ } from "./headless/MyListings.js";
225
+ export { Favorites, useFavorites, useFavoriteToggle } from "./headless/Favorites.js";
226
+ export type {
227
+ FavoriteToggleBag,
228
+ FavoritesBag,
229
+ UseFavoritesOptions,
230
+ } from "./headless/Favorites.js";
231
+ export { useListingActions } from "./headless/ListingActions.js";
232
+ export type { ListingActionsBag } from "./headless/ListingActions.js";
233
+
234
+ // ── nav manifest (the pair's public surface declaration) ─────────────────────
235
+ export { ACCOUNT_ROOT_ID, navEntries } from "./nav/manifest.js";
236
+
237
+ // ── i18n ─────────────────────────────────────────────────────────────────────
238
+ export {
239
+ LISTINGS_I18N_KEYS,
240
+ listingsI18nBundleEn,
241
+ registerListingsI18n,
242
+ } from "./i18n/keys.js";
243
+ export type { ListingsI18nKey } from "./i18n/keys.js";
244
+
245
+ // ── errors map (code → status/params/remediation/en; generated) ──────────────
246
+ export {
247
+ LISTINGS_ERRORS,
248
+ LISTINGS_ERROR_CODES,
249
+ LISTING_CANNOT_DELETE_ACTIVE,
250
+ LISTING_INVALID_TRANSITION,
251
+ LISTING_NOT_OWNER,
252
+ LISTING_PUBLISH_VALIDATION_FAILED,
253
+ explainListingsError,
254
+ listingsErrorBundleEn,
255
+ } from "./i18n/errorsMap.js";
256
+ export type {
257
+ ListingsErrorCode,
258
+ ListingsErrorSpec,
259
+ Remediation,
260
+ } from "./i18n/errorsMap.js";
@@ -0,0 +1,28 @@
1
+ import type { Context } from "react";
2
+ import { createModuleContext } from "@stapel/core";
3
+ import type { Analytics, ModuleContextKit } from "@stapel/core";
4
+ import type { ListingsApi } from "../api/listingsApi.js";
5
+ import type { ListingsRuntime } from "./runtime.js";
6
+
7
+ /**
8
+ * The wired ListingsRuntime shared through React context by
9
+ * `<ListingsProvider>`. Hooks in `model/` and `headless/` read the singletons
10
+ * from here. One reviewed copy of this plumbing lives in `@stapel/core`
11
+ * (`createModuleContext`, slim wave §21/S2); this module binds it under the
12
+ * pair's public names.
13
+ */
14
+ const kit: ModuleContextKit<ListingsRuntime> =
15
+ createModuleContext<ListingsRuntime>("Listings");
16
+
17
+ export const ListingsRuntimeContext: Context<ListingsRuntime | null> =
18
+ kit.RuntimeContext;
19
+
20
+ export const useListingsRuntime: () => ListingsRuntime = kit.useRuntime;
21
+
22
+ export const useListingsApi: () => ListingsApi = kit.useApi;
23
+
24
+ export const useListingsAnalytics: () => Analytics | null = kit.useAnalytics;
25
+
26
+ /** @internal Re-exported as `<ListingsProvider>` from `headless/`. */
27
+ export const ModuleProvider: ModuleContextKit<ListingsRuntime>["Provider"] =
28
+ kit.Provider;
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The draft a composer holds, and its two conversions to the wire.
3
+ *
4
+ * stapel-listings stores every user-editable field TWICE: `title_draft` beside
5
+ * `title`, `images_draft` beside `images`, and so on. `publish` promotes the
6
+ * draft half onto the published half; nothing else does. So a composer edits
7
+ * exactly one side of that twin, and this module is where the browser's
8
+ * shape (plain values keyed the way a form keys them) meets the wire's
9
+ * (`*_draft` fields plus the `{slug: {type, value}}` feature envelope).
10
+ *
11
+ * Everything here is PURE — no React, no fetch — so the round trip is
12
+ * testable without mounting anything, and so `test/compose.test.tsx` can
13
+ * assert the BODY a save sends rather than the fact that it sent one.
14
+ */
15
+ import type { FeatureDef, FeaturesDto } from "@stapel/attributes-react";
16
+ import { fromFeaturesDto, toFeaturesDto } from "@stapel/attributes-react";
17
+ import type {
18
+ ListingDetail,
19
+ ListingDraft,
20
+ ListingDraftPatch,
21
+ } from "../api/types.js";
22
+ import { DEFAULT_LISTING_CURRENCY } from "../api/types.js";
23
+
24
+ /**
25
+ * Where the listing is, as ONE value.
26
+ *
27
+ * A composite because the four members only make sense together: a `lat`
28
+ * without a `lon` is not "half a location", it is a broken one, and a
29
+ * `geohash` that disagrees with the coordinates beside it is worse than no
30
+ * geohash — the pin and the bucket point at different places.
31
+ *
32
+ * The pair does NOT compute `geohash` from `lat`/`lon`, and that is a
33
+ * decision. A geohash is precision-dependent, the indexer's bucketing depends
34
+ * on which precision was used, and a client that picked its own would put
35
+ * listings in neighbouring cells for reasons nobody could see. It is written
36
+ * by whatever resolved the place (a geo picker, `stapel-geo`) and carried
37
+ * verbatim from there to the wire.
38
+ */
39
+ export interface ListingLocation {
40
+ /** Opaque id from whatever place directory the deployment uses. */
41
+ readonly locationId: string;
42
+ /** What a person reads: "Kazan, Vahitovsky district". */
43
+ readonly locationLabel: string;
44
+ /** Decimal STRING, as the wire spells it — a float here would round-trip
45
+ * `55.796100` into `55.7961` and change what was submitted. */
46
+ readonly lat: string | null;
47
+ readonly lon: string | null;
48
+ readonly geohash: string;
49
+ }
50
+
51
+ export const EMPTY_LOCATION: ListingLocation = {
52
+ locationId: "",
53
+ locationLabel: "",
54
+ lat: null,
55
+ lon: null,
56
+ geohash: "",
57
+ };
58
+
59
+ /**
60
+ * Everything a composer edits.
61
+ *
62
+ * `features` holds PLAIN values keyed by slug — what a value editor's
63
+ * `onChange` produces — never the DTO envelope. The envelope is built at send
64
+ * time by `toFeaturesDto`, which tags each value with its type from the
65
+ * category schema; a composer that stored tagged values would have to
66
+ * re-tag them on every category change.
67
+ */
68
+ export interface ListingDraftValues {
69
+ readonly categoryId: string;
70
+ readonly title: string;
71
+ readonly description: string;
72
+ /** Decimal string, or `""` for "no price yet". */
73
+ readonly price: string;
74
+ readonly currency: string;
75
+ readonly language: string;
76
+ /** CDN references (`<type>/<hash>`) — ORDER IS THE GALLERY ORDER, and the
77
+ * first one is the cover. This is exactly `bag.refs` from
78
+ * `@stapel/cdn-react`'s upload queue; see `headless/ListingComposer.tsx`. */
79
+ readonly images: readonly string[];
80
+ readonly location: ListingLocation;
81
+ readonly features: Readonly<Record<string, unknown>>;
82
+ /** A countable good (a quantity applies) vs a service. */
83
+ readonly countable: boolean;
84
+ readonly stockQuantity: number | null;
85
+ readonly autoRepublish: boolean;
86
+ }
87
+
88
+ export interface EmptyDraftOptions {
89
+ readonly categoryId?: string;
90
+ readonly currency?: string;
91
+ readonly language?: string;
92
+ }
93
+
94
+ /** A blank draft. `currency` defaults to the storefront's (owner verdict F6 —
95
+ * RUB), overridable per runtime and per call. */
96
+ export function emptyDraftValues(
97
+ options: EmptyDraftOptions = {}
98
+ ): ListingDraftValues {
99
+ return {
100
+ categoryId: options.categoryId ?? "",
101
+ title: "",
102
+ description: "",
103
+ price: "",
104
+ currency: options.currency ?? DEFAULT_LISTING_CURRENCY,
105
+ language: options.language ?? "",
106
+ images: [],
107
+ location: EMPTY_LOCATION,
108
+ features: {},
109
+ countable: false,
110
+ stockQuantity: null,
111
+ autoRepublish: false,
112
+ };
113
+ }
114
+
115
+ function text(value: string | null | undefined): string {
116
+ return value ?? "";
117
+ }
118
+
119
+ /**
120
+ * A reopened draft: the server's `*_draft` fields → the composer's values.
121
+ *
122
+ * `features_draft` is `null` on a listing nobody has filled in yet, which is
123
+ * not the same as `{}` on the wire but is the same thing to a composer.
124
+ */
125
+ export function draftValuesFromWire(
126
+ draft: ListingDraft,
127
+ options: EmptyDraftOptions = {}
128
+ ): ListingDraftValues {
129
+ const base = emptyDraftValues(options);
130
+ return {
131
+ categoryId: text(draft.category_id),
132
+ title: text(draft.title_draft),
133
+ description: text(draft.description_draft),
134
+ price: text(draft.price_draft),
135
+ currency: draft.currency !== undefined && draft.currency.length > 0
136
+ ? draft.currency
137
+ : base.currency,
138
+ language: text(draft.language),
139
+ images: draft.images_draft ?? [],
140
+ location: {
141
+ locationId: text(draft.location_id_draft),
142
+ locationLabel: text(draft.location_label_draft),
143
+ lat: draft.lat_draft ?? null,
144
+ lon: draft.lon_draft ?? null,
145
+ geohash: text(draft.geohash_draft),
146
+ },
147
+ features: fromFeaturesDto((draft.features_draft ?? {}) as FeaturesDto),
148
+ countable: draft.countable ?? false,
149
+ stockQuantity: draft.stock_quantity ?? null,
150
+ autoRepublish: draft.auto_republish ?? false,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Editing something already PUBLISHED: the published half → the composer.
156
+ *
157
+ * A live listing's `*_draft` fields hold whatever was last submitted, which
158
+ * after a publish is the same content — but a listing published before this
159
+ * pair existed, or one whose draft was cleared, would open empty. Seeding
160
+ * from the PUBLISHED fields is what makes "edit" show the listing a person
161
+ * can actually see. Feature values come back from the DAO projection, which
162
+ * is the one place the published values live.
163
+ */
164
+ export function draftValuesFromDetail(
165
+ detail: ListingDetail,
166
+ featuresDto: FeaturesDto,
167
+ options: EmptyDraftOptions = {}
168
+ ): ListingDraftValues {
169
+ const base = emptyDraftValues(options);
170
+ return {
171
+ categoryId: detail.category_id,
172
+ title: text(detail.title),
173
+ description: text(detail.description),
174
+ price: text(detail.price),
175
+ currency: detail.currency !== undefined && detail.currency.length > 0
176
+ ? detail.currency
177
+ : base.currency,
178
+ language: text(detail.language),
179
+ images: detail.images ?? [],
180
+ location: {
181
+ locationId: text(detail.location_id),
182
+ locationLabel: text(detail.location_label),
183
+ lat: detail.lat ?? null,
184
+ lon: detail.lon ?? null,
185
+ geohash: text(detail.geohash),
186
+ },
187
+ features: fromFeaturesDto(featuresDto),
188
+ countable: detail.countable ?? false,
189
+ stockQuantity: detail.stock_quantity ?? null,
190
+ autoRepublish: detail.auto_republish ?? false,
191
+ };
192
+ }
193
+
194
+ /** The wire's shape for `features_draft`: since stapel-listings 0.6.1 fixed
195
+ * the discriminator, this is a map of properly discriminated DTOs. */
196
+ type WireFeaturesDraft = NonNullable<ListingDraftPatch["features_draft"]>;
197
+
198
+ /**
199
+ * The ONE boundary between the two descriptions of a feature value.
200
+ *
201
+ * `toFeaturesDto` speaks the engine's own structural shape (`{type: string,
202
+ * value: unknown}` plus whatever a type adds) — it has to, because the type
203
+ * vocabulary is an OPEN registry server-side (`EXTRA_TYPES`, runtime
204
+ * `register_feature_type`), so a deployment can legitimately submit a type
205
+ * this generated union has never heard of. The generated union describes the
206
+ * ten BUILTIN types precisely, which is exactly right for reading and one
207
+ * notch too narrow for writing.
208
+ *
209
+ * Rather than widen the wire type or narrow the engine's, the conversion is
210
+ * named here, once, with the reason attached. A cast scattered at call sites
211
+ * would be the same code with nowhere to write this down.
212
+ */
213
+ function toWireFeatures(
214
+ features: readonly FeatureDef[],
215
+ values: Readonly<Record<string, unknown>>
216
+ ): WireFeaturesDraft {
217
+ return toFeaturesDto(features, values) as unknown as WireFeaturesDraft;
218
+ }
219
+
220
+ /**
221
+ * The composer's values → the `save-draft` body.
222
+ *
223
+ * `features` needs the category schema to be tagged, so it is passed in. A
224
+ * feature the schema no longer declares is dropped by `toFeaturesDto` only if
225
+ * it is absent from `features`; keeping a stale slug in the payload would be
226
+ * refused per-feature by 0.6.0's M-7 rule
227
+ * (`error.400.listing_feature_not_allowed`), which is why
228
+ * {@link retainKnownFeatureValues} prunes on the way in rather than letting
229
+ * the server explain it.
230
+ */
231
+ export function draftPatchFromValues(
232
+ values: ListingDraftValues,
233
+ features: readonly FeatureDef[]
234
+ ): ListingDraftPatch {
235
+ return {
236
+ category_id: values.categoryId,
237
+ title_draft: values.title,
238
+ description_draft: values.description,
239
+ price_draft: values.price.length > 0 ? values.price : null,
240
+ currency: values.currency,
241
+ ...(values.language.length > 0 ? { language: values.language } : {}),
242
+ images_draft: [...values.images],
243
+ location_id_draft: values.location.locationId,
244
+ location_label_draft: values.location.locationLabel,
245
+ geohash_draft: values.location.geohash,
246
+ lat_draft: values.location.lat,
247
+ lon_draft: values.location.lon,
248
+ features_draft: toWireFeatures(features, values.features),
249
+ countable: values.countable,
250
+ // The pair mirrors the model's cross-field rule rather than sending a
251
+ // contradiction: a service carries no quantity, and `validate_countable
252
+ // _stock` refuses a `stock_quantity` beside `countable: false`.
253
+ stock_quantity: values.countable ? values.stockQuantity : null,
254
+ auto_republish: values.autoRepublish,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * The body for CREATING a draft: `category_id` and nothing else.
260
+ *
261
+ * `perform_create` forces `owner` and `status`, and everything else has a
262
+ * model default, so a create that also carried the form's current contents
263
+ * would be a second write of data the very next `save-draft` sends anyway —
264
+ * and would fail the whole submission on a field the person could still fix.
265
+ * Create the row, then save into it.
266
+ */
267
+ export function createDraftBody(categoryId: string): ListingDraftPatch {
268
+ return { category_id: categoryId };
269
+ }
270
+
271
+ /**
272
+ * Switching category: keep the answers whose slug the new schema also
273
+ * declares, drop the rest.
274
+ *
275
+ * Spec §4.1 asks for exactly this, and the reason is the same one the forms
276
+ * spec gives for `error.409.forms_version_superseded`: a person who picked
277
+ * "Phones", filled in Brand and Condition, then realised they meant "Used
278
+ * phones" should not retype what both categories ask for. A value whose slug
279
+ * is gone IS dropped, because 0.6.0 rejects an unknown slug per feature —
280
+ * carrying it would turn a category change into a publish refusal about a
281
+ * field the composer no longer draws.
282
+ */
283
+ export function retainKnownFeatureValues(
284
+ values: Readonly<Record<string, unknown>>,
285
+ features: readonly FeatureDef[]
286
+ ): Readonly<Record<string, unknown>> {
287
+ const known = new Set(features.map((feature) => feature.slug));
288
+ const out: Record<string, unknown> = {};
289
+ for (const [slug, value] of Object.entries(values)) {
290
+ if (known.has(slug)) out[slug] = value;
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /** Slugs that were answered and are NOT in the new schema — what
296
+ * {@link retainKnownFeatureValues} just dropped. A composer tells the person
297
+ * ("2 answers do not apply to this category") instead of losing them
298
+ * silently. */
299
+ export function droppedFeatureSlugs(
300
+ values: Readonly<Record<string, unknown>>,
301
+ features: readonly FeatureDef[]
302
+ ): readonly string[] {
303
+ const known = new Set(features.map((feature) => feature.slug));
304
+ return Object.keys(values)
305
+ .filter((slug) => !known.has(slug))
306
+ .sort();
307
+ }