@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,231 @@
1
+ import { useMemo } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import {
4
+ actionAvailable,
5
+ actionBlocked,
6
+ errorStatus,
7
+ firstBlock,
8
+ loadFailed,
9
+ loadLoading,
10
+ loadReady,
11
+ } from "@stapel/core";
12
+ import type { ActionAvailability, LoadState } from "@stapel/core";
13
+ import type {
14
+ ListingDetail as ListingDetailData,
15
+ ListingFeatureView,
16
+ ListingStatusInfo,
17
+ } from "../api/types.js";
18
+ import { useListing, useListingStatus } from "../model/queries.js";
19
+ import { useFavoriteListing } from "../model/mutations.js";
20
+ import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from "../model/features.js";
21
+ import { listingStatusView } from "../model/status.js";
22
+ import type { ListingStatusView } from "../model/status.js";
23
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
24
+ import { useMandateGate } from "./useMandateGate.js";
25
+
26
+ /**
27
+ * `images` is `string[] | NULL` on the wire, and the null is the SERVER
28
+ * saying "this listing has no photos" — not this pair saying "we do not
29
+ * know". Whether the read landed at all is carried by `state`, one field
30
+ * over, so collapsing the null here loses nothing: a named constant makes
31
+ * that argument visible where a bare `?? []` would look like the flattening
32
+ * `no-flattened-load-state` bans.
33
+ */
34
+ const NO_IMAGES: readonly string[] = [];
35
+
36
+ /**
37
+ * Everything a listing page renders, with the two absences it must be able to
38
+ * tell apart.
39
+ *
40
+ * ── Why a second read runs beside the detail ───────────────────────────────
41
+ *
42
+ * `GET /listings/{pk}/` reads `Listing.objects`, which excludes soft-deleted
43
+ * rows: a removed listing answers 404, and a 404 is also what a made-up id
44
+ * answers. `GET /listings/{pk}/status/` reads `Listing.all_objects` and is
45
+ * `AllowAny`, so it still answers for the removed one. Running both is what
46
+ * turns "there is no listing at this address" and "this listing was removed"
47
+ * into two different sentences — and the second is the one a person following
48
+ * a stale link actually needs.
49
+ *
50
+ * The probe is not a fallback fired after a failure: it runs in parallel, so
51
+ * the page resolves in one round trip rather than two sequential ones.
52
+ *
53
+ * ── Visibility is reported, not assumed ────────────────────────────────────
54
+ *
55
+ * The detail endpoint has NO `published()` filter on its queryset, so a draft
56
+ * and a taken-down listing both answer 200 to anyone holding the id. The bag
57
+ * therefore carries `publiclyVisible` (computed from `status`, the one field
58
+ * that decides it) and `viewerIsOwner`, and a skin says which of the two
59
+ * situations it is in. Rendering a draft as a live shop page would be the
60
+ * pair repeating the server's omission instead of covering it.
61
+ */
62
+ export interface ListingDetailBag {
63
+ readonly id: number;
64
+ readonly state: LoadState<ListingDetailData>;
65
+ /** The status probe, which answers for rows the detail cannot see. */
66
+ readonly statusState: LoadState<ListingStatusInfo>;
67
+ /** True when the detail 404s AND the probe says the row is soft-deleted —
68
+ * i.e. it existed and is gone, rather than never having existed. */
69
+ readonly removed: boolean;
70
+ /** The detail 404s and the probe finds nothing either. */
71
+ readonly notFound: boolean;
72
+ /** Both axes plus the sentence that comes out of the pair of them. */
73
+ readonly status: ListingStatusView | undefined;
74
+ readonly publiclyVisible: boolean;
75
+ /** Whether the person reading this is the owner, when both the probe and
76
+ * the viewer's id are known. `undefined` means "we did not ask" — a public
77
+ * page has no reason to. */
78
+ readonly viewerIsOwner: boolean | undefined;
79
+ /** The ordered specs table. */
80
+ readonly features: readonly ListingFeatureView[];
81
+ /** The `show_at_title` subset — what a heading appends. */
82
+ readonly titleFeatures: readonly ListingFeatureView[];
83
+ /** The `show_as_badge` subset — what a card shows. */
84
+ readonly badgeFeatures: readonly ListingFeatureView[];
85
+ /** Stored rows this build could not key. Reported, never rounded to zero. */
86
+ readonly unreadableFeatures: number;
87
+ /** Ordered CDN references. Resolving one to an image is the runtime's job
88
+ * (`resolveImage`), because no contract in this fleet resolves a
89
+ * stranger's reference — see `model/runtime.ts`. */
90
+ readonly images: readonly string[];
91
+ readonly isFavorited: boolean | undefined;
92
+ /** Saving a favourite needs a mandate; the block says which of the four
93
+ * reasons applies. */
94
+ readonly favoriteGate: ActionAvailability;
95
+ toggleFavorite(): void;
96
+ readonly favoriteInFlight: boolean;
97
+ refetch(): void;
98
+ }
99
+
100
+ export interface UseListingDetailOptions {
101
+ /** The reader's own id, when the host knows it — enables `viewerIsOwner`.
102
+ * A uuid string, matching `ListingDetail.owner` / `ListingStatus.owner_id`. */
103
+ readonly viewerId?: string;
104
+ }
105
+
106
+ export function useListingDetail(
107
+ id: number,
108
+ options: UseListingDetailOptions = {}
109
+ ): ListingDetailBag {
110
+ const detail = useListing(id);
111
+ const probe = useListingStatus(id);
112
+ const favorite = useFavoriteListing();
113
+ const mandate = useMandateGate();
114
+
115
+ const state: LoadState<ListingDetailData> =
116
+ detail.status === "error"
117
+ ? loadFailed(detail.error)
118
+ : detail.data !== undefined
119
+ ? loadReady(detail.data)
120
+ : loadLoading();
121
+
122
+ const statusState: LoadState<ListingStatusInfo> =
123
+ probe.status === "error"
124
+ ? loadFailed(probe.error)
125
+ : probe.data !== undefined
126
+ ? loadReady(probe.data)
127
+ : loadLoading();
128
+
129
+ const detailIs404 =
130
+ detail.status === "error" && errorStatus(detail.error) === 404;
131
+ const removed = detailIs404 && probe.data?.is_deleted === true;
132
+ const notFound = detailIs404 && !removed && probe.status === "error";
133
+
134
+ const status: ListingStatusView | undefined = useMemo(() => {
135
+ if (detail.data !== undefined) {
136
+ return listingStatusView(
137
+ detail.data.status ?? "draft",
138
+ detail.data.moderation_status ?? "pending"
139
+ );
140
+ }
141
+ // The probe still knows both axes for a row the detail cannot return —
142
+ // `moderation_status` arrives as a bare string there, so it is narrowed
143
+ // by the same table rather than trusted.
144
+ if (probe.data !== undefined) {
145
+ const moderation = probe.data.moderation_status;
146
+ return listingStatusView(
147
+ probe.data.status,
148
+ moderation === "approved" ||
149
+ moderation === "rejected" ||
150
+ moderation === "needs_review"
151
+ ? moderation
152
+ : "pending"
153
+ );
154
+ }
155
+ return undefined;
156
+ }, [detail.data, probe.data]);
157
+
158
+ const owner = detail.data?.owner ?? probe.data?.owner_id;
159
+ const viewerIsOwner =
160
+ options.viewerId === undefined || owner === undefined
161
+ ? undefined
162
+ : owner === options.viewerId;
163
+
164
+ const features = useMemo(
165
+ () => featuresFromDaoList(asFeatureDaoList(detail.data?.features)),
166
+ [detail.data]
167
+ );
168
+ const titleFeatures = useMemo(
169
+ () => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title)),
170
+ [detail.data]
171
+ );
172
+ const badgeFeatures = useMemo(
173
+ () => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges)),
174
+ [detail.data]
175
+ );
176
+
177
+ const isFavorited = detail.data?.is_favorited ?? undefined;
178
+
179
+ const favoriteGate = firstBlock(
180
+ mandate,
181
+ favorite.isPending
182
+ ? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
183
+ : actionAvailable(),
184
+ detail.data === undefined
185
+ ? actionBlocked(LISTINGS_I18N_KEYS.detailLoading)
186
+ : actionAvailable()
187
+ );
188
+
189
+ return {
190
+ id,
191
+ state,
192
+ statusState,
193
+ removed,
194
+ notFound,
195
+ status,
196
+ publiclyVisible: status?.lifecycle.publiclyVisible ?? false,
197
+ viewerIsOwner,
198
+ features,
199
+ titleFeatures,
200
+ badgeFeatures,
201
+ unreadableFeatures: unreadableFeatureCount(
202
+ asFeatureDaoList(detail.data?.features)
203
+ ),
204
+ images: detail.data?.images ?? NO_IMAGES,
205
+ isFavorited,
206
+ favoriteGate,
207
+ toggleFavorite: () => {
208
+ if (!favoriteGate.available) return;
209
+ favorite.mutate({ id, favorited: isFavorited !== true });
210
+ },
211
+ favoriteInFlight: favorite.isPending,
212
+ refetch: () => {
213
+ void detail.refetch();
214
+ void probe.refetch();
215
+ },
216
+ };
217
+ }
218
+
219
+ /** Renderless: the bag, handed to a render prop. Bring your own visuals, or
220
+ * import `<ListingDetailPane>` from `./default`. */
221
+ export function ListingDetail(props: {
222
+ id: number;
223
+ viewerId?: string;
224
+ children: (bag: ListingDetailBag) => ReactNode;
225
+ }): ReactElement {
226
+ const bag = useListingDetail(
227
+ props.id,
228
+ props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
229
+ );
230
+ return <>{props.children(bag)}</>;
231
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { ModuleProvider } from "../model/context.js";
3
+ import type { ListingsRuntime } from "../model/runtime.js";
4
+
5
+ /**
6
+ * Renderless provider — puts one wired {@link ListingsRuntime} in context for
7
+ * every hook and headless component in the pair. Bring your own visual shell.
8
+ *
9
+ * ```tsx
10
+ * <ListingsProvider runtime={createListingsRuntime({ baseUrl: "/listings/api/v1/" })}>
11
+ * <YourShopWindow />
12
+ * </ListingsProvider>
13
+ * ```
14
+ */
15
+ export function ListingsProvider(props: {
16
+ runtime: ListingsRuntime;
17
+ children: ReactNode;
18
+ }): ReactElement {
19
+ return <ModuleProvider runtime={props.runtime}>{props.children}</ModuleProvider>;
20
+ }
@@ -0,0 +1,148 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import {
5
+ actionAvailable,
6
+ actionBlocked,
7
+ loadFailed,
8
+ loadLoading,
9
+ loadReady,
10
+ useActiveSessionReady,
11
+ } from "@stapel/core";
12
+ import type { ActionAvailability, LoadState } from "@stapel/core";
13
+ import type {
14
+ ListingCard,
15
+ ListingPageParams,
16
+ MyCounters,
17
+ } from "../api/types.js";
18
+ import { MY_LISTINGS_SOURCE_MISSING } from "../model/mineSource.js";
19
+ import type { MyListingsSource } from "../model/mineSource.js";
20
+ import { useMyCounters } from "../model/queries.js";
21
+ import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
22
+ import { MY_LISTINGS_TABS } from "../model/status.js";
23
+ import type { MyListingsTab } from "../model/status.js";
24
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
25
+ import { useMandateGate } from "./useMandateGate.js";
26
+
27
+ /**
28
+ * The owner's dashboard — and the one screen in this pair whose rows the
29
+ * backend cannot supply.
30
+ *
31
+ * The counters are REAL and are shown. The rows come from an injected
32
+ * `MyListingsSource`, and when there is none `rows` lands in the `failed` arm
33
+ * carrying a NAMED reason rather than as an empty list. `model/mineSource.ts`
34
+ * holds the whole argument and the upstream asks; this file is the bag over
35
+ * it.
36
+ */
37
+
38
+ export interface MyListingsBag {
39
+ readonly tab: MyListingsTab;
40
+ readonly tabs: readonly MyListingsTab[];
41
+ setTab(tab: MyListingsTab): void;
42
+ /** The three real counts. */
43
+ readonly counters: LoadState<MyCounters>;
44
+ /** The rows for the current tab. `failed` with a named reason when no
45
+ * source is wired — never an empty list. */
46
+ readonly rows: LoadState<readonly ListingCard[]>;
47
+ readonly page: ListingPageParams;
48
+ readonly nextPage: ActionAvailability;
49
+ readonly prevPage: ActionAvailability;
50
+ goNext(): void;
51
+ goPrev(): void;
52
+ /** Whether the person may see this screen at all. */
53
+ readonly gate: ActionAvailability;
54
+ refetch(): void;
55
+ }
56
+
57
+ export interface UseMyListingsOptions {
58
+ readonly source?: MyListingsSource;
59
+ readonly initialTab?: MyListingsTab;
60
+ readonly limit?: number;
61
+ }
62
+
63
+ export function useMyListings(
64
+ options: UseMyListingsOptions = {}
65
+ ): MyListingsBag {
66
+ const gate = useMandateGate();
67
+ const sessionReady = useActiveSessionReady();
68
+ const [tab, setTabState] = useState<MyListingsTab>(
69
+ options.initialTab ?? "active"
70
+ );
71
+ const [page, setPage] = useState<ListingPageParams>(
72
+ options.limit !== undefined ? { limit: options.limit } : {}
73
+ );
74
+
75
+ const counters = useMyCounters();
76
+ const { source } = options;
77
+
78
+ const rowsQuery = useQuery({
79
+ queryKey: listingsQueryKeys.mine(tab, pageKey(page)),
80
+ queryFn: ({ signal }) =>
81
+ (source as MyListingsSource)({ tab, page, signal }),
82
+ enabled: source !== undefined && sessionReady && gate.available,
83
+ retry: false,
84
+ });
85
+
86
+ const rows: LoadState<readonly ListingCard[]> = useMemo(() => {
87
+ if (source === undefined) return loadFailed(MY_LISTINGS_SOURCE_MISSING);
88
+ if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
89
+ if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
90
+ return loadLoading();
91
+ }, [source, rowsQuery.status, rowsQuery.error, rowsQuery.data]);
92
+
93
+ const envelope = rowsQuery.data;
94
+
95
+ return {
96
+ tab,
97
+ tabs: MY_LISTINGS_TABS,
98
+ setTab: (next) => {
99
+ // A cursor belongs to ONE ordered candidate set. Carried across a tab
100
+ // change it either bounces or honestly returns page four of a
101
+ // different list — the same property `@stapel/search-react` writes
102
+ // down for its own keyset state.
103
+ setPage(options.limit !== undefined ? { limit: options.limit } : {});
104
+ setTabState(next);
105
+ },
106
+ counters:
107
+ counters.status === "error"
108
+ ? loadFailed(counters.error)
109
+ : counters.data !== undefined
110
+ ? loadReady(counters.data)
111
+ : loadLoading(),
112
+ rows,
113
+ page,
114
+ nextPage:
115
+ envelope?.has_next === true && envelope.next_anchor != null
116
+ ? actionAvailable()
117
+ : actionBlocked(LISTINGS_I18N_KEYS.pageNext),
118
+ prevPage:
119
+ envelope?.has_prev === true && envelope.prev_anchor != null
120
+ ? actionAvailable()
121
+ : actionBlocked(LISTINGS_I18N_KEYS.pagePrev),
122
+ goNext: () => {
123
+ const anchor = envelope?.next_anchor;
124
+ if (anchor == null) return;
125
+ setPage((current) => ({ ...current, anchor, direction: "next" }));
126
+ },
127
+ goPrev: () => {
128
+ const anchor = envelope?.prev_anchor;
129
+ if (anchor == null) return;
130
+ setPage((current) => ({ ...current, anchor, direction: "prev" }));
131
+ },
132
+ gate,
133
+ refetch: () => {
134
+ void counters.refetch();
135
+ void rowsQuery.refetch();
136
+ },
137
+ };
138
+ }
139
+
140
+ /** Renderless: the bag, handed to a render prop. */
141
+ export function MyListings(
142
+ props: UseMyListingsOptions & {
143
+ children: (bag: MyListingsBag) => ReactNode;
144
+ }
145
+ ): ReactElement {
146
+ const bag = useMyListings(props);
147
+ return <>{props.children(bag)}</>;
148
+ }
@@ -0,0 +1,40 @@
1
+ import { matchMandate, useMandate } from "@stapel/core";
2
+ import type { ActionAvailability } from "@stapel/core";
3
+ import { actionAvailable, actionBlocked } from "@stapel/core";
4
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
5
+
6
+ /**
7
+ * "May this person act at all?" — the first gate on every write in the pair.
8
+ *
9
+ * Posting a listing, saving a draft, marking something sold and saving a
10
+ * favourite are all `IsAuthenticated`. A visitor meeting one of them must be
11
+ * told to sign in, WITH the reason and with a way to do it: a hidden button
12
+ * teaches nothing (private-space canon §6.3), and a 401 after the click
13
+ * teaches it too late.
14
+ *
15
+ * The axis is read through core's `MandateSource` seam, never derived here. A
16
+ * storefront's derivation is "is there a session?"; a tenant app's is
17
+ * `@stapel/workspaces-react`'s. This pair asks and does not care which.
18
+ * `matchMandate` has five required arms, so the two `unresolved` outcomes
19
+ * cannot fall into the refusal's branch by omission:
20
+ *
21
+ * - `asking` — we have not finished asking. The control waits; it does not
22
+ * say "you may not".
23
+ * - `unavailable` — we COULD NOT ask. Also not "you may not": the storefront
24
+ * spec's own negative leg (§7.4) is that `/me` answering 503
25
+ * must not render as a refusal.
26
+ *
27
+ * Outside a `<MandateProvider>` core answers `unresolved/unavailable` rather
28
+ * than throwing, so a pair rendered in a host that never wired the axis
29
+ * degrades to "we could not check" — visible, fixable, and not a blank page.
30
+ */
31
+ export function useMandateGate(): ActionAvailability {
32
+ const mandate = useMandate();
33
+ return matchMandate<ActionAvailability>(mandate, {
34
+ member: () => actionAvailable(),
35
+ guest: () => actionBlocked(LISTINGS_I18N_KEYS.blockedGuest),
36
+ anonymous: () => actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn),
37
+ asking: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
38
+ unavailable: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
39
+ });
40
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The pair's error map (frontend-standard §4 checklist #7, frontend-core
3
+ * §2.5): the generated `code → { status, params, remediation, en }` catalog
4
+ * plus a tiny `explain()` lookup. Backs the manifest `errors` block and gives
5
+ * hosts a mechanical UX branch beside `t(code, params)`. The map itself is
6
+ * generated from the backend registry (`pnpm gen:errors`); this file only
7
+ * adds the lookup helper, names the three refusals a storefront actually
8
+ * meets, and re-exports the public surface.
9
+ */
10
+ import { LISTINGS_ERRORS } from "./generated/errors.gen.js";
11
+ import type { Remediation } from "./generated/errors.gen.js";
12
+
13
+ export {
14
+ LISTINGS_ERRORS,
15
+ LISTINGS_ERROR_CODES,
16
+ listingsErrorBundleEn,
17
+ } from "./generated/errors.gen.js";
18
+ export type {
19
+ ListingsErrorCode,
20
+ ListingsErrorSpec,
21
+ Remediation,
22
+ } from "./generated/errors.gen.js";
23
+
24
+ /**
25
+ * The lifecycle refusal, and the one code in this registry that carries the
26
+ * information needed to explain itself: `params.from_status` is the state the
27
+ * server refused to move OUT of (`views._transition`). A skin renders
28
+ * "a listing that is sold cannot be archived that way", not "conflict".
29
+ */
30
+ export const LISTING_INVALID_TRANSITION = "error.409.invalid_listing_transition";
31
+
32
+ /** Deleting something that is on sale. The remedy is in the sentence:
33
+ * archive it first. */
34
+ export const LISTING_CANNOT_DELETE_ACTIVE =
35
+ "error.409.listing_cannot_delete_active";
36
+
37
+ /**
38
+ * The publish refusal that is NOT a per-field verdict.
39
+ *
40
+ * `publish` answers an invalid draft with a bare `ValidationBatchResult`; this
41
+ * code is what it answers when the PROMOTION fails afterwards — today that
42
+ * means `REQUIRE_IMAGE_ON_PUBLISH` with an empty gallery. Two 400s, two
43
+ * meanings; `model/validation.ts`'s `publishRefusal` is the one place that
44
+ * tells them apart.
45
+ */
46
+ export const LISTING_PUBLISH_VALIDATION_FAILED =
47
+ "error.400.publish_validation_failed";
48
+
49
+ /** Somebody else's listing. Every owner operation routes through
50
+ * `views._get_own` — except `PUT`/`PATCH`, which is why this pair does not
51
+ * call them (`api/listingsApi.ts`). */
52
+ export const LISTING_NOT_OWNER = "error.403.listing_not_owner";
53
+
54
+ /**
55
+ * Resolve a backend error code to its remediation hint, or `undefined` for a
56
+ * code this module doesn't know (e.g. a cross-cutting `stapel.http.*`
57
+ * fallback). Zero guessing at runtime — a static lookup over the generated map.
58
+ */
59
+ export function explainListingsError(code: string): Remediation | undefined {
60
+ return (LISTINGS_ERRORS as Record<string, { remediation: Remediation }>)[code]
61
+ ?.remediation;
62
+ }