@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,237 @@
1
+ import type { StapelClient } from "@stapel/core";
2
+ import type {
3
+ DeleteResponse,
4
+ FavoriteToggleResponse,
5
+ ListingActionResponse,
6
+ ListingDetail,
7
+ ListingDraft,
8
+ ListingDraftPatch,
9
+ ListingPageParams,
10
+ ListingStatusInfo,
11
+ MyCounters,
12
+ PaginatedListingCards,
13
+ PublishResponse,
14
+ } from "./types.js";
15
+ import type { ValidationBatchResult } from "@stapel/attributes-react";
16
+
17
+ /**
18
+ * The pair's typed operation surface — one method per stapel-listings endpoint
19
+ * a storefront may call, bound to the injected {@link StapelClient} (the
20
+ * per-module override seam of frontend-standard §7.2). Paths are relative to
21
+ * the runtime's `baseUrl` (`/listings/api/v1/`).
22
+ *
23
+ * ── The two operations that are on the contract and NOT here ───────────────
24
+ *
25
+ * `PUT /{pk}/` and `PATCH /{pk}/` are absent, and this is the one place in
26
+ * the pair where an absence is a safety decision rather than a scope one.
27
+ * Every OWNER operation in this module routes through `views._get_own`, which
28
+ * answers `error.403.listing_not_owner` when the caller is not the owner —
29
+ * every one except these two. `update`/`partial_update` are the plain
30
+ * `ModelViewSet` implementations under the viewset's default
31
+ * `IsAuthenticatedOrReadOnly`, and `get_queryset` hands them
32
+ * `Listing.objects.all()`: any authenticated caller can write any listing's
33
+ * draft fields through them. `POST /{pk}/save-draft/` performs the SAME write
34
+ * (the same `ListingDraftSerializer`, `partial=True`) with the ownership
35
+ * check, so the pair uses it and nothing is lost. Both stay in the generated
36
+ * schema and therefore in `manifest.json` — the contract is not hidden, this
37
+ * pair simply declines to be the client that exercises it. Upstream ask,
38
+ * recorded in MODULE.md: put `_get_own` in front of `update`/`partial_update`
39
+ * (or drop them from the router).
40
+ *
41
+ * These operations will be GENERATED from schema.json operationIds by gen-api
42
+ * v2 (task `core-typed-ops`); until then they are hand-authored here (the ONE
43
+ * legal home of path strings — `stapel/no-string-paths` §2.3 carve-out).
44
+ */
45
+ export interface ListingsApi {
46
+ readonly client: StapelClient;
47
+
48
+ /**
49
+ * One keyset page of PUBLISHED cards.
50
+ *
51
+ * Two things this is not, both worth stating because the storefront spec
52
+ * assumed otherwise at one point:
53
+ *
54
+ * - It is not "my listings". `get_queryset` answers `qs.published()` for
55
+ * `list` and takes no owner parameter, so this endpoint cannot be
56
+ * narrowed to the caller. See `headless/MyListings.tsx` for what the
57
+ * dashboard does about that.
58
+ * - It is not the storefront grid either. A marketplace's result page goes
59
+ * through `@stapel/search-react`, because `promoted` (DSA Art. 26) rides
60
+ * every search item under every sort and a card list that carried the
61
+ * marking on some pages and not others would be worse than one that
62
+ * never claimed it. This exists for a host with no search module and for
63
+ * the "more from this category" strip a detail page may want.
64
+ */
65
+ list(
66
+ params?: ListingPageParams,
67
+ options?: { readonly signal?: AbortSignal }
68
+ ): Promise<PaginatedListingCards>;
69
+
70
+ /**
71
+ * One listing in full.
72
+ *
73
+ * `IsAuthenticatedOrReadOnly` + a queryset with no `published()` filter:
74
+ * this answers for a DRAFT and for a BLOCKED listing too, to anyone who
75
+ * knows the id. The pair does not pretend otherwise — `useListing` reports
76
+ * `publiclyVisible` off `status` so a skin can say "this listing is not
77
+ * published" instead of drawing a live-looking page (see `model/status.ts`,
78
+ * and MODULE.md's upstream note).
79
+ */
80
+ retrieve(
81
+ id: number,
82
+ options?: { readonly signal?: AbortSignal }
83
+ ): Promise<ListingDetail>;
84
+
85
+ /**
86
+ * The AllowAny status probe: both axes plus `is_deleted` / `is_expired` /
87
+ * `is_active` / `owner_id`.
88
+ *
89
+ * It reads `Listing.all_objects`, so it is the only operation that still
90
+ * answers for a SOFT-DELETED listing — which is exactly what turns a bare
91
+ * 404 on the detail into "this listing was removed".
92
+ */
93
+ status(
94
+ id: number,
95
+ options?: { readonly signal?: AbortSignal }
96
+ ): Promise<ListingStatusInfo>;
97
+
98
+ /** Counts by dashboard tab for the caller (`active` / `archived` /
99
+ * `drafts`). Server-side definitions, not the pair's: `active` folds
100
+ * PENDING in with PUBLISHED, `drafts` folds REJECTED in with DRAFT. */
101
+ myCounters(options?: { readonly signal?: AbortSignal }): Promise<MyCounters>;
102
+
103
+ /** One keyset page of the caller's favourites. */
104
+ myFavorites(
105
+ params?: ListingPageParams,
106
+ options?: { readonly signal?: AbortSignal }
107
+ ): Promise<PaginatedListingCards>;
108
+
109
+ /**
110
+ * Start a draft. `category_id` is the only required member: the server
111
+ * forces `owner=request.user` and `status=draft` in `perform_create`, so a
112
+ * body that tried to set either is writing a field it does not own.
113
+ */
114
+ createDraft(body: ListingDraftPatch): Promise<ListingDraft>;
115
+
116
+ /**
117
+ * Persist draft fields. Always partial — send what moved.
118
+ *
119
+ * Refuses with the DRF field-error envelope (`error.400.field.*`) on a
120
+ * declarative violation (`title_draft` over 255, a negative price, a
121
+ * `stock_quantity` that contradicts `countable`).
122
+ */
123
+ saveDraft(id: number, body: ListingDraftPatch): Promise<ListingDraft>;
124
+
125
+ /**
126
+ * Ask what publishing WOULD say, without publishing. Same validator as
127
+ * `publish` (`services.publish.validate_draft`), so the two cannot disagree
128
+ * — including on an unknown feature slug, which 0.6.0's M-7 convergence
129
+ * made a per-feature refusal (`error.400.listing_feature_not_allowed`)
130
+ * rather than an opaque failure at publish time.
131
+ */
132
+ validateDraft(
133
+ id: number,
134
+ options?: { readonly signal?: AbortSignal }
135
+ ): Promise<ValidationBatchResult>;
136
+
137
+ /**
138
+ * Promote the draft.
139
+ *
140
+ * TWO distinct 400s, and a caller must tell them apart:
141
+ * - an INVALID DRAFT answers with a bare {@link ValidationBatchResult}
142
+ * body — no `localizable_error`, no envelope — which core wraps as
143
+ * `stapel.http.400` with the batch on `StapelApiError.body`;
144
+ * - a promotion that then fails (`REQUIRE_IMAGE_ON_PUBLISH` with no photo)
145
+ * answers the ordinary envelope `error.400.publish_validation_failed`.
146
+ * `model/validation.ts` owns that split so no screen re-derives it.
147
+ *
148
+ * On success the returned `status` is `pending` for a first publication and
149
+ * stays `published` for an edit to a LIVE listing — the 0.5.0
150
+ * re-moderation semantics the whole dashboard is built around.
151
+ */
152
+ publish(id: number): Promise<PublishResponse>;
153
+
154
+ /** Move to ARCHIVED. `error.409.invalid_listing_transition` with
155
+ * `params.from_status` when the lifecycle does not allow it. */
156
+ archive(id: number): Promise<ListingActionResponse>;
157
+
158
+ /** Mark SOLD. Same 409 contract as {@link archive}. */
159
+ complete(id: number): Promise<ListingActionResponse>;
160
+
161
+ /** Favourite a listing. Idempotent server-side (`get_or_create`). */
162
+ favorite(id: number): Promise<FavoriteToggleResponse>;
163
+
164
+ /** Un-favourite. Idempotent: deleting nothing still answers 200. */
165
+ unfavorite(id: number): Promise<FavoriteToggleResponse>;
166
+
167
+ /**
168
+ * Soft-delete. Ownership-checked (unlike PUT/PATCH — see the header) and
169
+ * refused with `error.409.listing_cannot_delete_active` while the listing
170
+ * is PUBLISHED or PENDING: archive it first.
171
+ */
172
+ remove(id: number): Promise<DeleteResponse>;
173
+ }
174
+
175
+ function pageQuery(
176
+ params: ListingPageParams | undefined
177
+ ): Record<string, string | number | undefined> {
178
+ return {
179
+ ...(params?.anchor !== undefined ? { anchor: params.anchor } : {}),
180
+ ...(params?.direction !== undefined ? { direction: params.direction } : {}),
181
+ ...(params?.limit !== undefined ? { limit: params.limit } : {}),
182
+ };
183
+ }
184
+
185
+ function signal(
186
+ options: { readonly signal?: AbortSignal } | undefined
187
+ ): { signal?: AbortSignal } {
188
+ return options?.signal !== undefined ? { signal: options.signal } : {};
189
+ }
190
+
191
+ /** The collection root and the one place a listing id becomes a path. */
192
+ const COLLECTION = "/listings/";
193
+ function listingPath(id: number): string {
194
+ return `${COLLECTION}${String(id)}/`;
195
+ }
196
+
197
+ export function createListingsApi(client: StapelClient): ListingsApi {
198
+ return {
199
+ client,
200
+
201
+ list: (params, options) =>
202
+ client.get(COLLECTION, { query: pageQuery(params), ...signal(options) }),
203
+
204
+ retrieve: (id, options) => client.get(listingPath(id), signal(options)),
205
+
206
+ status: (id, options) =>
207
+ client.get(`${listingPath(id)}status/`, signal(options)),
208
+
209
+ myCounters: (options) =>
210
+ client.get(`${COLLECTION}my/counters/`, signal(options)),
211
+
212
+ myFavorites: (params, options) =>
213
+ client.get(`${COLLECTION}my/favorites/`, {
214
+ query: pageQuery(params),
215
+ ...signal(options),
216
+ }),
217
+
218
+ createDraft: (body) => client.post(COLLECTION, body),
219
+
220
+ saveDraft: (id, body) => client.post(`${listingPath(id)}save-draft/`, body),
221
+
222
+ validateDraft: (id, options) =>
223
+ client.get(`${listingPath(id)}validate-draft/`, signal(options)),
224
+
225
+ publish: (id) => client.post(`${listingPath(id)}publish/`),
226
+
227
+ archive: (id) => client.post(`${listingPath(id)}archive/`),
228
+
229
+ complete: (id) => client.post(`${listingPath(id)}complete/`),
230
+
231
+ favorite: (id) => client.post(`${listingPath(id)}favorite/`),
232
+
233
+ unfavorite: (id) => client.post(`${listingPath(id)}unfavorite/`),
234
+
235
+ remove: (id) => client.delete(listingPath(id)),
236
+ };
237
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Wire types for the stapel-listings HTTP contract — **derived from the
3
+ * generated OpenAPI surface** (frontend-standard §2/§3), never hand-maintained.
4
+ * The single source of truth is `components["schemas"]` from this pair's own
5
+ * package-LOCAL generated schema (`./generated/schema.js`, produced by
6
+ * `pnpm gen:api` from stapel-listings' OWN `docs/schema.json`).
7
+ *
8
+ * Two notes about the polymorphic feature union, one of them now HISTORY and
9
+ * kept because the reason it is history is the useful part:
10
+ *
11
+ * 1. **The discriminator is correct as of stapel-listings 0.6.1, and was not
12
+ * in 0.6.0.** 0.6.0 declared `discriminator: {propertyName: "type",
13
+ * mapping: {"null": ConvertibleUnitDao}}` — one bogus entry instead of the
14
+ * ten type slugs — and openapi-typescript answers that by stripping `type`
15
+ * from every member and re-adding a SYNTHETIC discriminant, so the
16
+ * generated `IntDao` said `type: "IntDao"` where the wire sends `"int"`.
17
+ * `@stapel/categories-react` hit the same defect one directory over and
18
+ * routed around it. 0.6.1 emits the ten slugs (and an upstream contract
19
+ * test now holds them there), so `Schemas["FeatureDao"]` and
20
+ * `Schemas["FeatureDto"]` discriminate on the real values and ARE the wire
21
+ * types this pair uses. What still comes from
22
+ * `@stapel/attributes-react` is the behaviour — the editors, the mirror,
23
+ * the formatter — which is the spec's L0 seam and never was a workaround.
24
+ *
25
+ * 2. **A stored DAO carries `slug`; the schema's `FeatureDao` does not.**
26
+ * `features`/`features_title`/`features_badges` are `ListingFeaturesOutput
27
+ * Field`, a plain `serializers.JSONField` whose OpenAPI *description* is
28
+ * swapped for the DAO union by an extension (`stapel-listings/
29
+ * serializers.py`). A JSONField filters nothing: what reaches the wire is
30
+ * what `build_features_list` stored, and that is `{**dao, "slug": slug}`
31
+ * (`services/features.py`). The slug is load-bearing — it is how a card
32
+ * keys a badge and how a refusal finds its control — so {@link
33
+ * ListingFeatureDao} mirrors the runtime shape and says why.
34
+ */
35
+ import type { FeatureConfig, FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
36
+ import type { components } from "./generated/schema.js";
37
+
38
+ /** The generated schema table — the one source of truth for wire shapes. */
39
+ export type Schemas = components["schemas"];
40
+
41
+ /** `GET /listings/{pk}/` 200 — everything a detail page reads. */
42
+ export type ListingDetail = Schemas["ListingDetail"];
43
+
44
+ /** One row of a card list (`GET /listings/`, `GET /listings/my/favorites/`). */
45
+ export type ListingCard = Schemas["ListingCard"];
46
+
47
+ /** `POST /listings/` request+response and `POST /{pk}/save-draft/` response —
48
+ * the draft twin. Every user-editable field is a `*_draft` one, promoted onto
49
+ * its published sibling by `publish`. */
50
+ export type ListingDraft = Schemas["ListingDraft"];
51
+
52
+ /** The partial body a `save-draft` write sends. */
53
+ export type ListingDraftPatch = Schemas["PatchedListingDraft"];
54
+
55
+ /** `GET /{pk}/status/` 200 — the AllowAny status probe, and the ONLY read that
56
+ * still answers for a soft-deleted listing (`Listing.all_objects`). */
57
+ export type ListingStatusInfo = Schemas["ListingStatus"];
58
+
59
+ /** `GET /my/counters/` 200 — the three dashboard tab counts. */
60
+ export type MyCounters = Schemas["MyCountersResponse"];
61
+
62
+ /** `POST /{pk}/publish/` 200. The 400 is a `ValidationBatchResult`, NOT an
63
+ * error envelope — see `model/validation.ts`. */
64
+ export type PublishResponse = Schemas["PublishResponse"];
65
+
66
+ /** `POST /{pk}/archive|complete/` 200. */
67
+ export type ListingActionResponse = Schemas["ListingActionResponse"];
68
+
69
+ /** `POST /{pk}/favorite|unfavorite/` 200. */
70
+ export type FavoriteToggleResponse = Schemas["FavoriteToggleResponse"];
71
+
72
+ /** `DELETE /{pk}/` 200. */
73
+ export type DeleteResponse = Schemas["DeleteResponse"];
74
+
75
+ /** The keyset envelope both card lists come back in (`IDAnchorPagination`). */
76
+ export type PaginatedListingCards = Schemas["PaginatedListingCardList"];
77
+
78
+ /**
79
+ * The nine lifecycle states, as `models.ListingStatus` declares them.
80
+ *
81
+ * A runtime array and not only a type, because the dashboard's own table of
82
+ * expected captions is asserted over it — a status added upstream turns
83
+ * `test/status.test.ts` red instead of rendering as a bare enum value.
84
+ */
85
+ export const LISTING_STATUSES: readonly ListingLifecycleStatus[] = [
86
+ "draft",
87
+ "pending",
88
+ "published",
89
+ "paused",
90
+ "expired",
91
+ "sold",
92
+ "rejected",
93
+ "blocked",
94
+ "archived",
95
+ ];
96
+
97
+ /** One lifecycle state. THE field that decides public visibility — and the
98
+ * only one (`models.py`: "no visibility-reads-moderation_status coupling"). */
99
+ export type ListingLifecycleStatus = Schemas["StatusD41Enum"];
100
+
101
+ /** The four content-moderation states, as `models.ModerationStatus` declares
102
+ * them. Independent of the lifecycle: see `model/status.ts`. */
103
+ export const MODERATION_STATUSES: readonly ListingModerationStatus[] = [
104
+ "pending",
105
+ "approved",
106
+ "rejected",
107
+ "needs_review",
108
+ ];
109
+
110
+ /** One moderation state. Decides NOTHING about visibility. */
111
+ export type ListingModerationStatus = Schemas["ModerationStatusEnum"];
112
+
113
+ /**
114
+ * The ten value types, straight out of the generated discriminated union.
115
+ *
116
+ * Not a hand-written list: `Schemas["FeatureDao"]["type"]` is the union of the
117
+ * ten `discriminator.mapping` keys, so a type registered upstream widens this
118
+ * automatically and a type REMOVED upstream reddens every exhaustive switch
119
+ * over it. That is only true because 0.6.1 fixed the mapping (header, note 1).
120
+ */
121
+ export type ListingFeatureType = Schemas["FeatureDao"]["type"];
122
+
123
+ /** The generated union itself, for a consumer that wants to narrow a row
124
+ * exhaustively by `type`. `featureFromDao` deliberately does not — it hands
125
+ * the whole row to `@stapel/attributes-react`, which switches on the same
126
+ * discriminant at runtime and is the one place that knows what each type's
127
+ * `value` means. */
128
+ export type ListingFeatureDaoUnion = Schemas["FeatureDao"];
129
+
130
+ /**
131
+ * One stored feature projection, as it actually arrives.
132
+ *
133
+ * The DAO is the value TOGETHER with the display configuration its type
134
+ * needs — `prefix`, `postfix`, `precision`, `trueLabel`, `maxSelected`, … all
135
+ * ride along beside `value`. That is why a card can render a badge without
136
+ * fetching the category schema: everything `formatFeatureValue` reads is in
137
+ * the row. `featureFromDao` (`model/features.ts`) is the one place that
138
+ * splits it back into the `(FeatureDef, FeatureValueDto)` pair
139
+ * `@stapel/attributes-react` formats.
140
+ *
141
+ * `slug` is declared REQUIRED here even though the schema omits it — see this
142
+ * file's header, note 2. A DAO without one is a stored row this build cannot
143
+ * key, and `featureFromDao` says so rather than inventing an index. The index
144
+ * signature is the other half of the same fact: the field is a `JSONField`,
145
+ * so what the row carries is what was stored, not what the union describes.
146
+ */
147
+ export interface ListingFeatureDao {
148
+ readonly slug: string;
149
+ /** The VALUE type slug — the axis `@stapel/attributes-react` switches on.
150
+ * Typed from the generated union, optional because a malformed stored row
151
+ * can lack it and a JSONField will pass that through. */
152
+ readonly type?: ListingFeatureType;
153
+ readonly value?: unknown;
154
+ /** Display name or translation key; falls back to the slug. */
155
+ readonly name?: string | null;
156
+ readonly order?: number | null;
157
+ /** Part of the listing's title line. */
158
+ readonly title?: boolean | null;
159
+ /** Rendered as a badge on the card. */
160
+ readonly badge?: boolean | null;
161
+ readonly translate?: string | null;
162
+ /** The type's own config keys (`prefix`, `precision`, `unitType`, …). */
163
+ readonly [key: string]: unknown;
164
+ }
165
+
166
+ /** The `(definition, value)` pair `@stapel/attributes-react` formats. */
167
+ export interface ListingFeatureView {
168
+ readonly feature: FeatureDef;
169
+ readonly value: FeatureValueDto | undefined;
170
+ }
171
+
172
+ /** Re-exported so a consumer types a feature config without also depending on
173
+ * `@stapel/attributes-react` directly. */
174
+ export type { FeatureConfig, FeatureDef, FeatureValueDto };
175
+
176
+ /** Query parameters of both keyset-paginated card lists. */
177
+ export interface ListingPageParams {
178
+ /** Opaque cursor from a previous answer. */
179
+ readonly anchor?: string;
180
+ readonly direction?: "next" | "prev" | "center";
181
+ readonly limit?: number;
182
+ }
183
+
184
+ /**
185
+ * The currency a composer starts a new listing in.
186
+ *
187
+ * RUB by owner verdict F6 (storefront spec, the owner's
188
+ * fork verdicts of 2026-08-22), and a default rather than a constant: `currency` is
189
+ * a free `maxLength: 8` string on the wire (stapel-listings has no currency
190
+ * enum — the vocabulary lives in stapel-currencies), so a deployment that
191
+ * sells in something else passes its own through `createListingsRuntime`.
192
+ */
193
+ export const DEFAULT_LISTING_CURRENCY = "RUB";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The one error surface this pair's default skin renders, so the split copy
3
+ * core produces reaches a screen the same way everywhere (mirrors the
4
+ * docs/notifications/workspaces etalon).
5
+ *
6
+ * Core's `describeFlowError` splits a failure into the sentence a person
7
+ * reads and the technical detail a support agent quotes (owner report
8
+ * 2026-08-09: the status used to be spliced into the sentence, ending in a
9
+ * bare `" (500)"`). The split only helps if a skin renders both halves at
10
+ * their own weights: `message` at normal weight, `detail` muted and small.
11
+ *
12
+ * `undefined` in — including a `detail` core left `undefined` because there
13
+ * was nothing worth quoting — renders nothing rather than an empty line.
14
+ */
15
+ import { Alert, Typography } from "antd";
16
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
17
+ import type { FlowErrorDisplay } from "@stapel/core";
18
+
19
+ export function ErrorAlert(props: {
20
+ error: FlowErrorDisplay | undefined;
21
+ style?: CSSProperties | undefined;
22
+ testId?: string | undefined;
23
+ action?: ReactNode;
24
+ }): ReactElement | null {
25
+ const { error } = props;
26
+ if (!error) return null;
27
+ return (
28
+ <Alert
29
+ type="error"
30
+ showIcon
31
+ {...(props.style ? { style: props.style } : {})}
32
+ {...(props.testId ? { "data-testid": props.testId } : {})}
33
+ {...(props.action ? { action: props.action } : {})}
34
+ message={error.message}
35
+ {...(error.detail
36
+ ? {
37
+ description: (
38
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
39
+ {error.detail}
40
+ </Typography.Text>
41
+ ),
42
+ }
43
+ : {})}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `<FavoritesPane>` — what the person saved.
3
+ *
4
+ * The simplest screen in the pair, and the one that shows the load-state
5
+ * discipline at its plainest: four arms, four different things on screen.
6
+ * "You have not saved anything yet" and "we could not load your favourites"
7
+ * are the pair a `data ?? []` would have merged, and merging them is exactly
8
+ * the substitution that cost the fleet an incident (spec §7.4).
9
+ */
10
+ import type { ReactElement } from "react";
11
+ import { Alert, Button, Empty, Flex, Space, Spin, Typography } from "antd";
12
+ import { matchList, useDescribeFlowError, useT } from "@stapel/core";
13
+ import { useFavorites } from "../headless/Favorites.js";
14
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
15
+ import { ErrorAlert } from "./ErrorAlert.js";
16
+ import { ListingCard } from "./ListingCard.js";
17
+ import { ListingsSkinTheme } from "./theme.js";
18
+ import type { ThemeModeProp } from "./types.js";
19
+
20
+ export interface FavoritesPaneProps extends ThemeModeProp {
21
+ /** Where a card leads. The container owns routing. */
22
+ readonly hrefFor?: (id: number) => string;
23
+ readonly onOpen?: (id: number) => void;
24
+ }
25
+
26
+ export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
27
+ const t = useT();
28
+ const describe = useDescribeFlowError();
29
+ const bag = useFavorites();
30
+
31
+ return (
32
+ <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
33
+ <Flex vertical gap={16} data-testid="listings-favorites">
34
+ <Typography.Title level={3}>
35
+ {t(LISTINGS_I18N_KEYS.favoritesTitle)}
36
+ </Typography.Title>
37
+
38
+ {!bag.gate.available ? (
39
+ <Alert
40
+ type="info"
41
+ showIcon
42
+ data-testid="listings-favorites-blocked"
43
+ message={t(bag.gate.block.code, bag.gate.block.params)}
44
+ />
45
+ ) : null}
46
+
47
+ {matchList(bag.rows, {
48
+ loading: () => (
49
+ <Flex justify="center" data-testid="listings-favorites-loading">
50
+ <Spin aria-label={t(LISTINGS_I18N_KEYS.favoritesLoading)} />
51
+ </Flex>
52
+ ),
53
+ failed: () => (
54
+ <ErrorAlert
55
+ testId="listings-favorites-error"
56
+ error={describe({
57
+ code: LISTINGS_I18N_KEYS.favoritesLoadFailed,
58
+ params: {},
59
+ status: 0,
60
+ message: undefined,
61
+ language: undefined,
62
+ })}
63
+ action={
64
+ <Button
65
+ size="small"
66
+ data-analytics="none"
67
+ data-analytics-reason="retrying a read the person already asked for; not a business action"
68
+ onClick={bag.refetch}
69
+ >
70
+ {t(LISTINGS_I18N_KEYS.mineRetry)}
71
+ </Button>
72
+ }
73
+ />
74
+ ),
75
+ empty: () => (
76
+ <Empty
77
+ data-testid="listings-favorites-empty"
78
+ description={t(LISTINGS_I18N_KEYS.favoritesEmpty)}
79
+ />
80
+ ),
81
+ ready: (rows) => (
82
+ <Flex wrap gap={16} data-testid="listings-favorites-grid">
83
+ {rows.map((row) => (
84
+ <div key={row.id} style={{ width: 240 }}>
85
+ <ListingCard
86
+ listing={row}
87
+ {...(props.hrefFor !== undefined
88
+ ? { href: props.hrefFor(row.id) }
89
+ : {})}
90
+ {...(props.onOpen !== undefined ? { onOpen: props.onOpen } : {})}
91
+ />
92
+ </div>
93
+ ))}
94
+ </Flex>
95
+ ),
96
+ })}
97
+
98
+ <Space>
99
+ <Button
100
+ size="small"
101
+ disabled={!bag.prevPage.available}
102
+ data-testid="listings-favorites-prev"
103
+ data-analytics="none"
104
+ data-analytics-reason="paging a list the person is already reading; not a business action"
105
+ onClick={bag.goPrev}
106
+ >
107
+ {t(LISTINGS_I18N_KEYS.pagePrev)}
108
+ </Button>
109
+ <Button
110
+ size="small"
111
+ disabled={!bag.nextPage.available}
112
+ data-testid="listings-favorites-next"
113
+ data-analytics="none"
114
+ data-analytics-reason="paging a list the person is already reading; not a business action"
115
+ onClick={bag.goNext}
116
+ >
117
+ {t(LISTINGS_I18N_KEYS.pageNext)}
118
+ </Button>
119
+ </Space>
120
+ </Flex>
121
+ </ListingsSkinTheme>
122
+ );
123
+ }