@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,86 @@
1
+ /**
2
+ * The two axes, drawn as two.
3
+ *
4
+ * `<LifecycleTag>` is the state; `<ModerationNote>` is what the moderation
5
+ * axis adds ON TOP of it, and it renders nothing when there is nothing to add
6
+ * (an approved listing owes its owner no note). Keeping them separate is what
7
+ * makes "Published" and "changes under review" appear side by side rather
8
+ * than one overwriting the other — the failure `model/status.ts` exists to
9
+ * prevent, made visual.
10
+ *
11
+ * A tone maps to an antd preset here and nowhere else. No hex leaves this
12
+ * file, because none enters it: the presets resolve through the theme, which
13
+ * resolves through `@stapel/tokens`.
14
+ */
15
+ import type { ReactElement } from "react";
16
+ import { Alert, Tag } from "antd";
17
+ import { useT } from "@stapel/core";
18
+ import type { ListingStatusTone, ListingStatusView } from "../model/status.js";
19
+
20
+ const TONE_COLOR: Readonly<Record<ListingStatusTone, string>> = {
21
+ neutral: "default",
22
+ waiting: "processing",
23
+ good: "success",
24
+ warning: "warning",
25
+ stopped: "error",
26
+ };
27
+
28
+ const TONE_ALERT: Readonly<
29
+ Record<ListingStatusTone, "info" | "success" | "warning" | "error">
30
+ > = {
31
+ neutral: "info",
32
+ waiting: "info",
33
+ good: "success",
34
+ warning: "warning",
35
+ stopped: "error",
36
+ };
37
+
38
+ export interface ListingStatusProps {
39
+ readonly status: ListingStatusView;
40
+ }
41
+
42
+ /** The lifecycle state — the field that decides whether anyone else can see
43
+ * this listing. */
44
+ export function LifecycleTag(props: ListingStatusProps): ReactElement {
45
+ const t = useT();
46
+ return (
47
+ <Tag
48
+ color={TONE_COLOR[props.status.lifecycle.tone]}
49
+ data-testid="listings-status-tag"
50
+ data-listing-status={props.status.lifecycle.status}
51
+ >
52
+ {t(props.status.lifecycle.labelKey)}
53
+ </Tag>
54
+ );
55
+ }
56
+
57
+ /**
58
+ * What the moderation axis adds. Renders `null` when it adds nothing, so a
59
+ * calm listing shows no banner at all.
60
+ */
61
+ export function ModerationNote(props: ListingStatusProps): ReactElement | null {
62
+ const t = useT();
63
+ const notice = props.status.moderation;
64
+ if (notice === undefined) return null;
65
+ return (
66
+ <Alert
67
+ type={TONE_ALERT[notice.tone]}
68
+ showIcon
69
+ data-testid="listings-moderation-note"
70
+ data-listing-moderation={notice.moderationStatus}
71
+ data-listing-live-under-review={String(notice.liveDuringReview)}
72
+ message={t(notice.messageKey)}
73
+ />
74
+ );
75
+ }
76
+
77
+ /** Both, in the order a person reads them: what it IS, then what is happening
78
+ * to it. */
79
+ export function ListingStatusBlock(props: ListingStatusProps): ReactElement {
80
+ return (
81
+ <>
82
+ <LifecycleTag status={props.status} />
83
+ <ModerationNote status={props.status} />
84
+ </>
85
+ );
86
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * One inline glyph: the favourite heart, filled and outlined.
3
+ *
4
+ * No `@ant-design/icons` dependency — the house convention (profiles-react's
5
+ * `icons.tsx`, shell-react's icon registry): a plain monochrome
6
+ * `currentColor` SVG, so it inherits the theme instead of carrying a colour
7
+ * of its own, and the pair stays one package lighter.
8
+ *
9
+ * `aria-hidden` on both: the button that holds them carries the label, and a
10
+ * glyph announced beside its own label reads the action twice.
11
+ */
12
+ import type { ReactElement } from "react";
13
+
14
+ export function HeartIcon(props: { filled: boolean }): ReactElement {
15
+ return (
16
+ <svg
17
+ width="16"
18
+ height="16"
19
+ viewBox="0 0 24 24"
20
+ fill={props.filled ? "currentColor" : "none"}
21
+ stroke="currentColor"
22
+ strokeWidth="2"
23
+ strokeLinecap="round"
24
+ strokeLinejoin="round"
25
+ role="img"
26
+ aria-hidden="true"
27
+ >
28
+ <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1L12 21l7.7-7.6 1.1-1a5.5 5.5 0 0 0 0-7.8z" />
29
+ </svg>
30
+ );
31
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `@stapel/listings-react/default` — the antd skin: the card another pair
3
+ * renders, the listing page, the composer and the seller's dashboard.
4
+ *
5
+ * A separate entry point (the convention every pair's `/default` follows) so
6
+ * a host rendering its own visuals over the bags never pulls `antd` into its
7
+ * bundle.
8
+ *
9
+ * ```tsx
10
+ * import { ListingsProvider, createListingsRuntime } from "@stapel/listings-react";
11
+ * import { ListingCard, ListingDetailPane } from "@stapel/listings-react/default";
12
+ *
13
+ * // the search pair's card slot — the container is the seam, not an import
14
+ * <SearchPage renderCard={(item) => <ListingCard listing={item.card} href={`/l/${item.id}`} />} />
15
+ * ```
16
+ */
17
+ export { ListingCard } from "./ListingCard.js";
18
+ export type { ListingCardProps } from "./ListingCard.js";
19
+ export { ListingDetailPane } from "./ListingDetailPane.js";
20
+ export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
21
+ export { ListingComposerPage } from "./ListingComposerPage.js";
22
+ export type { ListingComposerPageProps } from "./ListingComposerPage.js";
23
+ export { MyListingsPane } from "./MyListingsPane.js";
24
+ export type { MyListingsPaneProps } from "./MyListingsPane.js";
25
+ export { FavoritesPane } from "./FavoritesPane.js";
26
+ export type { FavoritesPaneProps } from "./FavoritesPane.js";
27
+
28
+ export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
29
+ export type { ListingStatusProps } from "./StatusTags.js";
30
+ export { ListingPhoto } from "./ListingPhoto.js";
31
+ export type { ListingPhotoProps } from "./ListingPhoto.js";
32
+ export { ErrorAlert } from "./ErrorAlert.js";
33
+ export { ListingsSkinTheme } from "./theme.js";
34
+ export type { ListingsSkinThemeProps } from "./theme.js";
35
+ export type { ThemeModeProp } from "./types.js";
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The default skin's INTERNAL theme provider — every `/default` surface wraps
3
+ * itself in this, so the skin is readable no matter what the host supplies
4
+ * (tracker #26: a skin set with no internal provider once inherited a theme
5
+ * bridge serving light-mode values inside a dark document — text on
6
+ * background at 1.00:1). The theme derives from `@stapel/tokens` via
7
+ * `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
8
+ * the HOST's document declares (`resolveThemeMode()` — the `data-theme`
9
+ * attribute `tokens.css` keys its dark block on), never to a hardcoded side.
10
+ *
11
+ * This is also what "override styles" means for this pair: a storefront
12
+ * retheming through the §68 token JSON regenerates its `--stapel-*` custom
13
+ * properties and the result page follows with zero code — a far better deal
14
+ * than a prop-per-colour API, and the reason the skin has none.
15
+ */
16
+ import { useMemo } from "react";
17
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
18
+ import { ConfigProvider } from "antd";
19
+ import {
20
+ resolveThemeMode,
21
+ toAntdTheme,
22
+ toAntdThemeConfig,
23
+ } from "@stapel/tokens-antd";
24
+ import type { ThemeMode } from "@stapel/tokens-antd";
25
+
26
+ export interface ListingsSkinThemeProps {
27
+ /**
28
+ * Light or dark. Defaults to the mode the host's document declares
29
+ * (`resolveThemeMode()`). Pass explicitly to pin a side.
30
+ */
31
+ readonly mode?: ThemeMode;
32
+ /** Extra styles merged onto the themed root element (layout only — colors
33
+ * come from the mode). */
34
+ readonly style?: CSSProperties;
35
+ readonly children: ReactNode;
36
+ }
37
+
38
+ /**
39
+ * `<ListingsSkinTheme/>` — the self-theming wrapper of
40
+ * `@stapel/listings-react/default`. Standalone use is supported (wrap any
41
+ * composition of the skin's parts once); the shipped surfaces
42
+ * (`ListingCard`, `ListingDetailPane`, `ListingComposerPage`,
43
+ * `MyListingsPane`, `FavoritesPane`)
44
+ * each already wrap themselves, and nested antd
45
+ * `ConfigProvider`s merge, so composing them under one `ListingsSkinTheme`
46
+ * stays correct.
47
+ */
48
+ export function ListingsSkinTheme(props: ListingsSkinThemeProps): ReactElement {
49
+ const mode = props.mode ?? resolveThemeMode();
50
+ const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
51
+ const token = useMemo(() => toAntdTheme(mode), [mode]);
52
+ return (
53
+ <ConfigProvider theme={theme}>
54
+ <div
55
+ data-listings-skin-root
56
+ data-listings-skin-mode={mode}
57
+ style={{
58
+ color: token.colorText,
59
+ backgroundColor: token.colorBgContainer,
60
+ ...props.style,
61
+ }}
62
+ >
63
+ {props.children}
64
+ </div>
65
+ </ConfigProvider>
66
+ );
67
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Small shared types for the `/default` skin — kept in one place so every
3
+ * surface takes the same `mode` prop and re-exports the same error dialect.
4
+ */
5
+ export type { FlowError } from "@stapel/core";
6
+ import type { ThemeMode } from "@stapel/tokens-antd";
7
+
8
+ /** Every `/default` surface accepts a theme mode; absent means "whatever the
9
+ * host document declares" (`resolveThemeMode()`), never a hardcoded side. */
10
+ export interface ThemeModeProp {
11
+ readonly mode?: ThemeMode;
12
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Zero-flow registry shim. stapel-listings annotates no `@flow_step`: its
3
+ * `docs/flows.json` is an empty array, so `gen:flows` skips emission for this
4
+ * pair (no `flows/generated/`). This hand-written shim preserves the pair's
5
+ * public flow-registry surface at its zero-flow shape — the stapel-chat /
6
+ * stapel-cdn / stapel-categories precedent, three directories over.
7
+ *
8
+ * Submitting a listing IS multi-step, and it is worth saying why that does
9
+ * not contradict the empty artifact. The steps —
10
+ *
11
+ * create draft → save draft (repeatedly) → validate → publish
12
+ *
13
+ * — are four independent endpoint calls that the server does not sequence:
14
+ * nothing on the backend remembers that a `save-draft` was part of a
15
+ * submission, `publish` may be called on a draft created a week ago, and
16
+ * `validate-draft` is optional. A `@flow_step`-annotated funnel would be a
17
+ * frontend fiction in a machine-readable artifact. The pair's real state
18
+ * machine lives in `headless/ListingComposer.tsx`, and its stages are named
19
+ * there ({@link ComposeStage}) so analytics can still follow the funnel.
20
+ *
21
+ * When the backend annotates flows, `pnpm gen:flows` emits
22
+ * `./generated/flows.gen.ts` again — replace these exports with re-exports
23
+ * from it (the shapes match by construction) and delete this file.
24
+ */
25
+ export const LISTINGS_FLOWS = {} as const;
26
+
27
+ /** Canonical flow ids present in flows.json (none yet — see above). */
28
+ export type ListingsFlowId = keyof typeof LISTINGS_FLOWS;
29
+
30
+ export type ListingsFlowSpec = (typeof LISTINGS_FLOWS)[ListingsFlowId];
31
+
32
+ export interface FlowEndpoint {
33
+ readonly method: string;
34
+ readonly path: string;
35
+ }
36
+
37
+ /** All HTTP endpoints a flow touches, in step order (for the contract test). */
38
+ export function flowEndpoints(id: ListingsFlowId): readonly FlowEndpoint[] {
39
+ // Same widened body as the generated registry's — valid for the zero-flow
40
+ // shape AND correct once flows exist.
41
+ const spec = LISTINGS_FLOWS[id] as
42
+ | { readonly steps: readonly { readonly endpoints: readonly FlowEndpoint[] }[] }
43
+ | undefined;
44
+ return spec ? spec.steps.flatMap((step) => step.endpoints) : [];
45
+ }
@@ -0,0 +1,137 @@
1
+ import { useState } from "react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import {
4
+ actionAvailable,
5
+ actionBlocked,
6
+ firstBlock,
7
+ loadFailed,
8
+ loadLoading,
9
+ loadReady,
10
+ } from "@stapel/core";
11
+ import type { ActionAvailability, LoadState } from "@stapel/core";
12
+ import type { ListingCard, ListingPageParams } from "../api/types.js";
13
+ import { useMyFavorites } from "../model/queries.js";
14
+ import { useFavoriteListing } from "../model/mutations.js";
15
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
16
+ import { useMandateGate } from "./useMandateGate.js";
17
+
18
+ /**
19
+ * Saving something for later — first-class in stapel-listings (a `Favorite`
20
+ * model, three endpoints) and therefore first-class here (owner verdict F7).
21
+ *
22
+ * Two surfaces over one contract: {@link useFavoriteToggle} is the heart on a
23
+ * card, {@link useFavorites} is the page listing what was saved.
24
+ */
25
+
26
+ export interface FavoriteToggleBag {
27
+ readonly favorited: boolean;
28
+ /** Blocked with the reason for a visitor, a guest, an unknown mandate, or
29
+ * while the previous toggle is still in flight. Never a hidden heart: a
30
+ * control that disappears for a visitor teaches them nothing, and the CTA
31
+ * to sign in is the whole point of showing it (spec §6.2 item 6). */
32
+ readonly gate: ActionAvailability;
33
+ toggle(): void;
34
+ readonly inFlight: boolean;
35
+ readonly error: unknown;
36
+ }
37
+
38
+ /**
39
+ * The heart on one card.
40
+ *
41
+ * `favorited` is the caller's — `ListingCard.is_favorited` comes down with
42
+ * every card, so a grid needs no extra read. It is `null` for an anonymous
43
+ * reader (`with_favorited` annotates `Value(None)`), which is a THIRD state
44
+ * and not `false`: "we did not ask" versus "not saved". The gate is what
45
+ * renders it, so the distinction never has to be squeezed into the boolean.
46
+ */
47
+ export function useFavoriteToggle(
48
+ id: number,
49
+ favorited: boolean | null | undefined
50
+ ): FavoriteToggleBag {
51
+ const mandate = useMandateGate();
52
+ const mutation = useFavoriteListing();
53
+ const gate = firstBlock(
54
+ mandate,
55
+ mutation.isPending
56
+ ? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
57
+ : actionAvailable()
58
+ );
59
+ return {
60
+ favorited: favorited === true,
61
+ gate,
62
+ toggle: () => {
63
+ if (!gate.available) return;
64
+ mutation.mutate({ id, favorited: favorited !== true });
65
+ },
66
+ inFlight: mutation.isPending,
67
+ error: mutation.error,
68
+ };
69
+ }
70
+
71
+ export interface FavoritesBag {
72
+ readonly rows: LoadState<readonly ListingCard[]>;
73
+ readonly page: ListingPageParams;
74
+ readonly nextPage: ActionAvailability;
75
+ readonly prevPage: ActionAvailability;
76
+ goNext(): void;
77
+ goPrev(): void;
78
+ readonly gate: ActionAvailability;
79
+ refetch(): void;
80
+ }
81
+
82
+ export interface UseFavoritesOptions {
83
+ readonly limit?: number;
84
+ }
85
+
86
+ /** The favourites page: a real keyset list, unlike the owner's own listings
87
+ * (see `MyListings.tsx` for why those are different). */
88
+ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
89
+ const gate = useMandateGate();
90
+ const [page, setPage] = useState<ListingPageParams>(
91
+ options.limit !== undefined ? { limit: options.limit } : {}
92
+ );
93
+ const query = useMyFavorites(page, { enabled: gate.available });
94
+ const envelope = query.data;
95
+
96
+ return {
97
+ rows:
98
+ query.status === "error"
99
+ ? loadFailed(query.error)
100
+ : envelope !== undefined
101
+ ? loadReady(envelope.items)
102
+ : loadLoading(),
103
+ page,
104
+ nextPage:
105
+ envelope?.has_next === true && envelope.next_anchor != null
106
+ ? actionAvailable()
107
+ : actionBlocked(LISTINGS_I18N_KEYS.pageNext),
108
+ prevPage:
109
+ envelope?.has_prev === true && envelope.prev_anchor != null
110
+ ? actionAvailable()
111
+ : actionBlocked(LISTINGS_I18N_KEYS.pagePrev),
112
+ goNext: () => {
113
+ const anchor = envelope?.next_anchor;
114
+ if (anchor == null) return;
115
+ setPage((current) => ({ ...current, anchor, direction: "next" }));
116
+ },
117
+ goPrev: () => {
118
+ const anchor = envelope?.prev_anchor;
119
+ if (anchor == null) return;
120
+ setPage((current) => ({ ...current, anchor, direction: "prev" }));
121
+ },
122
+ gate,
123
+ refetch: () => {
124
+ void query.refetch();
125
+ },
126
+ };
127
+ }
128
+
129
+ /** Renderless: the bag, handed to a render prop. */
130
+ export function Favorites(
131
+ props: UseFavoritesOptions & {
132
+ children: (bag: FavoritesBag) => ReactNode;
133
+ }
134
+ ): ReactElement {
135
+ const bag = useFavorites(props);
136
+ return <>{props.children(bag)}</>;
137
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ actionAvailable,
3
+ actionBlocked,
4
+ firstBlock,
5
+ } from "@stapel/core";
6
+ import type { ActionAvailability } from "@stapel/core";
7
+ import type { ListingLifecycleStatus } from "../api/types.js";
8
+ import {
9
+ useArchiveListing,
10
+ useCompleteListing,
11
+ useDeleteListing,
12
+ } from "../model/mutations.js";
13
+ import { canDelete, canTransition } from "../model/transitions.js";
14
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
15
+ import { useMandateGate } from "./useMandateGate.js";
16
+
17
+ /**
18
+ * The three lifecycle moves an owner can actually request, each behind a gate
19
+ * that states its reason.
20
+ *
21
+ * ── The mirror is UX, the 409 is the verdict ───────────────────────────────
22
+ *
23
+ * `LISTING_TRANSITIONS` (`model/transitions.ts`) is a copy of the server's
24
+ * whitelist, and it exists so a control can be switched off WITH a sentence:
25
+ * "a listing that is sold cannot be archived that way" beats a toast after
26
+ * the click. The server still decides — `transition_to` raises and the view
27
+ * answers 409 `error.409.invalid_listing_transition` with
28
+ * `params.from_status` — and that refusal is rendered as the named thing it
29
+ * is. The mirror may never block what the server would allow, which is why it
30
+ * is a copy of the table and not a summary of it.
31
+ */
32
+ export interface ListingActionsBag {
33
+ readonly archive: ActionAvailability;
34
+ readonly complete: ActionAvailability;
35
+ readonly remove: ActionAvailability;
36
+ doArchive(): void;
37
+ doComplete(): void;
38
+ doRemove(): void;
39
+ readonly inFlight: boolean;
40
+ /** The last refusal from any of the three, in the one error dialect. */
41
+ readonly error: unknown;
42
+ }
43
+
44
+ export function useListingActions(
45
+ id: number,
46
+ status: ListingLifecycleStatus | undefined
47
+ ): ListingActionsBag {
48
+ const mandate = useMandateGate();
49
+ const archive = useArchiveListing();
50
+ const complete = useCompleteListing();
51
+ const remove = useDeleteListing();
52
+
53
+ const inFlight = archive.isPending || complete.isPending || remove.isPending;
54
+ const busy: ActionAvailability = inFlight
55
+ ? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
56
+ : actionAvailable();
57
+
58
+ // An unknown status blocks with the loading reason rather than guessing:
59
+ // the whitelist cannot be consulted for a state nobody has read yet.
60
+ const known: ActionAvailability =
61
+ status === undefined
62
+ ? actionBlocked(LISTINGS_I18N_KEYS.detailLoading)
63
+ : actionAvailable();
64
+
65
+ function transitionGate(to: ListingLifecycleStatus): ActionAvailability {
66
+ if (status === undefined) return known;
67
+ return canTransition(status, to)
68
+ ? actionAvailable()
69
+ : actionBlocked(LISTINGS_I18N_KEYS.blockedTransition, {
70
+ from_status: status,
71
+ });
72
+ }
73
+
74
+ const archiveGate = firstBlock(mandate, known, busy, transitionGate("archived"));
75
+ const completeGate = firstBlock(mandate, known, busy, transitionGate("sold"));
76
+ const removeGate = firstBlock(
77
+ mandate,
78
+ known,
79
+ busy,
80
+ status !== undefined && !canDelete(status)
81
+ ? actionBlocked(LISTINGS_I18N_KEYS.blockedDeleteActive)
82
+ : actionAvailable()
83
+ );
84
+
85
+ return {
86
+ archive: archiveGate,
87
+ complete: completeGate,
88
+ remove: removeGate,
89
+ doArchive: () => {
90
+ if (archiveGate.available) archive.mutate(id);
91
+ },
92
+ doComplete: () => {
93
+ if (completeGate.available) complete.mutate(id);
94
+ },
95
+ doRemove: () => {
96
+ if (removeGate.available) remove.mutate(id);
97
+ },
98
+ inFlight,
99
+ error: archive.error ?? complete.error ?? remove.error,
100
+ };
101
+ }