@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,271 @@
1
+ /**
2
+ * `<ListingDetailPane>` — the listing page.
3
+ *
4
+ * Four distinct absences, four distinct sentences, and the whole point of the
5
+ * component is that none of them collapses into another:
6
+ *
7
+ * loading — we are asking
8
+ * failed — we could not ask (retry, never "nothing here")
9
+ * not found — no listing ever had this id
10
+ * removed — one did, and it is gone (the AllowAny status probe
11
+ * is the only read that can still say so)
12
+ *
13
+ * On top of that, a listing that IS returned may still not be on sale: the
14
+ * detail endpoint has no `published()` filter, so a draft answers 200 to
15
+ * anyone holding the id. The pane says which side of that it is on rather
16
+ * than dressing a draft up as a shop page.
17
+ */
18
+ import type { ReactElement, ReactNode } from "react";
19
+ import { Alert, Button, Descriptions, Divider, Flex, Space, Spin, Typography } from "antd";
20
+ import { matchLoad, useDescribeFlowError, useT, useI18n } from "@stapel/core";
21
+ import { FeatureValueList } from "@stapel/attributes-react/default";
22
+ import { formatFeatureValue } from "@stapel/attributes-react";
23
+ import { useListingDetail } from "../headless/ListingDetail.js";
24
+ import { asFeatureDaoList, featuresDtoFromDaoList } from "../model/features.js";
25
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
26
+ import { ErrorAlert } from "./ErrorAlert.js";
27
+ import { HeartIcon } from "./icons.js";
28
+ import { ListingPhoto } from "./ListingPhoto.js";
29
+ import { ListingStatusBlock } from "./StatusTags.js";
30
+ import { ListingsSkinTheme } from "./theme.js";
31
+ import type { ThemeModeProp } from "./types.js";
32
+
33
+ export interface ListingDetailPaneProps extends ThemeModeProp {
34
+ readonly id: number;
35
+ /** The reader's own uuid, when the host knows it. Enables the owner view —
36
+ * the only place the moderation axis is shown, because it is the only
37
+ * person it concerns. */
38
+ readonly viewerId?: string;
39
+ /** Slots the container fills: "message the seller" (chat-react), the
40
+ * seller's profile link, reviews. Cross-pair navigation is the container's
41
+ * job (spec §6.2 item 5), so this pair takes nodes rather than routes. */
42
+ readonly actions?: ReactNode;
43
+ readonly footer?: ReactNode;
44
+ }
45
+
46
+ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
47
+ const t = useT();
48
+ const { locale } = useI18n();
49
+ const describe = useDescribeFlowError();
50
+ const bag = useListingDetail(
51
+ props.id,
52
+ props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
53
+ );
54
+
55
+ return (
56
+ <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
57
+ <Flex vertical gap={16} data-testid="listings-detail">
58
+ {bag.removed ? (
59
+ <Alert
60
+ type="warning"
61
+ showIcon
62
+ data-testid="listings-detail-removed"
63
+ message={t(LISTINGS_I18N_KEYS.detailRemoved)}
64
+ />
65
+ ) : null}
66
+
67
+ {matchLoad(bag.state, {
68
+ loading: () => (
69
+ <Flex justify="center" data-testid="listings-detail-loading">
70
+ <Spin aria-label={t(LISTINGS_I18N_KEYS.detailLoading)} />
71
+ </Flex>
72
+ ),
73
+ failed: () =>
74
+ bag.removed ? null : (
75
+ <ErrorAlert
76
+ testId="listings-detail-error"
77
+ error={describe({
78
+ code: bag.notFound
79
+ ? LISTINGS_I18N_KEYS.detailNotFound
80
+ : LISTINGS_I18N_KEYS.detailLoadFailed,
81
+ params: {},
82
+ status: 0,
83
+ message: undefined,
84
+ language: undefined,
85
+ })}
86
+ action={
87
+ <Button
88
+ size="small"
89
+ data-analytics="none"
90
+ data-analytics-reason="retrying a read the person already asked for; not a business action"
91
+ onClick={bag.refetch}
92
+ >
93
+ {t(LISTINGS_I18N_KEYS.detailRetry)}
94
+ </Button>
95
+ }
96
+ />
97
+ ),
98
+ ready: (listing) => (
99
+ <>
100
+ {/* The moderation axis is the OWNER's business and nobody
101
+ else's: a buyer has no use for "changes under review", and
102
+ showing a stranger that a listing was refused would leak a
103
+ verdict about someone else's content. */}
104
+ {bag.viewerIsOwner === true && bag.status !== undefined ? (
105
+ <Flex vertical gap={8} data-testid="listings-detail-owner-view">
106
+ <ListingStatusBlock status={bag.status} />
107
+ {!bag.publiclyVisible ? (
108
+ <Alert
109
+ type="info"
110
+ showIcon
111
+ message={t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
112
+ />
113
+ ) : null}
114
+ </Flex>
115
+ ) : null}
116
+
117
+ {bag.viewerIsOwner !== true && !bag.publiclyVisible ? (
118
+ <Alert
119
+ type="warning"
120
+ showIcon
121
+ data-testid="listings-detail-not-published"
122
+ message={t(LISTINGS_I18N_KEYS.detailNotPublished)}
123
+ />
124
+ ) : null}
125
+
126
+ <Flex gap={16} wrap>
127
+ {bag.images.length === 0 ? (
128
+ <ListingPhoto
129
+ imageRef={undefined}
130
+ alt={listing.title ?? String(listing.id)}
131
+ style={{ width: 320, aspectRatio: "4 / 3" }}
132
+ />
133
+ ) : (
134
+ bag.images.map((ref, index) => (
135
+ <ListingPhoto
136
+ key={ref}
137
+ imageRef={ref}
138
+ alt={t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
139
+ index: index + 1,
140
+ total: bag.images.length,
141
+ })}
142
+ style={{ width: 320, aspectRatio: "4 / 3" }}
143
+ />
144
+ ))
145
+ )}
146
+ </Flex>
147
+
148
+ <Typography.Title level={3} data-testid="listings-detail-title">
149
+ {listing.title ?? ""}
150
+ </Typography.Title>
151
+
152
+ {/* The `show_at_title` projection, formatted from the stored
153
+ DAOs — no category read needed (see model/features.ts). */}
154
+ {bag.titleFeatures.length > 0 ? (
155
+ <Typography.Text type="secondary" data-testid="listings-detail-title-features">
156
+ {bag.titleFeatures
157
+ .map((view) =>
158
+ formatFeatureValue(view.feature, view.value, { t, locale })
159
+ )
160
+ .filter((text): text is string => text !== undefined)
161
+ .join(" · ")}
162
+ </Typography.Text>
163
+ ) : null}
164
+
165
+ <Typography.Title level={4} data-testid="listings-detail-price">
166
+ {listing.price !== undefined && listing.price.length > 0
167
+ ? `${listing.price} ${listing.currency ?? ""}`.trim()
168
+ : t(LISTINGS_I18N_KEYS.cardPriceAbsent)}
169
+ </Typography.Title>
170
+
171
+ <Space wrap>
172
+ <Button
173
+ disabled={!bag.favoriteGate.available}
174
+ aria-label={t(
175
+ bag.isFavorited === true
176
+ ? LISTINGS_I18N_KEYS.cardFavoriteRemove
177
+ : LISTINGS_I18N_KEYS.cardFavoriteAdd
178
+ )}
179
+ aria-pressed={bag.isFavorited === true}
180
+ icon={<HeartIcon filled={bag.isFavorited === true} />}
181
+ data-testid="listings-detail-favorite"
182
+ data-analytics="none"
183
+ data-analytics-reason="business action — host app wraps with its own tracked()"
184
+ onClick={bag.toggleFavorite}
185
+ >
186
+ {t(
187
+ bag.isFavorited === true
188
+ ? LISTINGS_I18N_KEYS.cardFavoriteRemove
189
+ : LISTINGS_I18N_KEYS.cardFavoriteAdd
190
+ )}
191
+ </Button>
192
+ {props.actions}
193
+ </Space>
194
+
195
+ {/* A switched-off control that cannot say why is the one shape
196
+ ActionAvailability makes unwritable — so the reason is
197
+ rendered, not implied by the disabled attribute. */}
198
+ {!bag.favoriteGate.available ? (
199
+ <Typography.Text
200
+ type="secondary"
201
+ data-testid="listings-detail-favorite-blocked"
202
+ >
203
+ {t(
204
+ bag.favoriteGate.block.code,
205
+ bag.favoriteGate.block.params
206
+ )}
207
+ </Typography.Text>
208
+ ) : null}
209
+
210
+ <Divider />
211
+
212
+ <Typography.Title level={5}>
213
+ {t(LISTINGS_I18N_KEYS.detailDescription)}
214
+ </Typography.Title>
215
+ <Typography.Paragraph data-testid="listings-detail-description">
216
+ {listing.description ?? ""}
217
+ </Typography.Paragraph>
218
+
219
+ <Typography.Title level={5}>
220
+ {t(LISTINGS_I18N_KEYS.detailSpecs)}
221
+ </Typography.Title>
222
+ {bag.features.length === 0 ? (
223
+ <Typography.Text type="secondary" data-testid="listings-detail-no-specs">
224
+ {t(LISTINGS_I18N_KEYS.detailNoSpecs)}
225
+ </Typography.Text>
226
+ ) : (
227
+ <FeatureValueList
228
+ features={bag.features.map((view) => view.feature)}
229
+ values={featuresDtoFromDaoList(
230
+ asFeatureDaoList(listing.features)
231
+ )}
232
+ />
233
+ )}
234
+
235
+ {/* Counted, not rounded to zero: a stored attribute this build
236
+ cannot key is a gap in what the buyer is being told. */}
237
+ {bag.unreadableFeatures > 0 ? (
238
+ <Alert
239
+ type="warning"
240
+ showIcon
241
+ data-testid="listings-detail-unreadable"
242
+ message={t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
243
+ count: bag.unreadableFeatures,
244
+ })}
245
+ />
246
+ ) : null}
247
+
248
+ <Descriptions size="small" column={1}>
249
+ {listing.stock_quantity != null ? (
250
+ <Descriptions.Item label={t(LISTINGS_I18N_KEYS.detailStock)}>
251
+ {listing.stock_quantity}
252
+ </Descriptions.Item>
253
+ ) : null}
254
+ {listing.location_label !== undefined &&
255
+ listing.location_label.length > 0 ? (
256
+ <Descriptions.Item
257
+ label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
258
+ >
259
+ {listing.location_label}
260
+ </Descriptions.Item>
261
+ ) : null}
262
+ </Descriptions>
263
+
264
+ {props.footer}
265
+ </>
266
+ ),
267
+ })}
268
+ </Flex>
269
+ </ListingsSkinTheme>
270
+ );
271
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A stored CDN reference, drawn — or the reason it cannot be.
3
+ *
4
+ * `Listing.images` carries opaque `<type>/<hash>` strings and no contract in
5
+ * this fleet resolves a stranger's reference (`model/runtime.ts` argues it at
6
+ * length). So this component asks the runtime's `resolveImage`, and when
7
+ * there is no resolver — or the resolver has nothing for this reference — it
8
+ * says so instead of emitting a broken `<img>`. An empty grey box that never
9
+ * loads teaches a person nothing; a sentence gets the wiring fixed.
10
+ *
11
+ * When the resolver DOES answer, `@stapel/image`'s `<Image>` takes over: it
12
+ * measures the slot, picks the variant tier that fits, blur-ups from
13
+ * `preview_b64` and never downgrades on a re-measure. None of that logic
14
+ * belongs here — it is why `@stapel/image` exists.
15
+ */
16
+ import type { CSSProperties, ReactElement } from "react";
17
+ import { Empty } from "antd";
18
+ import { Image } from "@stapel/image";
19
+ import { useT } from "@stapel/core";
20
+ import { useListingsRuntime } from "../model/context.js";
21
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
22
+
23
+ export interface ListingPhotoProps {
24
+ /** The stored reference, or `undefined` for a listing with no photos. */
25
+ readonly imageRef: string | undefined;
26
+ /** Alt text — required, and the caller has the context to write it. */
27
+ readonly alt: string;
28
+ readonly style?: CSSProperties;
29
+ }
30
+
31
+ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
32
+ const t = useT();
33
+ const runtime = useListingsRuntime();
34
+ const meta =
35
+ props.imageRef === undefined || runtime.resolveImage === undefined
36
+ ? undefined
37
+ : runtime.resolveImage(props.imageRef);
38
+
39
+ if (meta === undefined) {
40
+ return (
41
+ <Empty
42
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
43
+ data-testid="listings-photo-absent"
44
+ description={t(
45
+ props.imageRef === undefined
46
+ ? LISTINGS_I18N_KEYS.cardNoPhoto
47
+ : LISTINGS_I18N_KEYS.cardPhotoUnavailable
48
+ )}
49
+ {...(props.style ? { style: props.style } : {})}
50
+ />
51
+ );
52
+ }
53
+
54
+ return (
55
+ <Image
56
+ meta={meta}
57
+ alt={props.alt}
58
+ data-testid="listings-photo"
59
+ {...(props.style ? { style: props.style } : {})}
60
+ />
61
+ );
62
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * `<MyListingsPane>` — the seller's dashboard.
3
+ *
4
+ * Three counts that are real, and rows that may not be: stapel-listings 0.6.1
5
+ * has no owner-scoped list endpoint (`headless/MyListings.tsx` argues it in
6
+ * full), so the rows come from an injected source and the pane NAMES the gap
7
+ * when there is none. The counters are shown either way, because they are the
8
+ * one thing the contract can actually answer.
9
+ *
10
+ * Each row carries both axes. "Published" and "changes under review" appear
11
+ * beside each other, which is the entire reason `model/status.ts` exists: a
12
+ * dashboard that derived one from the other would tell a seller their live
13
+ * listing is offline, or never tell them their edit is being screened.
14
+ */
15
+ import type { ReactElement } from "react";
16
+ import {
17
+ Alert,
18
+ Badge,
19
+ Button,
20
+ Empty,
21
+ Flex,
22
+ List,
23
+ Space,
24
+ Spin,
25
+ Tabs,
26
+ Tooltip,
27
+ Typography,
28
+ } from "antd";
29
+ import { matchList, matchLoad, useDescribeFlowError, useT } from "@stapel/core";
30
+ import type { ActionAvailability } from "@stapel/core";
31
+ import type { ListingCard as ListingCardData } from "../api/types.js";
32
+ import { useMyListings } from "../headless/MyListings.js";
33
+ import type { MyListingsSource } from "../model/mineSource.js";
34
+ import { useListingActions } from "../headless/ListingActions.js";
35
+ import { listingStatusView } from "../model/status.js";
36
+ import type { MyListingsTab } from "../model/status.js";
37
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
38
+ import { ErrorAlert } from "./ErrorAlert.js";
39
+ import { ListingStatusBlock } from "./StatusTags.js";
40
+ import { ListingsSkinTheme } from "./theme.js";
41
+ import type { ThemeModeProp } from "./types.js";
42
+
43
+ const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
44
+ active: LISTINGS_I18N_KEYS.mineTabActive,
45
+ drafts: LISTINGS_I18N_KEYS.mineTabDrafts,
46
+ archived: LISTINGS_I18N_KEYS.mineTabArchived,
47
+ };
48
+
49
+ /** A control plus the sentence that explains it when it is off. Written once
50
+ * here because this pane has six of them and a grey button with no reason is
51
+ * the one shape `ActionAvailability` exists to make impossible. */
52
+ function GatedButton(props: {
53
+ gate: ActionAvailability;
54
+ label: string;
55
+ testId: string;
56
+ onClick: () => void;
57
+ danger?: boolean;
58
+ /** The call sites mark themselves `data-analytics="none"` with a
59
+ * `passthrough` reason (the convention `stapel/clickable-needs-event`
60
+ * documents for a forwarding component); the real click point is the antd
61
+ * button below, which declares its own outcome. */
62
+ "data-analytics"?: string;
63
+ "data-analytics-reason"?: string;
64
+ }): ReactElement {
65
+ const t = useT();
66
+ return (
67
+ <Tooltip
68
+ title={
69
+ props.gate.available
70
+ ? props.label
71
+ : t(props.gate.block.code, props.gate.block.params)
72
+ }
73
+ >
74
+ <span>
75
+ <Button
76
+ size="small"
77
+ danger={props.danger === true}
78
+ disabled={!props.gate.available}
79
+ data-testid={props.testId}
80
+ data-analytics="none"
81
+ data-analytics-reason="business action — host app wraps with its own tracked()"
82
+ onClick={props.onClick}
83
+ >
84
+ {props.label}
85
+ </Button>
86
+ </span>
87
+ </Tooltip>
88
+ );
89
+ }
90
+
91
+ function MyListingRow(props: {
92
+ listing: ListingCardData;
93
+ onEdit?: (id: number) => void;
94
+ }): ReactElement {
95
+ const t = useT();
96
+ const describe = useDescribeFlowError();
97
+ const actions = useListingActions(props.listing.id, props.listing.status);
98
+ // The card list carries `status` but NOT `moderation_status`
99
+ // (`ListingCardSerializer` omits it), so a row shows the moderation axis
100
+ // only through the one thing the projection does say. Pending is the
101
+ // honest default for the notice table's second argument here: it is the
102
+ // value that produces a note only when the lifecycle also implies one.
103
+ const status =
104
+ props.listing.status === undefined
105
+ ? undefined
106
+ : listingStatusView(props.listing.status, "approved");
107
+
108
+ return (
109
+ <List.Item
110
+ data-testid="listings-mine-row"
111
+ data-listing-id={props.listing.id}
112
+ actions={[
113
+ <GatedButton
114
+ key="edit"
115
+ data-analytics="none"
116
+ data-analytics-reason="passthrough — GatedButton owns the real click point"
117
+ gate={{ available: true }}
118
+ label={t(LISTINGS_I18N_KEYS.mineEdit)}
119
+ testId="listings-mine-edit"
120
+ onClick={() => {
121
+ props.onEdit?.(props.listing.id);
122
+ }}
123
+ />,
124
+ <GatedButton
125
+ key="complete"
126
+ data-analytics="none"
127
+ data-analytics-reason="passthrough — GatedButton owns the real click point"
128
+ gate={actions.complete}
129
+ label={t(LISTINGS_I18N_KEYS.mineComplete)}
130
+ testId="listings-mine-complete"
131
+ onClick={actions.doComplete}
132
+ />,
133
+ <GatedButton
134
+ key="archive"
135
+ data-analytics="none"
136
+ data-analytics-reason="passthrough — GatedButton owns the real click point"
137
+ gate={actions.archive}
138
+ label={t(LISTINGS_I18N_KEYS.mineArchive)}
139
+ testId="listings-mine-archive"
140
+ onClick={actions.doArchive}
141
+ />,
142
+ <GatedButton
143
+ key="delete"
144
+ data-analytics="none"
145
+ data-analytics-reason="passthrough — GatedButton owns the real click point"
146
+ danger
147
+ gate={actions.remove}
148
+ label={t(LISTINGS_I18N_KEYS.mineDelete)}
149
+ testId="listings-mine-delete"
150
+ onClick={actions.doRemove}
151
+ />,
152
+ ]}
153
+ >
154
+ <List.Item.Meta
155
+ title={props.listing.title ?? String(props.listing.id)}
156
+ description={
157
+ <Flex vertical gap={4}>
158
+ {status !== undefined ? <ListingStatusBlock status={status} /> : null}
159
+ {actions.error !== undefined && actions.error !== null ? (
160
+ <ErrorAlert
161
+ testId="listings-mine-action-error"
162
+ error={describe({
163
+ code: LISTINGS_I18N_KEYS.unknownError,
164
+ params: {},
165
+ status: 0,
166
+ message: undefined,
167
+ language: undefined,
168
+ })}
169
+ />
170
+ ) : null}
171
+ </Flex>
172
+ }
173
+ />
174
+ </List.Item>
175
+ );
176
+ }
177
+
178
+ export interface MyListingsPaneProps extends ThemeModeProp {
179
+ /** How the host gets the caller's own rows. Absent: the pane says the
180
+ * contract cannot answer, and still shows the counts. */
181
+ readonly source?: MyListingsSource;
182
+ readonly onEdit?: (id: number) => void;
183
+ }
184
+
185
+ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
186
+ const t = useT();
187
+ const describe = useDescribeFlowError();
188
+ const bag = useMyListings(
189
+ props.source !== undefined ? { source: props.source } : {}
190
+ );
191
+
192
+ return (
193
+ <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
194
+ <Flex vertical gap={16} data-testid="listings-mine">
195
+ <Typography.Title level={3}>
196
+ {t(LISTINGS_I18N_KEYS.mineTitle)}
197
+ </Typography.Title>
198
+
199
+ {!bag.gate.available ? (
200
+ <Alert
201
+ type="info"
202
+ showIcon
203
+ data-testid="listings-mine-blocked"
204
+ message={t(bag.gate.block.code, bag.gate.block.params)}
205
+ />
206
+ ) : null}
207
+
208
+ <Tabs
209
+ activeKey={bag.tab}
210
+ data-testid="listings-mine-tabs"
211
+ onChange={(key) => {
212
+ bag.setTab(key as MyListingsTab);
213
+ }}
214
+ items={bag.tabs.map((tab) => ({
215
+ key: tab,
216
+ label: (
217
+ <Space size={6}>
218
+ {t(TAB_LABEL[tab])}
219
+ {matchLoad(bag.counters, {
220
+ loading: () => null,
221
+ // A count we could not fetch is not zero. The badge is
222
+ // simply absent and the failure is stated once, below.
223
+ failed: () => null,
224
+ ready: (counters) => (
225
+ <Badge
226
+ count={counters[tab]}
227
+ showZero
228
+ data-testid={`listings-mine-count-${tab}`}
229
+ />
230
+ ),
231
+ })}
232
+ </Space>
233
+ ),
234
+ }))}
235
+ />
236
+
237
+ {matchLoad(bag.counters, {
238
+ loading: () => null,
239
+ failed: () => (
240
+ <Typography.Text type="secondary" data-testid="listings-mine-counters-failed">
241
+ {t(LISTINGS_I18N_KEYS.mineCountersFailed)}
242
+ </Typography.Text>
243
+ ),
244
+ ready: () => null,
245
+ })}
246
+
247
+ {matchList(bag.rows, {
248
+ loading: () => (
249
+ <Flex justify="center" data-testid="listings-mine-loading">
250
+ <Spin aria-label={t(LISTINGS_I18N_KEYS.mineLoading)} />
251
+ </Flex>
252
+ ),
253
+ failed: () => (
254
+ <ErrorAlert
255
+ testId="listings-mine-error"
256
+ error={describe({
257
+ code: LISTINGS_I18N_KEYS.mineLoadFailed,
258
+ params: {},
259
+ status: 0,
260
+ message: undefined,
261
+ language: undefined,
262
+ })}
263
+ action={
264
+ <Button
265
+ size="small"
266
+ data-analytics="none"
267
+ data-analytics-reason="retrying a read the person already asked for; not a business action"
268
+ onClick={bag.refetch}
269
+ >
270
+ {t(LISTINGS_I18N_KEYS.mineRetry)}
271
+ </Button>
272
+ }
273
+ />
274
+ ),
275
+ empty: () => (
276
+ <Empty
277
+ data-testid="listings-mine-empty"
278
+ description={t(LISTINGS_I18N_KEYS.mineEmpty)}
279
+ />
280
+ ),
281
+ ready: (rows) => (
282
+ <List
283
+ dataSource={[...rows]}
284
+ rowKey={(row) => row.id}
285
+ renderItem={(row) => (
286
+ <MyListingRow
287
+ listing={row}
288
+ {...(props.onEdit !== undefined ? { onEdit: props.onEdit } : {})}
289
+ />
290
+ )}
291
+ />
292
+ ),
293
+ })}
294
+
295
+ {/* The named gap. `matchList`'s `failed` arm above already renders
296
+ "we could not load"; this states WHY once, in the pane, because
297
+ the reason is a property of this deployment and not of this
298
+ request. */}
299
+ {props.source === undefined ? (
300
+ <Alert
301
+ type="warning"
302
+ showIcon
303
+ data-testid="listings-mine-source-missing"
304
+ message={t(LISTINGS_I18N_KEYS.mineSourceMissing)}
305
+ />
306
+ ) : null}
307
+
308
+ <Space>
309
+ <Button
310
+ size="small"
311
+ disabled={!bag.prevPage.available}
312
+ data-testid="listings-mine-prev"
313
+ data-analytics="none"
314
+ data-analytics-reason="paging a list the person is already reading; not a business action"
315
+ onClick={bag.goPrev}
316
+ >
317
+ {t(LISTINGS_I18N_KEYS.pagePrev)}
318
+ </Button>
319
+ <Button
320
+ size="small"
321
+ disabled={!bag.nextPage.available}
322
+ data-testid="listings-mine-next"
323
+ data-analytics="none"
324
+ data-analytics-reason="paging a list the person is already reading; not a business action"
325
+ onClick={bag.goNext}
326
+ >
327
+ {t(LISTINGS_I18N_KEYS.pageNext)}
328
+ </Button>
329
+ </Space>
330
+ </Flex>
331
+ </ListingsSkinTheme>
332
+ );
333
+ }