@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/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ # @stapel/listings-react
package/MODULE.md ADDED
@@ -0,0 +1,191 @@
1
+ # @stapel/listings-react — module guide
2
+
3
+ Pairs with **stapel-listings 0.6.1** (`>=0.6 <0.7`), 12 paths under
4
+ `/listings/api/v1/`. Contract sources: the module's own
5
+ `docs/{schema,errors,flows}.json`, pinned in `contract-pins.json` and
6
+ regenerated under `pnpm gen:check`.
7
+
8
+ ## Layers
9
+
10
+ ```
11
+ src/api/ listingsApi.ts the 14 operations a storefront calls; the one home of path strings
12
+ types.ts wire aliases + the one hand-mirrored shape (the stored DAO)
13
+ generated/ openapi-typescript, drift-gated
14
+ src/model/ status.ts the two axes → one caption, 9 × 4, asserted
15
+ transitions.ts the server's whitelist, mirrored (UX only; the 409 is the verdict)
16
+ draft.ts the *_draft twin ↔ the composer's values
17
+ features.ts the stored DAO projection → what attributes-react formats
18
+ validation.ts the mirror, and the split of the two publish 400s
19
+ mineSource.ts THE GAP: no owner-scoped list endpoint exists
20
+ runtime · context · queryKeys · queries · mutations
21
+ src/flows/ registry.ts zero-flow shim (the module annotates no @flow_step)
22
+ src/headless/ ListingsProvider · ListingDetail · ListingComposer · MyListings
23
+ Favorites · ListingActions · useMandateGate
24
+ src/default/ the antd skin, `./default` subpath
25
+ src/i18n/ keys · ru · es · errorsMap + generated/
26
+ src/nav/ manifest.ts
27
+ ```
28
+
29
+ Everything in `model/` except the hooks is pure — no React, no fetch — which
30
+ is what lets the status table, the draft conversions and the mirror be tested
31
+ without a DOM, and what lets an SSR render call them directly.
32
+
33
+ ## 1. Two axes, and the divergence 0.5.0 introduced
34
+
35
+ `status` (nine states) is the lifecycle and the ONLY thing that decides public
36
+ visibility: `Listing.objects.published()` filters on it alone,
37
+ `INDEXED_STATUSES` is `{published}`, and the model says so out loud ("no
38
+ visibility-reads-moderation_status coupling", `models.py`).
39
+ `moderation_status` (four states) is the content verdict and decides nothing
40
+ about visibility.
41
+
42
+ ```
43
+ first publication status draft → PENDING, moderation → pending
44
+ nothing public until a verdict arrives
45
+
46
+ editing a LIVE listing status published (UNCHANGED), moderation → pending
47
+ the edit is visible immediately; a rejecting verdict
48
+ later lands as PUBLISHED → BLOCKED
49
+ ```
50
+
51
+ `model/status.ts` produces both halves of the sentence from BOTH fields, once.
52
+ `test/status.test.ts` walks all 36 combinations, and names the four that look
53
+ contradictory. `liveUnderReview` is a named boolean rather than an inference a
54
+ caller repeats.
55
+
56
+ The moderation axis is shown to the OWNER and to nobody else: a buyer has no
57
+ use for "changes under review", and showing a stranger that a listing was
58
+ refused would leak a verdict about someone else's content.
59
+
60
+ ## 2. Contract deltas — what the schema does not say
61
+
62
+ ### 2.1 The discriminator was broken in 0.6.0 and is fixed in 0.6.1
63
+
64
+ 0.6.0 declared `discriminator: {propertyName: "type", mapping: {"null":
65
+ ConvertibleUnitDao}}` — one bogus entry instead of the ten type slugs — and
66
+ openapi-typescript answers that by stripping `type` from every member and
67
+ re-adding a synthetic discriminant, so the generated `IntDao` said
68
+ `type: "IntDao"` where the wire sends `"int"`. `@stapel/categories-react` hit
69
+ the same defect and routed around it through attributes-react's hand-mirrored
70
+ types. **0.6.1 emits the ten slugs**, so this pair's `Schemas["FeatureDao"]`
71
+ and `Schemas["FeatureDto"]` discriminate on the real values and ARE the wire
72
+ types it uses; `ListingFeatureType` is derived from the union rather than
73
+ hand-listed, so a type added upstream widens it automatically.
74
+
75
+ What still comes from `@stapel/attributes-react` is the BEHAVIOUR — the value
76
+ editors, the client mirror, the formatter — which is the spec's L0 seam and
77
+ never was a workaround.
78
+
79
+ ### 2.2 A stored DAO carries `slug`; the schema's `FeatureDao` does not
80
+
81
+ `features` / `features_title` / `features_badges` are
82
+ `ListingFeaturesOutputField`, a plain `serializers.JSONField` whose OpenAPI
83
+ *description* is swapped for the DAO union by an extension. A JSONField
84
+ filters nothing: what reaches the wire is what `build_features_list` stored,
85
+ and that is `{**dao, "slug": slug}` (`services/features.py`). The slug is
86
+ load-bearing — it is how a card keys a badge and how a refusal finds its
87
+ control — so `ListingFeatureDao` mirrors the runtime shape and says why.
88
+
89
+ A row WITHOUT a slug is treated as malformed and counted
90
+ (`unreadableFeatureCount`), never silently dropped: a synthesized index would
91
+ key a badge to a position that moves whenever the category does.
92
+
93
+ ### 2.3 The DAO carries its type's config inline — and that is the good news
94
+
95
+ `prefix`, `postfix`, `precision`, `trueLabel`, `maxSelected`, … all ride along
96
+ beside `value`, which is why `<ListingCard>` can format "1200 W" from the row
97
+ alone. A grid of forty cards costs one query and no category read. The one
98
+ thing a DAO does not carry is `select`'s `options` table, so an option value
99
+ falls back to its raw form — which is a translation KEY when the config is
100
+ translatable (the default), so a host whose bundle carries the catalogue's
101
+ copy still reads a word.
102
+
103
+ ### 2.4 The publish 400 has two shapes
104
+
105
+ An invalid draft answers a BARE `ValidationBatchResult` (no
106
+ `localizable_error`, no envelope), which core wraps as `stapel.http.400` with
107
+ the batch on `StapelApiError.body`. A promotion that then fails
108
+ (`REQUIRE_IMAGE_ON_PUBLISH` with no photo) answers the ordinary
109
+ `error.400.publish_validation_failed`. `publishRefusal` branches on the BODY,
110
+ not the status: a caller that branched on `status === 400` alone would put a
111
+ sentence under a feature control.
112
+
113
+ ### 2.5 The description's refusal is already keyed like a control
114
+
115
+ `services.publish.validate_draft` inserts `validate_description`'s row at the
116
+ front of the same list, and that row's slug is literally `"description"`
117
+ (`stapel_attributes/validation.py:726`) — the same key the mirror files its
118
+ own length refusal under. One routing table covers both.
119
+
120
+ ## 3. Upstream asks
121
+
122
+ | # | Ask | Why it matters here |
123
+ |---|---|---|
124
+ | 1 | An owner-scoped list (`?owner=me`, or a `my/listings` action) | Without it a seller cannot be shown their own drafts by any call the contract offers. Today: an injected `MyListingsSource`, and a named failure when there is none. `src/model/mineSource.ts`. |
125
+ | 2 | A read that returns the `*_draft` twin | `GET /{pk}/` serializes the PUBLISHED fields only, so a draft abandoned and reopened later comes back empty. Editing a live listing is unaffected. |
126
+ | 3 | `_get_own` in front of `update` / `partial_update` | Both are the plain `ModelViewSet` implementations under `IsAuthenticatedOrReadOnly` over `Listing.objects.all()`: **any authenticated caller can write any listing's draft fields through `PUT`/`PATCH`.** Every other owner operation checks ownership. This pair declines both and uses `save-draft`, which performs the same write with the check — so nothing is lost, but the endpoints remain reachable by anything else that speaks the contract. |
127
+ | 4 | A `published()` filter (or an owner check) on `retrieve` | The detail endpoint answers 200 for a draft, a rejected and a blocked listing to anyone holding the id. The pair reports `publiclyVisible` from `status` and says which situation the reader is in, but it cannot stop the read. |
128
+ | 5 | A public read-by-reference for stored images | `Listing.images` is opaque `<type>/<hash>` and nothing in this fleet resolves a stranger's reference (stapel-cdn's `file/exists/` is owner-scoped). Today: a host-supplied `resolveImage`. |
129
+ | 6 | `slug` declared on `FeatureDao` | See §2.2 — it is on the wire and absent from the schema. |
130
+
131
+ ## 4. The three seams, and why none of them is an import
132
+
133
+ `@stapel/cdn-react` and `@stapel/categories-react` are L2 pairs, and L2 pairs
134
+ never import each other (the monorepo README states the direction). So:
135
+
136
+ - **the gallery** is a structural `ListingImagesBag` — two members, satisfied
137
+ by `useUploadQueue()`'s bag. `bag.refs` IS the value of `images_draft` (same
138
+ order, first tile the cover) and `bag.settled` is the submit gate. That pair
139
+ wrote its bag to this contract on purpose (its §13.6 note 9);
140
+ - **the category schema** is a plain `readonly FeatureDef[]`, plus a
141
+ `categorySlot` for the chooser;
142
+ - **a stored image reference** is resolved by a host-supplied
143
+ `ListingImageResolver`.
144
+
145
+ `@stapel/attributes-react` IS a dependency; it is L0, like `@stapel/image`.
146
+
147
+ ## 5. Gates and their reasons
148
+
149
+ Every write in the pair is behind an `ActionAvailability`. The mandate axis is
150
+ read through core's `MandateSource` seam — never derived here, because a
151
+ storefront's derivation is "is there a session?" and a tenant app's is
152
+ `@stapel/workspaces-react`'s. `matchMandate` has five required arms, so the
153
+ two `unresolved` outcomes cannot fall into the refusal's branch: "we have not
154
+ finished asking" and "we could not ask" are both distinct from "you may not".
155
+
156
+ The lifecycle mirror (`model/transitions.ts`) is a COPY of
157
+ `LISTING_TRANSITIONS`, not a summary of it, and it may never block what the
158
+ server would allow. The 409 stays the verdict, rendered as the named refusal
159
+ it is with `params.from_status` in the sentence.
160
+
161
+ ## 6. Locales
162
+
163
+ `stapel-listings` ships no `translations/` directory, so 21 of the 63 registry
164
+ codes have no upstream catalogue. They split by owner: 42 cross-cutting
165
+ `stapel_core` codes are generated from core's catalogue, the 9
166
+ `stapel_listings` codes are authored in `src/i18n/{ru,es}.ts` (nine lines to
167
+ delete when upstream localizes), and the 12 `stapel_attributes` codes are
168
+ deliberately left to `@stapel/attributes-react`. `test/i18n.test.ts` asserts
169
+ over the UNION of the two bundles a host actually registers, so nobody can
170
+ make it pass by copying the other package's keys in.
171
+
172
+ ## 7. Tests
173
+
174
+ 142 in 8 files (138 under `test`, 4 under `test:pack`). The ones that carry
175
+ the weight:
176
+
177
+ - `status.test.ts` — the 9 × 4 table, the tab grouping (including BLOCKED,
178
+ which `my_counters` counts in no tab at all), and the transition mirror;
179
+ - `draft.test.ts` — the payload, in both directions: type tagging from the
180
+ category schema, blanks omitted rather than nulled, the countable/stock
181
+ cross-field rule, code-point length, the two kinds of publish 400;
182
+ - `compose.test.tsx` — the submission against the wire: the row is created
183
+ with the category and nothing else, the gallery's refs go out in order, the
184
+ save happens BEFORE the publish, a `ValidationBatchResult` lands on the
185
+ control that caused it, and the two publish outcomes are told apart by what
186
+ the server answered;
187
+ - `detail.test.tsx` — four absences and four sentences, the owner-only
188
+ moderation axis, and the favourite control blocked-but-visible for a
189
+ visitor;
190
+ - `mine.test.tsx` — the named gap, real counters beside it, and no badge at
191
+ all for a count that failed to load.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @stapel/listings-react
2
+
3
+ The frontend pair for **stapel-listings**: the listing page, the submission
4
+ flow, and the seller's dashboard.
5
+
6
+ Two facts about this module shape the whole package, and both are easy to get
7
+ subtly wrong:
8
+
9
+ 1. **A listing has two independent state axes.** `status` decides whether
10
+ anyone can see it; `moderation_status` decides nothing about that. Since
11
+ stapel-listings 0.5.0 an edit to a LIVE listing keeps `status: published`
12
+ and moves only the moderation axis — so "published, and we are reviewing
13
+ your changes" is a real state, and a screen that computed either field from
14
+ the other would either hide a listing buyers are reading or never tell its
15
+ owner their edit is being screened.
16
+ 2. **A publish refusal is per-field, and it arrives in an unusual envelope.**
17
+ `POST /{pk}/publish/` answers an invalid draft with a bare
18
+ `ValidationBatchResult` — no `localizable_error` — while a promotion that
19
+ fails afterwards answers the ordinary one. Two 400s, two meanings.
20
+
21
+ Business + state in the main entry, zero visual opinion; the antd skin lives
22
+ behind `./default`. Built on `@stapel/core` (typed client + `StapelApiError`
23
+ envelope, `LoadState`, `ActionAvailability`, the mandate seam, i18n engine,
24
+ TanStack Query), `@stapel/attributes-react` (the value editors, the client
25
+ mirror and the display formatter) and `@stapel/image` (the variant ladder).
26
+
27
+ ## Install
28
+
29
+ ```
30
+ pnpm add @stapel/listings-react @stapel/core @stapel/attributes-react @stapel/image @tanstack/react-query react
31
+ # for the default skin:
32
+ pnpm add antd @stapel/tokens-antd
33
+ ```
34
+
35
+ ## A listing page, in six lines
36
+
37
+ ```tsx
38
+ import { createListingsRuntime, ListingsProvider } from "@stapel/listings-react";
39
+ import { ListingDetailPane } from "@stapel/listings-react/default";
40
+
41
+ const runtime = createListingsRuntime({
42
+ baseUrl: "/listings/api/v1/",
43
+ resolveImage: (ref) => myCdn.describe(ref), // see "Photos" below
44
+ });
45
+
46
+ export function ListingRoute({ id }: { id: number }) {
47
+ return (
48
+ <ListingsProvider runtime={runtime}>
49
+ <ListingDetailPane id={id} />
50
+ </ListingsProvider>
51
+ );
52
+ }
53
+ ```
54
+
55
+ ## The card is a slot, not an import
56
+
57
+ A marketplace's result grid goes through `@stapel/search-react`, because
58
+ `promoted` (DSA Art. 26) rides every search item under every sort and a card
59
+ list that carried the marking on some pages and not others would be worse than
60
+ one that never claimed it. The two pairs never import each other — the
61
+ container is the seam:
62
+
63
+ ```tsx
64
+ <SearchPage renderCard={(item) => <ListingCard listing={item.card} href={`/l/${item.id}`} />} />
65
+ ```
66
+
67
+ `<ListingCard>` renders its badges from `features_badges`, a stored projection
68
+ that carries each type's display config beside its value. A grid of forty
69
+ cards therefore costs one query and **no category read**.
70
+
71
+ ## Submitting a listing
72
+
73
+ Four contracts meet on the composer, and three of them arrive as seams rather
74
+ than dependencies:
75
+
76
+ | what | how it arrives | why |
77
+ |---|---|---|
78
+ | the category's schema | `features: FeatureDef[]` | `@stapel/categories-react` is an L2 pair; L2 pairs do not import each other |
79
+ | the photos | `images` — two members of `@stapel/cdn-react`'s upload bag | same rule; `bag.refs` IS `images_draft` and `bag.settled` is the submit gate |
80
+ | the value editors | `@stapel/attributes-react` | L0, so a direct dependency |
81
+
82
+ ```tsx
83
+ const gallery = useUploadQueue({ max: 10 }); // @stapel/cdn-react
84
+ const features = useCategoryFeatures(categoryId); // @stapel/categories-react
85
+
86
+ <ListingComposerPage
87
+ features={features}
88
+ images={gallery}
89
+ categorySlot={<CategoryPickerField value={categoryId} onChange={setCategoryId} />}
90
+ gallerySlot={<MediaGalleryField bag={gallery} />}
91
+ />
92
+ ```
93
+
94
+ The flow is `create draft → save into it → publish`. The composer always saves
95
+ before it publishes, because `publish` promotes the STORED draft: publishing
96
+ without saving would promote whatever was there before the last keystroke.
97
+
98
+ ### Every switched-off publish button says which of six reasons it is
99
+
100
+ sign in · choose a category · we could not load what this category asks for ·
101
+ this build cannot draw one of these details · wait for the photos · fix the
102
+ highlighted fields. They are ordered the way a person would be told, and the
103
+ reason is rendered beside the button.
104
+
105
+ ## Photos need a resolver, and the pair says so
106
+
107
+ `Listing.images` is a list of opaque CDN references (`<type>/<hash>`), and
108
+ **no contract in this fleet resolves a stranger's reference**: there is no
109
+ public read-by-reference in stapel-listings, and stapel-cdn's `file/exists/`
110
+ is owner-scoped. So the runtime takes a `resolveImage` from the deployment,
111
+ which knows where its CDN serves from, and a pane without one says "photos
112
+ cannot be shown here" instead of drawing a broken `<img>`. Inventing
113
+ `${cdnBase}/${ref}` would be writing a contract nobody agreed to.
114
+
115
+ ## What this pair cannot do, and why
116
+
117
+ **List your own listings.** stapel-listings 0.6.1 has no owner-scoped list
118
+ endpoint: `GET /listings/` answers `published()` and takes no owner parameter.
119
+ The counters (`my/counters`) are real and are shown; the rows come from an
120
+ injected `MyListingsSource`, and with none the dashboard reports a NAMED
121
+ failure rather than an empty grid — "we cannot ask" and "you have no listings"
122
+ are different sentences. See `src/model/mineSource.ts` for the upstream asks.
123
+
124
+ **Reopen an abandoned draft.** No read returns the `*_draft` twin:
125
+ `GET /{pk}/` serializes the published fields. Editing a LIVE listing works
126
+ completely (the published half IS the listing); a draft reopened in a later
127
+ session comes back empty and the composer says so.
128
+
129
+ **Write through `PUT` / `PATCH`.** They are on the contract and absent from
130
+ `ListingsApi`. Every other owner operation routes through `views._get_own`;
131
+ these two are the plain `ModelViewSet` implementations under
132
+ `IsAuthenticatedOrReadOnly` over `Listing.objects.all()`, so any authenticated
133
+ caller can write any listing's draft fields through them.
134
+ `POST /{pk}/save-draft/` performs the same write WITH the ownership check, so
135
+ the pair uses that and nothing is lost.
136
+
137
+ ## Layers
138
+
139
+ ```
140
+ src/api/ listingsApi.ts · types.ts · generated/schema.ts
141
+ src/model/ status · transitions · draft · features · validation · mineSource
142
+ runtime · context · queryKeys · queries · mutations
143
+ src/flows/ registry.ts (zero-flow shim — the module annotates none)
144
+ src/headless/ ListingsProvider · ListingDetail · ListingComposer · MyListings
145
+ Favorites · ListingActions · useMandateGate
146
+ src/default/ the antd skin, `./default` subpath
147
+ src/i18n/ keys · ru · es · errorsMap + generated/
148
+ src/nav/ manifest.ts
149
+ ```
150
+
151
+ `model/` is pure apart from the hooks: the status table, the draft
152
+ conversions, the mirror and the publish-400 split are all plain functions, so
153
+ the parts worth getting right are tested without a DOM.
154
+
155
+ ## Locales
156
+
157
+ English is inline. `./i18n/ru` and `./i18n/es` are opt-in subpaths and carry
158
+ the UI copy as well as the nine `stapel_listings` error keys — the storefront
159
+ is ru-first (owner verdict F1), and a half-translated submission form is
160
+ visible immediately. The twelve `stapel_attributes` keys are deliberately NOT
161
+ here: `@stapel/attributes-react` owns and translates them, and one refusal must
162
+ not have two sentences.
163
+
164
+ ## Documentation
165
+
166
+ - `MODULE.md` — the module guide: the two axes in full, the contract deltas,
167
+ the upstream asks.
168
+ - `manifest.json` / `llms.txt` — generated self-description, drift-gated.
169
+ - `nav-manifest.json` — the four routes this pair contributes.