@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,87 @@
1
+ import { createModuleRuntime } from "@stapel/core";
2
+ import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
3
+ import type { StapelImage } from "@stapel/image";
4
+ import { createListingsApi } from "../api/listingsApi.js";
5
+ import type { ListingsApi } from "../api/listingsApi.js";
6
+ import { DEFAULT_LISTING_CURRENCY } from "../api/types.js";
7
+ import { DEFAULT_DRAFT_LIMITS } from "./validation.js";
8
+ import type { ListingDraftLimits } from "./validation.js";
9
+
10
+ /**
11
+ * Turn a stored image reference into something renderable.
12
+ *
13
+ * ── Why this is a seam and not a URL builder ───────────────────────────────
14
+ *
15
+ * `Listing.images` is a list of OPAQUE CDN references (`<type>/<hash>`), and
16
+ * the listings contract offers no way to resolve one: there is no
17
+ * `GET /listings/{pk}/images/`, and stapel-cdn's `file/exists/` is
18
+ * owner-scoped (`uploaded_by=request.user`), so `@stapel/cdn-react`'s
19
+ * `useCdnRef` can resolve a person's OWN draft and can never render a
20
+ * stranger's gallery — it says so itself. There is therefore no public
21
+ * read-by-reference anywhere in this fleet's contracts today.
22
+ *
23
+ * The honest answer is a seam, not an invented URL convention: a deployment
24
+ * knows where its CDN serves from, and hands that knowledge in once. A pair
25
+ * that guessed `${cdnBase}/${ref}` would be writing a contract nobody agreed
26
+ * to, and it would break the first host that puts a signature on the path.
27
+ *
28
+ * Returning a full `StapelImage` rather than a string is what buys the
29
+ * variant ladder: with `variants` populated, `@stapel/image`'s `<Image>`
30
+ * measures the slot and picks a tier; with only `url`, it shows that one.
31
+ * Both are supported, and a resolver that has nothing for a reference returns
32
+ * `undefined` — the skin then says "photo unavailable" instead of drawing a
33
+ * broken `<img>`. Recorded as an upstream gap in MODULE.md.
34
+ */
35
+ export type ListingImageResolver = (ref: string) => StapelImage | undefined;
36
+
37
+ export interface CreateListingsRuntimeOptions
38
+ extends CreateModuleRuntimeOptions {
39
+ /** See {@link ListingImageResolver}. Absent = no gallery is drawn, and the
40
+ * skin names the reason. */
41
+ readonly resolveImage?: ListingImageResolver;
42
+ /**
43
+ * The deployment's own ceilings, mirrored client-side. Partial: only the
44
+ * ones that differ from `stapel_listings/conf.py`'s library defaults.
45
+ */
46
+ readonly limits?: Partial<ListingDraftLimits>;
47
+ /** Currency a new draft starts in. Owner verdict F6: RUB. */
48
+ readonly currency?: string;
49
+ }
50
+
51
+ /**
52
+ * The wired listings runtime — core's `ModuleRuntime` bound to this pair's
53
+ * API (slim wave §21/S2), plus the three pieces of deployment knowledge the
54
+ * pair cannot derive: how to render a reference, where the ceilings are, and
55
+ * what currency the shop trades in.
56
+ *
57
+ * ```tsx
58
+ * const runtime = createListingsRuntime({
59
+ * baseUrl: "/listings/api/v1/",
60
+ * resolveImage: (ref) => myCdn.describe(ref),
61
+ * });
62
+ * <ListingsProvider runtime={runtime}>…</ListingsProvider>
63
+ * ```
64
+ *
65
+ * NOT session-gated as a whole. The public reads (`retrieve`, `status`,
66
+ * `list`) are `IsAuthenticatedOrReadOnly` and answer a visitor who will never
67
+ * sign in; the owner reads and every write are `IsAuthenticated` and the
68
+ * hooks that call them gate on `useActiveSessionReady` individually. Gating
69
+ * the module would make a shop window wait for a login bootstrap.
70
+ */
71
+ export interface ListingsRuntime extends ModuleRuntime<ListingsApi> {
72
+ readonly resolveImage: ListingImageResolver | undefined;
73
+ readonly limits: ListingDraftLimits;
74
+ readonly currency: string;
75
+ }
76
+
77
+ export function createListingsRuntime(
78
+ options: CreateListingsRuntimeOptions
79
+ ): ListingsRuntime {
80
+ const base = createModuleRuntime((client) => createListingsApi(client), options);
81
+ return {
82
+ ...base,
83
+ resolveImage: options.resolveImage,
84
+ limits: { ...DEFAULT_DRAFT_LIMITS, ...options.limits },
85
+ currency: options.currency ?? DEFAULT_LISTING_CURRENCY,
86
+ };
87
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * TWO AXES, BOTH ON SCREEN, NEITHER STANDING IN FOR THE OTHER.
3
+ *
4
+ * `status` (nine states) is the lifecycle and the ONLY thing that decides
5
+ * whether a listing is public: `Listing.objects.published()` filters on it
6
+ * alone, `INDEXED_STATUSES` is `{published}`, and the model says so out loud
7
+ * ("no visibility-reads-moderation_status coupling", `models.py`).
8
+ * `moderation_status` (four states) is the content verdict and decides
9
+ * NOTHING about visibility.
10
+ *
11
+ * Since stapel-listings 0.5.0 the two genuinely diverge, and that divergence
12
+ * is the reason this file exists:
13
+ *
14
+ * first publication status draft → PENDING, moderation → pending
15
+ * (nothing public until a verdict arrives)
16
+ * editing a LIVE listing status published (UNCHANGED), moderation → pending
17
+ * (the edit is visible immediately; a rejecting
18
+ * verdict later lands as PUBLISHED → BLOCKED)
19
+ *
20
+ * A dashboard that computed "under re-review" from `status` would show
21
+ * nothing at all for the second row — the listing looks exactly as it did —
22
+ * and the owner would never learn their edit is being screened. A dashboard
23
+ * that computed "visible" from `moderation_status` would tell them their live
24
+ * listing is offline while strangers are reading it. Both mistakes are one
25
+ * `if` away, which is why the sentence a person reads is produced HERE, from
26
+ * BOTH fields, once.
27
+ *
28
+ * Everything below is pure: no React, no antd, no fetch. `test/status.test.ts`
29
+ * asserts the whole 9 × 4 table, so a state added upstream is a red test and
30
+ * not a bare enum value on a page.
31
+ */
32
+ import type {
33
+ ListingLifecycleStatus,
34
+ ListingModerationStatus,
35
+ } from "../api/types.js";
36
+ import { LISTING_TRANSITIONS } from "./transitions.js";
37
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
38
+
39
+ /**
40
+ * How a status should READ, independent of the words: neutral (nothing is
41
+ * happening), waiting (something is in progress and needs no action), good
42
+ * (live), warning (the owner has something to do), stopped (the listing is
43
+ * over, by choice or by verdict).
44
+ *
45
+ * A tone rather than a colour: `/default` maps it to antd tags, another skin
46
+ * maps it to whatever it maps it to, and the token layer stays the only place
47
+ * a hex exists.
48
+ */
49
+ export type ListingStatusTone =
50
+ | "neutral"
51
+ | "waiting"
52
+ | "good"
53
+ | "warning"
54
+ | "stopped";
55
+
56
+ /** The lifecycle half of what a person is told. */
57
+ export interface LifecycleCaption {
58
+ readonly status: ListingLifecycleStatus;
59
+ readonly labelKey: string;
60
+ readonly tone: ListingStatusTone;
61
+ /** Is the listing readable by a stranger right now? `published` and
62
+ * nothing else — the same predicate the queryset uses. */
63
+ readonly publiclyVisible: boolean;
64
+ }
65
+
66
+ const LIFECYCLE: Readonly<
67
+ Record<ListingLifecycleStatus, Omit<LifecycleCaption, "status">>
68
+ > = {
69
+ draft: {
70
+ labelKey: LISTINGS_I18N_KEYS.statusDraft,
71
+ tone: "neutral",
72
+ publiclyVisible: false,
73
+ },
74
+ pending: {
75
+ labelKey: LISTINGS_I18N_KEYS.statusPending,
76
+ tone: "waiting",
77
+ publiclyVisible: false,
78
+ },
79
+ published: {
80
+ labelKey: LISTINGS_I18N_KEYS.statusPublished,
81
+ tone: "good",
82
+ publiclyVisible: true,
83
+ },
84
+ paused: {
85
+ labelKey: LISTINGS_I18N_KEYS.statusPaused,
86
+ tone: "neutral",
87
+ publiclyVisible: false,
88
+ },
89
+ expired: {
90
+ labelKey: LISTINGS_I18N_KEYS.statusExpired,
91
+ tone: "warning",
92
+ publiclyVisible: false,
93
+ },
94
+ sold: {
95
+ labelKey: LISTINGS_I18N_KEYS.statusSold,
96
+ tone: "stopped",
97
+ publiclyVisible: false,
98
+ },
99
+ rejected: {
100
+ labelKey: LISTINGS_I18N_KEYS.statusRejected,
101
+ tone: "warning",
102
+ publiclyVisible: false,
103
+ },
104
+ blocked: {
105
+ labelKey: LISTINGS_I18N_KEYS.statusBlocked,
106
+ tone: "stopped",
107
+ publiclyVisible: false,
108
+ },
109
+ archived: {
110
+ labelKey: LISTINGS_I18N_KEYS.statusArchived,
111
+ tone: "neutral",
112
+ publiclyVisible: false,
113
+ },
114
+ };
115
+
116
+ /** The lifecycle caption for one status. */
117
+ export function lifecycleCaption(
118
+ status: ListingLifecycleStatus
119
+ ): LifecycleCaption {
120
+ return { status, ...LIFECYCLE[status] };
121
+ }
122
+
123
+ /**
124
+ * Is this listing readable by someone who is not its owner?
125
+ *
126
+ * One predicate, one field, matching the server's. Exported because the
127
+ * DETAIL read needs it and cannot get it from a 404: `GET /listings/{pk}/`
128
+ * has no `published()` filter on its queryset, so a draft answers 200 to a
129
+ * stranger who knows the id (upstream note in MODULE.md). The pair reports
130
+ * the fact instead of rendering a draft as if it were a shop window.
131
+ */
132
+ export function isPubliclyVisible(status: ListingLifecycleStatus): boolean {
133
+ return LIFECYCLE[status].publiclyVisible;
134
+ }
135
+
136
+ /** The owner-facing note about the moderation axis, when there is one. */
137
+ export interface ModerationNotice {
138
+ readonly moderationStatus: ListingModerationStatus;
139
+ readonly messageKey: string;
140
+ readonly tone: ListingStatusTone;
141
+ /**
142
+ * Is the listing live WHILE this verdict is pending? True only for the
143
+ * re-moderation row — a published listing whose edit is being screened.
144
+ * The sentence differs from a first submission's precisely here.
145
+ */
146
+ readonly liveDuringReview: boolean;
147
+ }
148
+
149
+ /**
150
+ * What to say about the moderation axis, given BOTH fields.
151
+ *
152
+ * `undefined` means "nothing to add": an approved listing needs no note, and
153
+ * neither does a draft nobody has submitted. Every other combination has a
154
+ * sentence, including the ones that look contradictory — those are the ones
155
+ * worth naming.
156
+ */
157
+ export function moderationNotice(
158
+ status: ListingLifecycleStatus,
159
+ moderationStatus: ListingModerationStatus
160
+ ): ModerationNotice | undefined {
161
+ const live = status === "published";
162
+
163
+ if (moderationStatus === "approved") {
164
+ // A takedown keeps `approved` on the moderation axis in some flows (the
165
+ // lifecycle moved, the verdict did not), so an approved BLOCKED listing
166
+ // still owes its owner an explanation — and it is the lifecycle's.
167
+ return undefined;
168
+ }
169
+
170
+ if (moderationStatus === "pending") {
171
+ if (live) {
172
+ return {
173
+ moderationStatus,
174
+ messageKey: LISTINGS_I18N_KEYS.moderationLiveEditPending,
175
+ tone: "waiting",
176
+ liveDuringReview: true,
177
+ };
178
+ }
179
+ if (status === "pending") {
180
+ return {
181
+ moderationStatus,
182
+ messageKey: LISTINGS_I18N_KEYS.moderationFirstReview,
183
+ tone: "waiting",
184
+ liveDuringReview: false,
185
+ };
186
+ }
187
+ // A draft/archived/sold row carrying a pending verdict: the submission
188
+ // was overtaken by a lifecycle move. Say that, rather than promising a
189
+ // review that will not put it back on the shelf.
190
+ return {
191
+ moderationStatus,
192
+ messageKey: LISTINGS_I18N_KEYS.moderationPendingOffline,
193
+ tone: "neutral",
194
+ liveDuringReview: false,
195
+ };
196
+ }
197
+
198
+ if (moderationStatus === "needs_review") {
199
+ return {
200
+ moderationStatus,
201
+ messageKey: live
202
+ ? LISTINGS_I18N_KEYS.moderationLiveNeedsReview
203
+ : LISTINGS_I18N_KEYS.moderationNeedsReview,
204
+ tone: "waiting",
205
+ liveDuringReview: live,
206
+ };
207
+ }
208
+
209
+ // rejected — and it matters whether the lifecycle followed the verdict.
210
+ return {
211
+ moderationStatus,
212
+ messageKey: live
213
+ ? LISTINGS_I18N_KEYS.moderationRejectedStillLive
214
+ : LISTINGS_I18N_KEYS.moderationRejected,
215
+ tone: "warning",
216
+ liveDuringReview: live,
217
+ };
218
+ }
219
+
220
+ /** Both halves at once — what a dashboard row and a detail header render. */
221
+ export interface ListingStatusView {
222
+ readonly lifecycle: LifecycleCaption;
223
+ readonly moderation: ModerationNotice | undefined;
224
+ /** True when the two axes disagree in the way 0.5.0 made possible: live to
225
+ * the public, under review for its owner. The one combination a host is
226
+ * most likely to get wrong, so it is a named boolean and not an inference
227
+ * a caller repeats. */
228
+ readonly liveUnderReview: boolean;
229
+ /** What the owner can do next, in lifecycle terms — see
230
+ * {@link LISTING_TRANSITIONS}. */
231
+ readonly allowedTransitions: readonly ListingLifecycleStatus[];
232
+ }
233
+
234
+ export function listingStatusView(
235
+ status: ListingLifecycleStatus,
236
+ moderationStatus: ListingModerationStatus
237
+ ): ListingStatusView {
238
+ const moderation = moderationNotice(status, moderationStatus);
239
+ return {
240
+ lifecycle: lifecycleCaption(status),
241
+ moderation,
242
+ liveUnderReview: moderation?.liveDuringReview === true,
243
+ allowedTransitions: LISTING_TRANSITIONS[status],
244
+ };
245
+ }
246
+
247
+ /**
248
+ * The dashboard tabs, and the statuses each one folds together.
249
+ *
250
+ * The grouping is the SERVER's (`views.my_counters`), copied here so a tab's
251
+ * caption and its count cannot describe different sets: `active` includes
252
+ * PENDING beside PUBLISHED, and `drafts` includes REJECTED beside DRAFT —
253
+ * both because a listing in those states is something the owner is still
254
+ * working on. A client that grouped them its own way would show "3 active"
255
+ * over two rows.
256
+ */
257
+ export const MY_LISTINGS_TABS = ["active", "drafts", "archived"] as const;
258
+
259
+ export type MyListingsTab = (typeof MY_LISTINGS_TABS)[number];
260
+
261
+ export const MY_LISTINGS_TAB_STATUSES: Readonly<
262
+ Record<MyListingsTab, readonly ListingLifecycleStatus[]>
263
+ > = {
264
+ active: ["published", "pending"],
265
+ drafts: ["draft", "rejected"],
266
+ archived: ["archived", "paused", "expired", "sold"],
267
+ };
268
+
269
+ /** Which tab a listing belongs to, or `undefined` for BLOCKED — the one
270
+ * status `my/counters` counts in no tab at all. A dashboard that silently
271
+ * dropped it would hide exactly the listing whose owner most needs to know. */
272
+ export function tabOf(
273
+ status: ListingLifecycleStatus
274
+ ): MyListingsTab | undefined {
275
+ for (const tab of MY_LISTINGS_TABS) {
276
+ if (MY_LISTINGS_TAB_STATUSES[tab].includes(status)) return tab;
277
+ }
278
+ return undefined;
279
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The lifecycle whitelist, mirrored from `stapel_listings.models
3
+ * .LISTING_TRANSITIONS`.
4
+ *
5
+ * ── A mirror, not a verdict ────────────────────────────────────────────────
6
+ *
7
+ * The server decides. `transition_to` raises `TransitionError` and the view
8
+ * answers **409 `error.409.invalid_listing_transition`** with
9
+ * `params.from_status`, and that refusal is rendered as the named sentence it
10
+ * is. What the mirror buys is the difference between a button that is there
11
+ * and does nothing and a button that is switched off WITH its reason: "you
12
+ * cannot mark a draft sold — publish it first" beats a 409 toast after the
13
+ * click, and beats a hidden control (which teaches nothing) by the whole
14
+ * private-space canon §6.3.
15
+ *
16
+ * The mirror is deliberately CONSERVATIVE in one direction only: it may never
17
+ * block something the server would allow. That is why it is a copy of the
18
+ * table rather than a hand-written summary of it, and why `test/status.test
19
+ * .ts` walks every source state.
20
+ *
21
+ * Two entries look odd and are correct:
22
+ * - `published → blocked` exists, but the owner API has no route to it: only
23
+ * `apply_moderation("rejected")` walks it. It is in the table because the
24
+ * table is the model's, and leaving it out would make the mirror a
25
+ * paraphrase.
26
+ * - `blocked → published` is reinstatement after a successful appeal, also
27
+ * moderation-driven. `archive` and `complete` are the only two transitions
28
+ * this pair's API can actually request.
29
+ */
30
+ import type { ListingLifecycleStatus } from "../api/types.js";
31
+
32
+ export const LISTING_TRANSITIONS: Readonly<
33
+ Record<ListingLifecycleStatus, readonly ListingLifecycleStatus[]>
34
+ > = {
35
+ draft: ["pending", "archived"],
36
+ pending: ["published", "rejected", "draft", "archived"],
37
+ published: ["paused", "expired", "sold", "blocked", "archived"],
38
+ blocked: ["published", "draft", "archived"],
39
+ paused: ["published", "archived", "expired"],
40
+ expired: ["pending", "published", "archived"],
41
+ sold: ["archived", "published"],
42
+ rejected: ["draft", "archived"],
43
+ archived: ["draft"],
44
+ };
45
+
46
+ /** Would the server accept this move? A same-status move is a no-op the
47
+ * server returns early on, so it counts as allowed. */
48
+ export function canTransition(
49
+ from: ListingLifecycleStatus,
50
+ to: ListingLifecycleStatus
51
+ ): boolean {
52
+ return from === to || LISTING_TRANSITIONS[from].includes(to);
53
+ }
54
+
55
+ /**
56
+ * Deleting is not a transition and has its own rule
57
+ * (`views.destroy`): a PUBLISHED or PENDING listing is refused with
58
+ * `error.409.listing_cannot_delete_active` — archive it first.
59
+ */
60
+ export function canDelete(status: ListingLifecycleStatus): boolean {
61
+ return status !== "published" && status !== "pending";
62
+ }