@stapel/listings-react 0.4.0 → 0.8.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 (133) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/dist/api/generated/schema.d.ts +94 -4
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FavoritesPane.d.ts +40 -3
  5. package/dist/default/FavoritesPane.d.ts.map +1 -1
  6. package/dist/default/FavoritesPane.js +30 -18
  7. package/dist/default/FavoritesPane.js.map +1 -1
  8. package/dist/default/ListingCard.d.ts +81 -4
  9. package/dist/default/ListingCard.d.ts.map +1 -1
  10. package/dist/default/ListingCard.js +97 -27
  11. package/dist/default/ListingCard.js.map +1 -1
  12. package/dist/default/ListingComposerPage.d.ts +163 -23
  13. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  14. package/dist/default/ListingComposerPage.js +85 -43
  15. package/dist/default/ListingComposerPage.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +41 -4
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +38 -25
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/ListingPhoto.d.ts +13 -1
  21. package/dist/default/ListingPhoto.d.ts.map +1 -1
  22. package/dist/default/ListingPhoto.js +47 -9
  23. package/dist/default/ListingPhoto.js.map +1 -1
  24. package/dist/default/ListingPrice.d.ts +13 -0
  25. package/dist/default/ListingPrice.d.ts.map +1 -0
  26. package/dist/default/ListingPrice.js +60 -0
  27. package/dist/default/ListingPrice.js.map +1 -0
  28. package/dist/default/MyListingsPane.d.ts +7 -28
  29. package/dist/default/MyListingsPane.d.ts.map +1 -1
  30. package/dist/default/MyListingsPane.js +136 -67
  31. package/dist/default/MyListingsPane.js.map +1 -1
  32. package/dist/default/SignInLink.js +8 -7
  33. package/dist/default/SignInLink.js.map +1 -1
  34. package/dist/default/StatusTags.d.ts +16 -5
  35. package/dist/default/StatusTags.d.ts.map +1 -1
  36. package/dist/default/StatusTags.js +19 -13
  37. package/dist/default/StatusTags.js.map +1 -1
  38. package/dist/default/index.d.ts +15 -9
  39. package/dist/default/index.d.ts.map +1 -1
  40. package/dist/default/index.js +13 -6
  41. package/dist/default/index.js.map +1 -1
  42. package/dist/headless/Favorites.d.ts +6 -0
  43. package/dist/headless/Favorites.d.ts.map +1 -1
  44. package/dist/headless/Favorites.js +23 -7
  45. package/dist/headless/Favorites.js.map +1 -1
  46. package/dist/headless/ListingActions.d.ts +12 -0
  47. package/dist/headless/ListingActions.d.ts.map +1 -1
  48. package/dist/headless/ListingActions.js +3 -0
  49. package/dist/headless/ListingActions.js.map +1 -1
  50. package/dist/headless/ListingComposer.d.ts +23 -0
  51. package/dist/headless/ListingComposer.d.ts.map +1 -1
  52. package/dist/headless/ListingComposer.js +33 -7
  53. package/dist/headless/ListingComposer.js.map +1 -1
  54. package/dist/headless/ListingDetail.d.ts.map +1 -1
  55. package/dist/headless/ListingDetail.js +7 -5
  56. package/dist/headless/ListingDetail.js.map +1 -1
  57. package/dist/headless/useMandateGate.d.ts +33 -2
  58. package/dist/headless/useMandateGate.d.ts.map +1 -1
  59. package/dist/headless/useMandateGate.js +42 -4
  60. package/dist/headless/useMandateGate.js.map +1 -1
  61. package/dist/i18n/es.d.ts +6 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +23 -5
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  66. package/dist/i18n/generated/errors.es.gen.js +1 -1
  67. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  68. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  69. package/dist/i18n/generated/errors.gen.js +3 -0
  70. package/dist/i18n/generated/errors.gen.js.map +1 -1
  71. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  72. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  73. package/dist/i18n/keys.d.ts +30 -3
  74. package/dist/i18n/keys.d.ts.map +1 -1
  75. package/dist/i18n/keys.js +47 -7
  76. package/dist/i18n/keys.js.map +1 -1
  77. package/dist/i18n/ru.d.ts +16 -1
  78. package/dist/i18n/ru.d.ts.map +1 -1
  79. package/dist/i18n/ru.js +35 -5
  80. package/dist/i18n/ru.js.map +1 -1
  81. package/dist/index.d.ts +1 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/model/draft.d.ts +9 -6
  86. package/dist/model/draft.d.ts.map +1 -1
  87. package/dist/model/draft.js +3 -1
  88. package/dist/model/draft.js.map +1 -1
  89. package/dist/model/features.d.ts +21 -7
  90. package/dist/model/features.d.ts.map +1 -1
  91. package/dist/model/features.js +26 -0
  92. package/dist/model/features.js.map +1 -1
  93. package/llms.txt +9 -8
  94. package/manifest.json +82 -31
  95. package/nav-manifest.json +1 -1
  96. package/package.json +19 -14
  97. package/src/analytics/generated/events.json +1 -1
  98. package/src/api/generated/schema.ts +94 -4
  99. package/src/default/FavoritesPane.tsx +156 -82
  100. package/src/default/ListingCard.tsx +283 -116
  101. package/src/default/ListingComposerPage.tsx +403 -187
  102. package/src/default/ListingDetailPane.tsx +200 -98
  103. package/src/default/ListingPhoto.tsx +89 -16
  104. package/src/default/ListingPrice.tsx +77 -0
  105. package/src/default/MyListingsPane.tsx +286 -228
  106. package/src/default/SignInLink.tsx +4 -4
  107. package/src/default/StatusTags.tsx +36 -18
  108. package/src/default/index.ts +18 -7
  109. package/src/headless/Favorites.tsx +36 -7
  110. package/src/headless/ListingActions.tsx +19 -0
  111. package/src/headless/ListingComposer.tsx +56 -5
  112. package/src/headless/ListingDetail.tsx +12 -5
  113. package/src/headless/useMandateGate.ts +49 -5
  114. package/src/i18n/es.ts +29 -5
  115. package/src/i18n/generated/errors.es.gen.ts +1 -1
  116. package/src/i18n/generated/errors.gen.ts +3 -0
  117. package/src/i18n/generated/errors.json +7 -0
  118. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  119. package/src/i18n/keys.ts +54 -7
  120. package/src/i18n/ru.ts +41 -5
  121. package/src/index.ts +5 -1
  122. package/src/model/draft.ts +12 -7
  123. package/src/model/features.ts +48 -7
  124. package/dist/default/ErrorAlert.d.ts +0 -9
  125. package/dist/default/ErrorAlert.d.ts.map +0 -1
  126. package/dist/default/ErrorAlert.js +0 -27
  127. package/dist/default/ErrorAlert.js.map +0 -1
  128. package/dist/default/theme.d.ts +0 -25
  129. package/dist/default/theme.d.ts.map +0 -1
  130. package/dist/default/theme.js +0 -40
  131. package/dist/default/theme.js.map +0 -1
  132. package/src/default/ErrorAlert.tsx +0 -46
  133. package/src/default/theme.tsx +0 -67
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `<ListingDetailPane>` — the listing page.
2
+ * `<ListingDetailPane>` — the listing page, and the page a marketplace makes
3
+ * its money on.
3
4
  *
4
5
  * Four distinct absences, four distinct sentences, and the whole point of the
5
6
  * component is that none of them collapses into another:
@@ -14,31 +15,82 @@
14
15
  * detail endpoint has no `published()` filter, so a draft answers 200 to
15
16
  * anyone holding the id. The pane says which side of that it is on rather
16
17
  * than dressing a draft up as a shop page.
18
+ *
19
+ * ── The page has ONE primary action, and it depends on who is reading ──────
20
+ *
21
+ * For two releases the only control here was "Save to favourites" — the money
22
+ * screen of a marketplace with no way to reach the seller, and the owner's own
23
+ * copy of the page offering to favourite their own listing. So:
24
+ *
25
+ * a buyer → `contactSlot` (the container's `@stapel/chat-react`
26
+ * "message the seller" button), with favouriting beside it as
27
+ * the secondary it always was;
28
+ * the owner → Edit and Take down, and no contact button at all — you do not
29
+ * message yourself.
30
+ *
31
+ * `contactSlot` is a SLOT because conversations belong to another L2 pair and
32
+ * L2 pairs do not import each other. Unfilled it renders `<SlotPlaceholder>`,
33
+ * so an app wired without a chat is a named gap in a dev build rather than a
34
+ * page whose only verb is "save".
17
35
  */
18
36
  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";
37
+ import { Button, Descriptions, Divider, Flex, Typography } from "antd";
38
+ import {
39
+ ErrorAlert,
40
+ EmptyState,
41
+ GatedButton,
42
+ GatedControl,
43
+ SkinTheme,
44
+ } from "@stapel/tokens-antd/skin";
45
+ import {
46
+ SlotPlaceholder,
47
+ matchLoad,
48
+ useI18n,
49
+ useT,
50
+ } from "@stapel/core";
51
+ import { spacing } from "@stapel/tokens";
21
52
  import { FeatureValueList } from "@stapel/attributes-react/default";
22
53
  import { formatFeatureValue } from "@stapel/attributes-react";
23
54
  import { useListingDetail } from "../headless/ListingDetail.js";
55
+ import { useListingActions } from "../headless/ListingActions.js";
24
56
  import { asFeatureDaoList, featuresDtoFromDaoList } from "../model/features.js";
25
57
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
26
- import { ErrorAlert } from "./ErrorAlert.js";
27
58
  import { HeartIcon } from "./icons.js";
28
59
  import { ListingPhoto } from "./ListingPhoto.js";
60
+ import { ListingPrice } from "./ListingPrice.js";
29
61
  import { ListingStatusBlock } from "./StatusTags.js";
30
- import { ListingsSkinTheme } from "./theme.js";
31
62
  import type { ThemeModeProp } from "./types.js";
32
63
 
64
+ /** The reading measure of the page body. A detail page is prose plus a spec
65
+ * table; past this it stops being one column and starts being a stripe across
66
+ * a 2560px pane. */
67
+ export const DETAIL_MEASURE = "60rem";
68
+
69
+ /** The narrowest a gallery tile may get before the grid drops a column. A
70
+ * measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
71
+ * which is §83's geometry rule — one photo per row on a phone, three on a
72
+ * desktop pane, and no `width: 320` that is near-full-bleed on one and a
73
+ * postage stamp on the other. */
74
+ export const DETAIL_PHOTO_MIN = "14rem";
75
+
33
76
  export interface ListingDetailPaneProps extends ThemeModeProp {
34
77
  readonly id: number;
35
78
  /** The reader's own uuid, when the host knows it. Enables the owner view —
36
79
  * the only place the moderation axis is shown, because it is the only
37
80
  * person it concerns. */
38
81
  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. */
82
+ /**
83
+ * THE primary action for a buyer: "message the seller", filled by the
84
+ * container from `@stapel/chat-react`. Rendered first, before favouriting,
85
+ * and never shown to the owner.
86
+ */
87
+ readonly contactSlot?: ReactNode;
88
+ /** Open the composer on this listing — the owner's primary. Absent is a real
89
+ * answer: the button then states that this app has no editing screen. */
90
+ readonly onEdit?: (id: number) => void;
91
+ /** Extra chrome beside the primary (the seller's profile link, a share
92
+ * button). Cross-pair navigation is the container's job (spec §6.2 item 5),
93
+ * so this pair takes nodes rather than routes. */
42
94
  readonly actions?: ReactNode;
43
95
  readonly footer?: ReactNode;
44
96
  }
@@ -46,53 +98,57 @@ export interface ListingDetailPaneProps extends ThemeModeProp {
46
98
  export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
47
99
  const t = useT();
48
100
  const { locale } = useI18n();
49
- const describe = useDescribeFlowError();
50
101
  const bag = useListingDetail(
51
102
  props.id,
52
103
  props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
53
104
  );
105
+ const owner = bag.viewerIsOwner === true;
106
+ const actions = useListingActions(props.id, bag.status?.lifecycle.status);
107
+ const editGate = actions.editGate(props.onEdit !== undefined);
108
+
109
+ const favoriteLabel = t(
110
+ bag.isFavorited === true
111
+ ? LISTINGS_I18N_KEYS.cardFavoriteRemove
112
+ : LISTINGS_I18N_KEYS.cardFavoriteAdd
113
+ );
54
114
 
55
115
  return (
56
- <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
57
- <Flex vertical gap={16} data-testid="listings-detail">
116
+ <SkinTheme
117
+ surface="base"
118
+ style={{ maxWidth: DETAIL_MEASURE, padding: spacing[4] }}
119
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
120
+ >
121
+ <Flex vertical gap={spacing[4]} data-testid="listings-detail">
58
122
  {bag.removed ? (
59
- <Alert
60
- type="warning"
61
- showIcon
62
- data-testid="listings-detail-removed"
123
+ <ErrorAlert
124
+ testId="listings-detail-removed"
63
125
  message={t(LISTINGS_I18N_KEYS.detailRemoved)}
64
126
  />
65
127
  ) : null}
66
128
 
67
129
  {matchLoad(bag.state, {
68
130
  loading: () => (
69
- <Flex justify="center" data-testid="listings-detail-loading">
70
- <Spin aria-label={t(LISTINGS_I18N_KEYS.detailLoading)} />
71
- </Flex>
131
+ <div
132
+ role="status"
133
+ aria-busy="true"
134
+ aria-label={t(LISTINGS_I18N_KEYS.detailLoading)}
135
+ data-testid="listings-detail-loading"
136
+ data-stapel-load-state="loading"
137
+ />
72
138
  ),
73
- failed: () =>
74
- bag.removed ? null : (
139
+ failed: (error) =>
140
+ bag.removed ? null : bag.notFound ? (
141
+ <EmptyState
142
+ testId="listings-detail-error"
143
+ title={t(LISTINGS_I18N_KEYS.detailNotFound)}
144
+ />
145
+ ) : (
75
146
  <ErrorAlert
76
147
  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
- }
148
+ thrown={error}
149
+ message={t(LISTINGS_I18N_KEYS.detailLoadFailed)}
150
+ onRetry={bag.refetch}
151
+ retryLabel={t(LISTINGS_I18N_KEYS.detailRetry)}
96
152
  />
97
153
  ),
98
154
  ready: (listing) => (
@@ -101,34 +157,39 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
101
157
  else's: a buyer has no use for "changes under review", and
102
158
  showing a stranger that a listing was refused would leak a
103
159
  verdict about someone else's content. */}
104
- {bag.viewerIsOwner === true && bag.status !== undefined ? (
105
- <Flex vertical gap={8} data-testid="listings-detail-owner-view">
160
+ {owner && bag.status !== undefined ? (
161
+ <Flex vertical gap={spacing[2]} data-testid="listings-detail-owner-view">
106
162
  <ListingStatusBlock status={bag.status} />
107
163
  {!bag.publiclyVisible ? (
108
- <Alert
109
- type="info"
110
- showIcon
111
- message={t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
112
- />
164
+ <Typography.Text type="secondary">
165
+ {t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
166
+ </Typography.Text>
113
167
  ) : null}
114
168
  </Flex>
115
169
  ) : null}
116
170
 
117
- {bag.viewerIsOwner !== true && !bag.publiclyVisible ? (
118
- <Alert
119
- type="warning"
120
- showIcon
121
- data-testid="listings-detail-not-published"
171
+ {!owner && !bag.publiclyVisible ? (
172
+ <ErrorAlert
173
+ testId="listings-detail-not-published"
122
174
  message={t(LISTINGS_I18N_KEYS.detailNotPublished)}
175
+ variant="inline"
123
176
  />
124
177
  ) : null}
125
178
 
126
- <Flex gap={16} wrap>
179
+ {/* Element-width tiles: the grid decides how many fit, the
180
+ photos fill them. */}
181
+ <div
182
+ data-testid="listings-detail-gallery"
183
+ style={{
184
+ display: "grid",
185
+ gridTemplateColumns: `repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr))`,
186
+ gap: spacing[3],
187
+ }}
188
+ >
127
189
  {bag.images.length === 0 ? (
128
190
  <ListingPhoto
129
191
  imageRef={undefined}
130
192
  alt={listing.title ?? String(listing.id)}
131
- style={{ width: 320, aspectRatio: "4 / 3" }}
132
193
  />
133
194
  ) : (
134
195
  bag.images.map((ref, index) => (
@@ -139,11 +200,10 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
139
200
  index: index + 1,
140
201
  total: bag.images.length,
141
202
  })}
142
- style={{ width: 320, aspectRatio: "4 / 3" }}
143
203
  />
144
204
  ))
145
205
  )}
146
- </Flex>
206
+ </div>
147
207
 
148
208
  <Typography.Title level={3} data-testid="listings-detail-title">
149
209
  {listing.title ?? ""}
@@ -163,48 +223,90 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
163
223
  ) : null}
164
224
 
165
225
  <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)}
226
+ <ListingPrice
227
+ amount={listing.price}
228
+ {...(listing.currency !== undefined
229
+ ? { currency: listing.currency }
230
+ : {})}
231
+ />
169
232
  </Typography.Title>
170
233
 
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>
234
+ {/* The buy box. One primary, and which one depends on who is
235
+ reading this page. */}
236
+ <Flex
237
+ wrap
238
+ gap={spacing[3]}
239
+ align="flex-start"
240
+ data-testid="listings-detail-actions"
241
+ >
242
+ {owner ? (
243
+ <>
244
+ <GatedButton
245
+ gate={editGate}
246
+ type="primary"
247
+ testId="listings-detail-edit"
248
+ data-analytics="none"
249
+ data-analytics-reason="business action — host app wraps with its own tracked()"
250
+ onClick={() => {
251
+ props.onEdit?.(props.id);
252
+ }}
253
+ >
254
+ {t(LISTINGS_I18N_KEYS.detailEdit)}
255
+ </GatedButton>
256
+ <GatedButton
257
+ gate={actions.archive}
258
+ danger
259
+ testId="listings-detail-take-down"
260
+ data-analytics="none"
261
+ data-analytics-reason="business action — host app wraps with its own tracked()"
262
+ onClick={actions.doArchive}
263
+ >
264
+ {t(LISTINGS_I18N_KEYS.detailTakeDown)}
265
+ </GatedButton>
266
+ </>
267
+ ) : (
268
+ <div data-testid="listings-detail-contact">
269
+ {props.contactSlot ?? <SlotPlaceholder name="contactSlot" />}
270
+ </div>
271
+ )}
272
+
273
+ {/* Favouriting your own listing is not a thing anyone does;
274
+ for everyone else it is the secondary it always was. */}
275
+ {owner ? null : (
276
+ <GatedControl
277
+ gate={bag.favoriteGate}
278
+ testId="listings-detail-favorite-gate"
279
+ >
280
+ {(bind) => (
281
+ <Button
282
+ disabled={bind.disabled}
283
+ data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
284
+ {...(bind["aria-describedby"] !== undefined
285
+ ? { "aria-describedby": bind["aria-describedby"] }
286
+ : {})}
287
+ aria-label={favoriteLabel}
288
+ aria-pressed={bag.isFavorited === true}
289
+ icon={<HeartIcon filled={bag.isFavorited === true} />}
290
+ data-testid="listings-detail-favorite"
291
+ data-analytics="none"
292
+ data-analytics-reason="business action — host app wraps with its own tracked()"
293
+ onClick={bag.toggleFavorite}
294
+ >
295
+ {favoriteLabel}
296
+ </Button>
297
+ )}
298
+ </GatedControl>
299
+ )}
300
+
192
301
  {props.actions}
193
- </Space>
302
+ </Flex>
194
303
 
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>
304
+ {actions.error !== undefined && actions.error !== null ? (
305
+ <ErrorAlert
306
+ testId="listings-detail-action-error"
307
+ thrown={actions.error}
308
+ variant="inline"
309
+ />
208
310
  ) : null}
209
311
 
210
312
  <Divider />
@@ -235,14 +337,14 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
235
337
  {/* Counted, not rounded to zero: a stored attribute this build
236
338
  cannot key is a gap in what the buyer is being told. */}
237
339
  {bag.unreadableFeatures > 0 ? (
238
- <Alert
340
+ <Typography.Text
239
341
  type="warning"
240
- showIcon
241
342
  data-testid="listings-detail-unreadable"
242
- message={t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
343
+ >
344
+ {t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
243
345
  count: bag.unreadableFeatures,
244
346
  })}
245
- />
347
+ </Typography.Text>
246
348
  ) : null}
247
349
 
248
350
  <Descriptions size="small" column={1}>
@@ -271,6 +373,6 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
271
373
  ),
272
374
  })}
273
375
  </Flex>
274
- </ListingsSkinTheme>
376
+ </SkinTheme>
275
377
  );
276
378
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A stored CDN reference, drawn — or the reason it cannot be.
2
+ * A stored CDN reference, drawn — or a designed placeholder saying why not.
3
3
  *
4
4
  * `Listing.images` carries opaque `<type>/<hash>` strings and no contract in
5
5
  * this fleet resolves a stranger's reference (`model/runtime.ts` argues it at
@@ -8,18 +8,33 @@
8
8
  * says so instead of emitting a broken `<img>`. An empty grey box that never
9
9
  * loads teaches a person nothing; a sentence gets the wiring fixed.
10
10
  *
11
+ * The placeholder is TOKENISED and theme-aware. It was antd's `<Empty>`, whose
12
+ * illustration is a flat `#d9d9d9` slab: it glared white on every dark page in
13
+ * the visual pass and read as a broken image rather than as a designed "no
14
+ * photo yet". It is now the aspect-ratio box the photo would have occupied,
15
+ * painted from `colorFillQuaternary` with a camera glyph in
16
+ * `colorTextQuaternary`, so it belongs to whichever side the theme is on and
17
+ * never shifts the layout when a real photo lands.
18
+ *
11
19
  * When the resolver DOES answer, `@stapel/image`'s `<Image>` takes over: it
12
20
  * measures the slot, picks the variant tier that fits, blur-ups from
13
21
  * `preview_b64` and never downgrades on a re-measure. None of that logic
14
22
  * belongs here — it is why `@stapel/image` exists.
15
23
  */
16
24
  import type { CSSProperties, ReactElement } from "react";
17
- import { Empty } from "antd";
25
+ import { useMemo } from "react";
26
+ import { Typography, theme as antdTheme } from "antd";
18
27
  import { Image } from "@stapel/image";
19
28
  import { useT } from "@stapel/core";
29
+ import { radii, spacing } from "@stapel/tokens";
20
30
  import { useListingsRuntime } from "../model/context.js";
21
31
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
22
32
 
33
+ /** The default shape of a listing photo box everywhere in this skin. Declared
34
+ * once so a card, a detail hero and a dashboard thumbnail cannot drift into
35
+ * three different crops. */
36
+ export const LISTING_PHOTO_ASPECT = "4 / 3";
37
+
23
38
  export interface ListingPhotoProps {
24
39
  /** The stored reference, or `undefined` for a listing with no photos. */
25
40
  readonly imageRef: string | undefined;
@@ -28,26 +43,78 @@ export interface ListingPhotoProps {
28
43
  readonly style?: CSSProperties;
29
44
  }
30
45
 
46
+ /** A camera outline in `currentColor` — the house convention (`icons.tsx`):
47
+ * no icon dependency, and it inherits the theme rather than carrying a
48
+ * colour. `aria-hidden` because the sentence beside it is the label. */
49
+ function CameraGlyph(): ReactElement {
50
+ return (
51
+ <svg
52
+ width="24"
53
+ height="24"
54
+ viewBox="0 0 24 24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth="1.5"
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ aria-hidden="true"
61
+ >
62
+ <path d="M3 8.5A2.5 2.5 0 0 1 5.5 6h1.7l1.2-2h6.2l1.2 2h1.7A2.5 2.5 0 0 1 20 8.5v8A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5z" />
63
+ <circle cx="12" cy="12.5" r="3.2" />
64
+ </svg>
65
+ );
66
+ }
67
+
31
68
  export function ListingPhoto(props: ListingPhotoProps): ReactElement {
32
69
  const t = useT();
70
+ const { token } = antdTheme.useToken();
33
71
  const runtime = useListingsRuntime();
34
- const meta =
35
- props.imageRef === undefined || runtime.resolveImage === undefined
36
- ? undefined
37
- : runtime.resolveImage(props.imageRef);
72
+ // Memoised on the REFERENCE, not called in render. A host resolver is a
73
+ // plain function returning a fresh object (`resolveImage: (ref) => ({ … })`
74
+ // is the documented shape), so calling it inline handed `<Image>` a new
75
+ // `meta` identity on every render of this card — which is a load `<Image>`
76
+ // then has to decide is or is not the same one. It defends itself now, and
77
+ // a caller still should not manufacture the churn.
78
+ const resolve = runtime.resolveImage;
79
+ const imageRef = props.imageRef;
80
+ const meta = useMemo(
81
+ () => (imageRef === undefined || resolve === undefined ? undefined : resolve(imageRef)),
82
+ [imageRef, resolve]
83
+ );
38
84
 
39
85
  if (meta === undefined) {
86
+ const caption = t(
87
+ props.imageRef === undefined
88
+ ? LISTINGS_I18N_KEYS.cardNoPhoto
89
+ : LISTINGS_I18N_KEYS.cardPhotoUnavailable
90
+ );
40
91
  return (
41
- <Empty
42
- image={Empty.PRESENTED_IMAGE_SIMPLE}
92
+ <div
93
+ role="img"
94
+ aria-label={caption}
43
95
  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
- />
96
+ style={{
97
+ display: "flex",
98
+ flexDirection: "column",
99
+ alignItems: "center",
100
+ justifyContent: "center",
101
+ gap: spacing[1],
102
+ width: "100%",
103
+ aspectRatio: LISTING_PHOTO_ASPECT,
104
+ background: token.colorFillQuaternary,
105
+ color: token.colorTextQuaternary,
106
+ borderRadius: radii.md,
107
+ ...props.style,
108
+ }}
109
+ >
110
+ <CameraGlyph />
111
+ <Typography.Text
112
+ type="secondary"
113
+ style={{ fontSize: token.fontSizeSM, textAlign: "center" }}
114
+ >
115
+ {caption}
116
+ </Typography.Text>
117
+ </div>
51
118
  );
52
119
  }
53
120
 
@@ -56,7 +123,13 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
56
123
  meta={meta}
57
124
  alt={props.alt}
58
125
  data-testid="listings-photo"
59
- {...(props.style ? { style: props.style } : {})}
126
+ style={{
127
+ width: "100%",
128
+ aspectRatio: LISTING_PHOTO_ASPECT,
129
+ objectFit: "cover",
130
+ borderRadius: radii.md,
131
+ ...props.style,
132
+ }}
60
133
  />
61
134
  );
62
135
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `<ListingPrice>` — the asking price, formatted as money.
3
+ *
4
+ * ── The defect this closes ─────────────────────────────────────────────────
5
+ *
6
+ * Every price on this pair's screens was `` `${price} ${currency}` `` —
7
+ * `4500.00 RUB`. An ISO code where a glyph belongs, no thousands grouping, a
8
+ * forced `.00` on a whole number, and the same string in `ru` and `es` as in
9
+ * `en`. `@stapel/currencies-react` exists precisely so no pair spells that
10
+ * template literal again: `formatMoney` is `Intl.NumberFormat` over a decimal
11
+ * STRING (never a `number` — `4500.10` is not a float), per locale and per the
12
+ * currency's own minor units.
13
+ *
14
+ * ── Two branches, because the catalogue is optional ────────────────────────
15
+ *
16
+ * A deployment that mounted `<CurrenciesProvider>` has the catalogue, so the
17
+ * price gets the currency's real glyph even where the locale carries none
18
+ * (`en` has no `₽`) and the formatting is the one the rest of that host's
19
+ * money uses. A host that did not mount it still gets a properly formatted,
20
+ * properly localized number through the pure `formatMoney` — the pair does not
21
+ * require a second module to render a price, and it does not silently fall
22
+ * back to the template literal either.
23
+ *
24
+ * The branch is decided by a context READ, and each arm is its own component,
25
+ * so no hook is called conditionally. `@stapel/currencies-react` is an
26
+ * OPTIONAL peer for exactly this reason: absent, only the plain arm ever
27
+ * mounts.
28
+ */
29
+ import { useContext } from "react";
30
+ import type { ReactElement } from "react";
31
+ import { useI18n, useT } from "@stapel/core";
32
+ import {
33
+ CurrenciesRuntimeContext,
34
+ formatMoney,
35
+ useMoney,
36
+ } from "@stapel/currencies-react";
37
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
38
+
39
+ export interface ListingPriceProps {
40
+ /** The amount as the wire spells it — a decimal string. */
41
+ readonly amount: string | undefined;
42
+ /** The code the amount is quoted in (`Listing.currency`). */
43
+ readonly currency?: string | undefined;
44
+ }
45
+
46
+ /** Is there a price at all? A listing may carry none, and "no price" is a
47
+ * sentence, not a zero. */
48
+ function hasAmount(props: ListingPriceProps): boolean {
49
+ return props.amount !== undefined && props.amount.length > 0;
50
+ }
51
+
52
+ function PlainPrice(props: ListingPriceProps): ReactElement {
53
+ const { locale } = useI18n();
54
+ return (
55
+ <>
56
+ {formatMoney(props.amount ?? "", (props.currency ?? "").toUpperCase(), {
57
+ locale,
58
+ })}
59
+ </>
60
+ );
61
+ }
62
+
63
+ function CataloguePrice(props: ListingPriceProps): ReactElement {
64
+ const money = useMoney();
65
+ return <>{money.format(props.amount ?? "", (props.currency ?? "").toUpperCase())}</>;
66
+ }
67
+
68
+ /**
69
+ * The price as text — the caller owns the typography, so one component serves
70
+ * the card's strong line and the detail page's display heading.
71
+ */
72
+ export function ListingPrice(props: ListingPriceProps): ReactElement {
73
+ const t = useT();
74
+ const wired = useContext(CurrenciesRuntimeContext) !== null;
75
+ if (!hasAmount(props)) return <>{t(LISTINGS_I18N_KEYS.cardPriceAbsent)}</>;
76
+ return wired ? <CataloguePrice {...props} /> : <PlainPrice {...props} />;
77
+ }