@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,122 @@
1
+ /**
2
+ * The stored DAO projection → something `@stapel/attributes-react` can draw.
3
+ *
4
+ * A listing carries its attribute values THREE times, all of them DAO lists:
5
+ * `features` (everything, ordered), `features_title` (the ones flagged for
6
+ * the title line) and `features_badges` (the ones flagged for the card). A
7
+ * DAO is the value together with the display configuration its type needs —
8
+ * `prefix`, `precision`, `trueLabel`, `unitType`, … all inline beside
9
+ * `value` — which is the single most useful property of this contract:
10
+ *
11
+ * **a card can render its badges without ever fetching the category.**
12
+ *
13
+ * `formatFeatureValue` wants a `(FeatureDef, FeatureValueDto)` pair, so this
14
+ * module splits a DAO back into one. The split is mechanical, and it is
15
+ * written down once here rather than at each of the three call sites.
16
+ *
17
+ * ── Where the config comes from, and why the whole row is handed over ──────
18
+ *
19
+ * `FeatureConfig` has an index signature, so passing the DAO itself as the
20
+ * config costs nothing and loses nothing: the type-specific keys are already
21
+ * at the top level of the row, and the four presentation flags (`name`,
22
+ * `order`, `title`, `badge`) are simply ignored by every formatter. Copying a
23
+ * hand-picked subset instead would be a list to keep in step with ten types.
24
+ *
25
+ * ── What a DAO does NOT carry ──────────────────────────────────────────────
26
+ *
27
+ * `select`'s `options`. `normalize_to_dao` stores the chosen VALUES, not the
28
+ * option table, so `formatFeatureValue` falls back to the raw option value —
29
+ * which is a translation KEY when the config is translatable (the default),
30
+ * so a host whose bundle carries the catalogue's option copy still reads a
31
+ * word. A host whose bundle does not sees the key, on purpose: a visible
32
+ * `option.condition.used` gets fixed, an invented "Used" ships wrong.
33
+ */
34
+ import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
35
+ import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
36
+
37
+ /** Keys that belong to the DAO envelope rather than to the type's config. */
38
+ const ENVELOPE = new Set(["slug", "value", "name", "order", "title", "badge"]);
39
+
40
+ /**
41
+ * One DAO row → the definition/value pair the display half consumes.
42
+ *
43
+ * `undefined` for a row with no `slug`: the stored projection always injects
44
+ * one (`build_features_list`: `{**dao, "slug": slug}`), so a row without it
45
+ * is a malformed record, and a synthesized index would key a badge to a
46
+ * position that changes whenever the category does.
47
+ */
48
+ export function featureFromDao(
49
+ dao: ListingFeatureDao
50
+ ): ListingFeatureView | undefined {
51
+ if (typeof dao.slug !== "string" || dao.slug.length === 0) return undefined;
52
+
53
+ const config: Record<string, unknown> = {};
54
+ for (const [key, value] of Object.entries(dao)) {
55
+ if (!ENVELOPE.has(key)) config[key] = value;
56
+ }
57
+
58
+ const feature: FeatureDef = {
59
+ slug: dao.slug,
60
+ config,
61
+ name: dao.name ?? null,
62
+ ...(dao.translate !== undefined ? { translate: dao.translate } : {}),
63
+ };
64
+
65
+ const value: FeatureValueDto | undefined =
66
+ typeof dao.type === "string" && dao.type.length > 0
67
+ ? { type: dao.type, value: dao.value }
68
+ : undefined;
69
+
70
+ return { feature, value };
71
+ }
72
+
73
+ /** A stored projection → the pairs a display component iterates. Malformed
74
+ * rows are dropped HERE, in one place, and counted by {@link
75
+ * unreadableFeatureCount} so a skin can say how many rather than pretend the
76
+ * listing had fewer attributes. */
77
+ export function featuresFromDaoList(
78
+ daos: readonly ListingFeatureDao[] | null | undefined
79
+ ): readonly ListingFeatureView[] {
80
+ const out: ListingFeatureView[] = [];
81
+ for (const dao of daos ?? []) {
82
+ const view = featureFromDao(dao);
83
+ if (view !== undefined) out.push(view);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** How many rows of a stored projection this build could not key. */
89
+ export function unreadableFeatureCount(
90
+ daos: readonly ListingFeatureDao[] | null | undefined
91
+ ): number {
92
+ return (daos ?? []).length - featuresFromDaoList(daos).length;
93
+ }
94
+
95
+ /**
96
+ * A stored projection → the `{slug: {type, value}}` envelope, which is what
97
+ * a composer reopening a PUBLISHED listing needs: `draftValuesFromDetail`
98
+ * seeds its editors from the published values, and the published values live
99
+ * only in the DAO list.
100
+ */
101
+ export function featuresDtoFromDaoList(
102
+ daos: readonly ListingFeatureDao[] | null | undefined
103
+ ): Readonly<Record<string, FeatureValueDto>> {
104
+ const out: Record<string, FeatureValueDto> = {};
105
+ for (const view of featuresFromDaoList(daos)) {
106
+ if (view.value !== undefined) out[view.feature.slug] = view.value;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * The `features` field of a detail response, typed.
113
+ *
114
+ * The generated `FeatureDao` union is unusable (the discriminator defect —
115
+ * see `api/types.ts`), and `features` is a `JSONField` on the serializer, so
116
+ * this cast is the boundary between "the schema's description" and "the row
117
+ * the JSONField actually emitted". It lives in ONE function so there is one
118
+ * place to change when upstream fixes the mapping.
119
+ */
120
+ export function asFeatureDaoList(value: unknown): readonly ListingFeatureDao[] {
121
+ return Array.isArray(value) ? (value as readonly ListingFeatureDao[]) : [];
122
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * THE GAP: stapel-listings cannot list a person their own listings.
3
+ *
4
+ * This is the one place in the pair where a contract is missing rather than
5
+ * awkward, so it gets its own module and its own name.
6
+ *
7
+ * `GET /listings/` answers `qs.published()` and takes no owner parameter
8
+ * (`views.ListingViewSet.get_queryset`): it returns the whole marketplace's
9
+ * shop window and can be narrowed to nobody. The only owner-scoped reads in
10
+ * the module are `my/counters` (three integers) and `my/favorites`. So a
11
+ * seller's own DRAFTS are unreachable by any call this contract offers — and
12
+ * drafts are not indexed either, so routing around it through
13
+ * `@stapel/search-react`'s `owner=` would return the published subset and
14
+ * quietly call it "everything".
15
+ *
16
+ * The storefront spec assumed `GET /listings/` was "for mine" (§4.1). It is
17
+ * not, and this pair records the gap rather than papering over it.
18
+ *
19
+ * Two upstream asks, in preference order:
20
+ * 1. an `?owner=me` filter (or a `my/listings` action) on the list endpoint,
21
+ * with the same `IDAnchorPagination` envelope the other two use;
22
+ * 2. failing that, a `status` filter on it, so at least the published subset
23
+ * can be narrowed without a search module.
24
+ *
25
+ * Until then a host injects a {@link MyListingsSource} and the dashboard
26
+ * works completely; without one it shows the real counters and NAMES the
27
+ * absence, because "we cannot ask" and "you have no listings" are different
28
+ * sentences.
29
+ */
30
+ import { StapelApiError } from "@stapel/core";
31
+ import type { ListingPageParams, PaginatedListingCards } from "../api/types.js";
32
+ import type { MyListingsTab } from "./status.js";
33
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
34
+
35
+ /** One page of the caller's own listings, however the host can get them. */
36
+ export type MyListingsSource = (args: {
37
+ readonly tab: MyListingsTab;
38
+ readonly page: ListingPageParams;
39
+ readonly signal?: AbortSignal;
40
+ }) => Promise<PaginatedListingCards>;
41
+
42
+ /**
43
+ * The error a dashboard without a source reports.
44
+ *
45
+ * `status: 0` on purpose: this is a fact about the deployment's wiring, not
46
+ * something a server said, and a client-side refusal must never be
47
+ * indistinguishable from one that came over the wire.
48
+ */
49
+ export const MY_LISTINGS_SOURCE_MISSING: StapelApiError = new StapelApiError({
50
+ code: LISTINGS_I18N_KEYS.mineSourceMissing,
51
+ message:
52
+ "No MyListingsSource was supplied, and stapel-listings has no " +
53
+ "owner-scoped list endpoint to fall back on.",
54
+ status: 0,
55
+ });
@@ -0,0 +1,224 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import type { QueryClient, UseMutationResult } from "@tanstack/react-query";
3
+ import type { StapelApiError } from "@stapel/core";
4
+ import type {
5
+ DeleteResponse,
6
+ FavoriteToggleResponse,
7
+ ListingActionResponse,
8
+ ListingDraft,
9
+ ListingDraftPatch,
10
+ PublishResponse,
11
+ } from "../api/types.js";
12
+ import { useListingsApi } from "./context.js";
13
+ import { listingsQueryKeys } from "./queryKeys.js";
14
+
15
+ /**
16
+ * Every write in the module, each one saying what it invalidates and why.
17
+ *
18
+ * ── The listing id is a VARIABLE, not a hook argument ──────────────────────
19
+ *
20
+ * Every mutation here takes its id in the mutation's variables rather than in
21
+ * the hook's closure, and that is a correctness decision rather than a style
22
+ * one. The composer creates the draft row and saves into it IN THE SAME user
23
+ * gesture: an id-bound hook would still be holding the id from the render
24
+ * before the create, so the save would go to the wrong path (or to none). A
25
+ * variable is read at call time, which is the only time the id is known.
26
+ *
27
+ * ── The invalidation rule ──────────────────────────────────────────────────
28
+ *
29
+ * A write that can move `status` or `moderation_status` invalidates the
30
+ * DETAIL, the STATUS probe and the COUNTERS — the counters because their
31
+ * three tabs are defined by status sets (`views.my_counters`), so a publish
32
+ * silently moves a row from "drafts" to "active" and a dashboard that did not
33
+ * refetch would show the old number beside the new row.
34
+ *
35
+ * Nothing here is optimistic except the favourite toggle, and that exception
36
+ * is argued at its own hook.
37
+ */
38
+
39
+ /** Everything a status-moving write must freshen. */
40
+ function invalidateListing(queryClient: QueryClient, id: number): void {
41
+ void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.detail(id) });
42
+ void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.status(id) });
43
+ void queryClient.invalidateQueries({
44
+ queryKey: listingsQueryKeys.myCounters(),
45
+ });
46
+ void queryClient.invalidateQueries({
47
+ queryKey: listingsQueryKeys.validateDraft(id),
48
+ });
49
+ }
50
+
51
+ /** Start a draft. The server forces `owner` and `status=draft`. */
52
+ export function useCreateDraft(): UseMutationResult<
53
+ ListingDraft,
54
+ StapelApiError,
55
+ ListingDraftPatch
56
+ > {
57
+ const api = useListingsApi();
58
+ const queryClient = useQueryClient();
59
+ return useMutation({
60
+ mutationFn: (body: ListingDraftPatch) => api.createDraft(body),
61
+ retry: false,
62
+ onSuccess: () => {
63
+ void queryClient.invalidateQueries({
64
+ queryKey: listingsQueryKeys.myCounters(),
65
+ });
66
+ },
67
+ });
68
+ }
69
+
70
+ /** What a `save-draft` write carries. */
71
+ export interface SaveDraftInput {
72
+ readonly id: number;
73
+ readonly body: ListingDraftPatch;
74
+ }
75
+
76
+ /**
77
+ * Persist draft fields. Always partial — send what the form holds.
78
+ *
79
+ * The validate-draft entry is invalidated rather than written: the saved row
80
+ * comes back, but it carries no verdict, and a stale verdict beside fresh
81
+ * content is the one combination a composer must never show.
82
+ */
83
+ export function useSaveDraft(): UseMutationResult<
84
+ ListingDraft,
85
+ StapelApiError,
86
+ SaveDraftInput
87
+ > {
88
+ const api = useListingsApi();
89
+ const queryClient = useQueryClient();
90
+ return useMutation({
91
+ mutationFn: (input: SaveDraftInput) => api.saveDraft(input.id, input.body),
92
+ retry: false,
93
+ onSuccess: (_data, input) => {
94
+ invalidateListing(queryClient, input.id);
95
+ },
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Promote the draft.
101
+ *
102
+ * `retry: false` — the 400 here is a VERDICT about the content (a bare
103
+ * `ValidationBatchResult` body, see `model/validation.ts`), and repeating a
104
+ * verdict three times only delays the moment the composer can lay it on the
105
+ * controls.
106
+ */
107
+ export function usePublishListing(): UseMutationResult<
108
+ PublishResponse,
109
+ StapelApiError,
110
+ number
111
+ > {
112
+ const api = useListingsApi();
113
+ const queryClient = useQueryClient();
114
+ return useMutation({
115
+ mutationFn: (id: number) => api.publish(id),
116
+ retry: false,
117
+ onSuccess: (_data, id) => {
118
+ invalidateListing(queryClient, id);
119
+ },
120
+ });
121
+ }
122
+
123
+ /** Move to ARCHIVED. 409 `error.409.invalid_listing_transition` carries
124
+ * `params.from_status`, which is what turns the refusal into a sentence. */
125
+ export function useArchiveListing(): UseMutationResult<
126
+ ListingActionResponse,
127
+ StapelApiError,
128
+ number
129
+ > {
130
+ const api = useListingsApi();
131
+ const queryClient = useQueryClient();
132
+ return useMutation({
133
+ mutationFn: (id: number) => api.archive(id),
134
+ retry: false,
135
+ onSuccess: (_data, id) => {
136
+ invalidateListing(queryClient, id);
137
+ },
138
+ });
139
+ }
140
+
141
+ /** Mark SOLD. Same 409 contract as {@link useArchiveListing}. */
142
+ export function useCompleteListing(): UseMutationResult<
143
+ ListingActionResponse,
144
+ StapelApiError,
145
+ number
146
+ > {
147
+ const api = useListingsApi();
148
+ const queryClient = useQueryClient();
149
+ return useMutation({
150
+ mutationFn: (id: number) => api.complete(id),
151
+ retry: false,
152
+ onSuccess: (_data, id) => {
153
+ invalidateListing(queryClient, id);
154
+ },
155
+ });
156
+ }
157
+
158
+ /** Soft-delete. Refused with `error.409.listing_cannot_delete_active` while
159
+ * the listing is PUBLISHED or PENDING. */
160
+ export function useDeleteListing(): UseMutationResult<
161
+ DeleteResponse,
162
+ StapelApiError,
163
+ number
164
+ > {
165
+ const api = useListingsApi();
166
+ const queryClient = useQueryClient();
167
+ return useMutation({
168
+ mutationFn: (id: number) => api.remove(id),
169
+ retry: false,
170
+ onSuccess: (_data, id) => {
171
+ invalidateListing(queryClient, id);
172
+ void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.all });
173
+ },
174
+ });
175
+ }
176
+
177
+ /** What a favourite toggle carries: the id and the state it should END in. */
178
+ export interface FavoriteInput {
179
+ readonly id: number;
180
+ readonly favorited: boolean;
181
+ }
182
+
183
+ /**
184
+ * Favourite / un-favourite, as ONE mutation over the intended next state.
185
+ *
186
+ * Two endpoints, one intent — and modelling it as two hooks would let a
187
+ * caller call the wrong one for the state it is in. The server is idempotent
188
+ * on both sides (`get_or_create` / `filter().delete()`), so a double-click
189
+ * cannot desynchronise anything.
190
+ *
191
+ * This is the pair's ONE write whose next state the client may predict, and
192
+ * the reason is that the truth is a single boolean the caller already holds.
193
+ * Everything else here moves a LIFECYCLE whose next state is the server's to
194
+ * decide — guessing `pending` versus `published` after a publish would be
195
+ * inventing the very verdict the pair exists to report faithfully.
196
+ *
197
+ * The invalidation reaches the favourites LIST as well as the row: a heart
198
+ * turned off on the favourites page must remove the card, not leave a hollow
199
+ * one behind.
200
+ */
201
+ export function useFavoriteListing(): UseMutationResult<
202
+ FavoriteToggleResponse,
203
+ StapelApiError,
204
+ FavoriteInput
205
+ > {
206
+ const api = useListingsApi();
207
+ const queryClient = useQueryClient();
208
+ return useMutation({
209
+ mutationFn: (input: FavoriteInput) =>
210
+ input.favorited ? api.favorite(input.id) : api.unfavorite(input.id),
211
+ retry: false,
212
+ onSettled: (_data, _error, input) => {
213
+ void queryClient.invalidateQueries({
214
+ queryKey: listingsQueryKeys.detail(input.id),
215
+ });
216
+ void queryClient.invalidateQueries({
217
+ queryKey: listingsQueryKeys.allFavorites(),
218
+ });
219
+ void queryClient.invalidateQueries({
220
+ queryKey: listingsQueryKeys.allLists(),
221
+ });
222
+ },
223
+ });
224
+ }
@@ -0,0 +1,150 @@
1
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
2
+ import type { UseQueryResult } from "@tanstack/react-query";
3
+ import { useActiveSessionReady } from "@stapel/core";
4
+ import type { StapelApiError } from "@stapel/core";
5
+ import type { ValidationBatchResult } from "@stapel/attributes-react";
6
+ import type {
7
+ ListingDetail,
8
+ ListingPageParams,
9
+ ListingStatusInfo,
10
+ MyCounters,
11
+ PaginatedListingCards,
12
+ } from "../api/types.js";
13
+ import { useListingsApi } from "./context.js";
14
+ import { listingsQueryKeys, pageKey } from "./queryKeys.js";
15
+
16
+ /**
17
+ * Read hooks over the listings API (frontend-standard §2). Keys are
18
+ * namespaced (see `listingsQueryKeys`).
19
+ *
20
+ * ── Two gating regimes in one module, and the line between them ────────────
21
+ *
22
+ * `useListing` / `useListingStatus` / `useListingCards` call
23
+ * `IsAuthenticatedOrReadOnly` and `AllowAny` endpoints: a visitor reads them
24
+ * with no session at all, so gating on `useActiveSessionReady` would make a
25
+ * shop window wait for a login bootstrap it has no stake in. Core's own doc
26
+ * comment carves out exactly this case.
27
+ *
28
+ * `useMyCounters` / `useMyFavorites` / `useValidateDraft` are
29
+ * `IsAuthenticated` and DO gate: firing them before the session substrate has
30
+ * settled produces a 401 that means "we asked too early", which is
31
+ * indistinguishable on screen from "you are signed out".
32
+ */
33
+
34
+ /**
35
+ * One listing in full.
36
+ *
37
+ * `retry: false` on purpose. The failures here are verdicts about the
38
+ * request: a 404 for a listing that is gone, a 403 for one that is not
39
+ * yours. Retrying a 404 three times only delays the moment the page can say
40
+ * which it was — and for a soft-deleted listing, `useListingStatus` is the
41
+ * read that can actually explain it (see `headless/ListingDetail.tsx`).
42
+ */
43
+ export function useListing(
44
+ id: number | undefined,
45
+ options?: { readonly enabled?: boolean }
46
+ ): UseQueryResult<ListingDetail, StapelApiError> {
47
+ const api = useListingsApi();
48
+ return useQuery({
49
+ queryKey: listingsQueryKeys.detail(id ?? -1),
50
+ queryFn: ({ signal }) => api.retrieve(id as number, { signal }),
51
+ enabled: (options?.enabled ?? true) && id !== undefined,
52
+ retry: false,
53
+ });
54
+ }
55
+
56
+ /**
57
+ * The status probe — both axes plus `is_deleted`, for ANY listing id.
58
+ *
59
+ * The one read that survives a soft delete (`Listing.all_objects`), which is
60
+ * why a detail page runs it beside the detail rather than only after a
61
+ * failure: "this listing was removed" is a sentence, and a bare 404 is not.
62
+ */
63
+ export function useListingStatus(
64
+ id: number | undefined,
65
+ options?: { readonly enabled?: boolean }
66
+ ): UseQueryResult<ListingStatusInfo, StapelApiError> {
67
+ const api = useListingsApi();
68
+ return useQuery({
69
+ queryKey: listingsQueryKeys.status(id ?? -1),
70
+ queryFn: ({ signal }) => api.status(id as number, { signal }),
71
+ enabled: (options?.enabled ?? true) && id !== undefined,
72
+ retry: false,
73
+ });
74
+ }
75
+
76
+ /**
77
+ * A keyset page of PUBLISHED cards.
78
+ *
79
+ * `keepPreviousData`: paging a grid that blanks between pages reads as a
80
+ * failure, and the previous page is still a true answer about the previous
81
+ * cursor. The FIRST load has nothing to keep and is `loading`; a failure is
82
+ * still `failed` rather than stale rows pretending to be current.
83
+ */
84
+ export function useListingCards(
85
+ params?: ListingPageParams,
86
+ options?: { readonly enabled?: boolean }
87
+ ): UseQueryResult<PaginatedListingCards, StapelApiError> {
88
+ const api = useListingsApi();
89
+ return useQuery({
90
+ queryKey: listingsQueryKeys.list(pageKey(params)),
91
+ queryFn: ({ signal }) => api.list(params, { signal }),
92
+ enabled: options?.enabled ?? true,
93
+ placeholderData: keepPreviousData,
94
+ retry: false,
95
+ });
96
+ }
97
+
98
+ /** The dashboard's three counts. Session-gated. */
99
+ export function useMyCounters(options?: {
100
+ readonly enabled?: boolean;
101
+ }): UseQueryResult<MyCounters, StapelApiError> {
102
+ const api = useListingsApi();
103
+ const sessionReady = useActiveSessionReady();
104
+ return useQuery({
105
+ queryKey: listingsQueryKeys.myCounters(),
106
+ queryFn: ({ signal }) => api.myCounters({ signal }),
107
+ enabled: (options?.enabled ?? true) && sessionReady,
108
+ retry: false,
109
+ });
110
+ }
111
+
112
+ /** A keyset page of the caller's favourites. Session-gated. */
113
+ export function useMyFavorites(
114
+ params?: ListingPageParams,
115
+ options?: { readonly enabled?: boolean }
116
+ ): UseQueryResult<PaginatedListingCards, StapelApiError> {
117
+ const api = useListingsApi();
118
+ const sessionReady = useActiveSessionReady();
119
+ return useQuery({
120
+ queryKey: listingsQueryKeys.myFavorites(pageKey(params)),
121
+ queryFn: ({ signal }) => api.myFavorites(params, { signal }),
122
+ enabled: (options?.enabled ?? true) && sessionReady,
123
+ placeholderData: keepPreviousData,
124
+ retry: false,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * What publishing WOULD say, without publishing.
130
+ *
131
+ * Disabled by default: it is a round trip per call and the composer's own
132
+ * mirror answers the same question for free most of the time. A composer
133
+ * turns it on when the person asks ("check my listing") and after a save, so
134
+ * the SERVER's opinion of the saved draft is what the button reports —
135
+ * `staleTime: 0` because the draft is exactly the thing that just moved.
136
+ */
137
+ export function useValidateDraft(
138
+ id: number | undefined,
139
+ options?: { readonly enabled?: boolean }
140
+ ): UseQueryResult<ValidationBatchResult, StapelApiError> {
141
+ const api = useListingsApi();
142
+ const sessionReady = useActiveSessionReady();
143
+ return useQuery({
144
+ queryKey: listingsQueryKeys.validateDraft(id ?? -1),
145
+ queryFn: ({ signal }) => api.validateDraft(id as number, { signal }),
146
+ enabled: (options?.enabled ?? false) && id !== undefined && sessionReady,
147
+ staleTime: 0,
148
+ retry: false,
149
+ });
150
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
3
+ * Everything under the `"listings"` root so a host can invalidate the whole
4
+ * module or match a single read. Explicit tuple return types satisfy
5
+ * `--isolatedDeclarations`.
6
+ *
7
+ * THE KEY IS THE REQUEST for the paginated reads: a page is keyed on the very
8
+ * parameter object the client is handed, normalized so that "no cursor" and
9
+ * "an explicitly undefined cursor" cannot cache twice. Two states that
10
+ * produce the same request therefore share a cache entry, and one that
11
+ * produces a different request cannot silently reuse a page — the stale-page
12
+ * bug (new filter, cached rows) is unwritable rather than merely avoided.
13
+ */
14
+ import type { ListingPageParams } from "../api/types.js";
15
+
16
+ /** The normalized page cursor a list read is keyed on. */
17
+ export interface ListingPageKey {
18
+ readonly anchor: string | null;
19
+ readonly direction: string | null;
20
+ readonly limit: number | null;
21
+ }
22
+
23
+ /** Normalize a page's parameters into its key. Exported because a test that
24
+ * asserts "these two states hit one cache entry" must build the key the same
25
+ * way the hook does. */
26
+ export function pageKey(params?: ListingPageParams): ListingPageKey {
27
+ return {
28
+ anchor: params?.anchor ?? null,
29
+ direction: params?.direction ?? null,
30
+ limit: params?.limit ?? null,
31
+ };
32
+ }
33
+
34
+ const ROOT = "listings" as const;
35
+
36
+ export const listingsQueryKeys: {
37
+ readonly all: readonly ["listings"];
38
+ /** The published card list. */
39
+ list(page: ListingPageKey): readonly ["listings", "list", ListingPageKey];
40
+ /** One listing in full. */
41
+ detail(id: number): readonly ["listings", "detail", number];
42
+ /** The AllowAny status probe — a SEPARATE entry from the detail on purpose:
43
+ * it answers for a soft-deleted listing the detail 404s on, so caching them
44
+ * together would let a 404 evict the one read that can explain it. */
45
+ status(id: number): readonly ["listings", "status", number];
46
+ /** The dashboard's three counts. */
47
+ myCounters(): readonly ["listings", "my", "counters"];
48
+ /** The caller's favourites, one keyset page. */
49
+ myFavorites(
50
+ page: ListingPageKey
51
+ ): readonly ["listings", "my", "favorites", ListingPageKey];
52
+ /** The owner's own rows, per tab — see `headless/MyListings.tsx` for why
53
+ * this read goes through an injected source. */
54
+ mine(
55
+ tab: string,
56
+ page: ListingPageKey
57
+ ): readonly ["listings", "my", "listings", string, ListingPageKey];
58
+ /** `GET /{pk}/validate-draft/` — the dry run of a publish. */
59
+ validateDraft(id: number): readonly ["listings", "validate-draft", number];
60
+ /** Every favourites PAGE, for an invalidation after a toggle — the cursor
61
+ * is not known at the write, so the prefix is what a write can target. */
62
+ allFavorites(): readonly ["listings", "my", "favorites"];
63
+ /** Every published-card PAGE, same reason. */
64
+ allLists(): readonly ["listings", "list"];
65
+ } = {
66
+ all: [ROOT],
67
+ list: (page) => [ROOT, "list", page],
68
+ detail: (id) => [ROOT, "detail", id],
69
+ status: (id) => [ROOT, "status", id],
70
+ myCounters: () => [ROOT, "my", "counters"],
71
+ myFavorites: (page) => [ROOT, "my", "favorites", page],
72
+ mine: (tab, page) => [ROOT, "my", "listings", tab, page],
73
+ validateDraft: (id) => [ROOT, "validate-draft", id],
74
+ allFavorites: () => [ROOT, "my", "favorites"],
75
+ allLists: () => [ROOT, "list"],
76
+ };