@stapel/listings-react 0.4.0 → 0.7.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 (107) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/api/generated/schema.d.ts +2 -2
  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 +25 -4
  9. package/dist/default/ListingCard.d.ts.map +1 -1
  10. package/dist/default/ListingCard.js +19 -12
  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 +4 -0
  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/i18n/es.d.ts +6 -1
  55. package/dist/i18n/es.d.ts.map +1 -1
  56. package/dist/i18n/es.js +21 -4
  57. package/dist/i18n/es.js.map +1 -1
  58. package/dist/i18n/keys.d.ts +19 -2
  59. package/dist/i18n/keys.d.ts.map +1 -1
  60. package/dist/i18n/keys.js +35 -5
  61. package/dist/i18n/keys.js.map +1 -1
  62. package/dist/i18n/ru.d.ts +16 -1
  63. package/dist/i18n/ru.d.ts.map +1 -1
  64. package/dist/i18n/ru.js +33 -4
  65. package/dist/i18n/ru.js.map +1 -1
  66. package/dist/model/draft.d.ts +9 -6
  67. package/dist/model/draft.d.ts.map +1 -1
  68. package/dist/model/draft.js +3 -1
  69. package/dist/model/draft.js.map +1 -1
  70. package/dist/model/features.d.ts +21 -7
  71. package/dist/model/features.d.ts.map +1 -1
  72. package/dist/model/features.js +26 -0
  73. package/dist/model/features.js.map +1 -1
  74. package/llms.txt +7 -6
  75. package/manifest.json +53 -11
  76. package/nav-manifest.json +1 -1
  77. package/package.json +17 -12
  78. package/src/analytics/generated/events.json +1 -1
  79. package/src/api/generated/schema.ts +2 -2
  80. package/src/default/FavoritesPane.tsx +156 -82
  81. package/src/default/ListingCard.tsx +88 -46
  82. package/src/default/ListingComposerPage.tsx +403 -187
  83. package/src/default/ListingDetailPane.tsx +200 -98
  84. package/src/default/ListingPhoto.tsx +89 -16
  85. package/src/default/ListingPrice.tsx +77 -0
  86. package/src/default/MyListingsPane.tsx +286 -228
  87. package/src/default/SignInLink.tsx +4 -4
  88. package/src/default/StatusTags.tsx +36 -18
  89. package/src/default/index.ts +18 -7
  90. package/src/headless/Favorites.tsx +10 -0
  91. package/src/headless/ListingActions.tsx +19 -0
  92. package/src/headless/ListingComposer.tsx +56 -5
  93. package/src/i18n/es.ts +26 -4
  94. package/src/i18n/keys.ts +42 -5
  95. package/src/i18n/ru.ts +38 -4
  96. package/src/model/draft.ts +12 -7
  97. package/src/model/features.ts +48 -7
  98. package/dist/default/ErrorAlert.d.ts +0 -9
  99. package/dist/default/ErrorAlert.d.ts.map +0 -1
  100. package/dist/default/ErrorAlert.js +0 -27
  101. package/dist/default/ErrorAlert.js.map +0 -1
  102. package/dist/default/theme.d.ts +0 -25
  103. package/dist/default/theme.d.ts.map +0 -1
  104. package/dist/default/theme.js +0 -40
  105. package/dist/default/theme.js.map +0 -1
  106. package/src/default/ErrorAlert.tsx +0 -46
  107. package/src/default/theme.tsx +0 -67
@@ -24,34 +24,48 @@
24
24
  * 3. **An empty tab says which emptiness it is.** "No drafts" and "nothing
25
25
  * sold yet" are different sentences and one generic "nothing here" is
26
26
  * neither.
27
+ *
28
+ * ── The row, rebuilt for a 390px screen ────────────────────────────────────
29
+ *
30
+ * The visual pass measured the old one and it failed on a phone in four ways
31
+ * at once: `Edit ¦ Mark sold ¦ Archive ¦ Delete` in antd's `List.Item`
32
+ * `actions` slot overflowed the viewport and clipped "Delete"; the title and
33
+ * the status shared one line with no `min-width: 0`, so the word "Draft" split
34
+ * into "Draf/t"; the status itself was drawn three different ways down one
35
+ * list; and every "why is this off" sentence lived in a `Tooltip`, which a
36
+ * touch device cannot open and a disabled antd button never fires anyway.
37
+ *
38
+ * So: the row is a two-column flex (thumbnail, then a `min-width: 0` column
39
+ * that owns the title, the status and the actions), the actions WRAP instead
40
+ * of overflowing, each one is a shared `<GatedButton>` with its reason as
41
+ * ordinary text beside it, and the status is one `<ListingStatusBlock>`.
42
+ * Delete asks first, through the shared `<SkinConfirm>` — a bottom sheet on a
43
+ * phone. It used to fire the mutation on the first click.
27
44
  */
45
+ import { useState } from "react";
28
46
  import type { ReactElement } from "react";
47
+ import { Flex, List, Tabs, Typography } from "antd";
29
48
  import {
30
- Alert,
31
- Badge,
32
- Button,
33
- Empty,
34
- Flex,
35
- List,
36
- Space,
37
- Spin,
38
- Tabs,
39
- Tooltip,
40
- Typography,
41
- } from "antd";
42
- import { matchList, matchLoad, useDescribeFlowError, useT } from "@stapel/core";
43
- import type { ActionAvailability } from "@stapel/core";
49
+ ErrorAlert,
50
+ EmptyState,
51
+ GatedButton,
52
+ SkinConfirm,
53
+ SkinTheme,
54
+ } from "@stapel/tokens-antd/skin";
55
+ import { matchList, matchLoad, useT, useTPlural } from "@stapel/core";
56
+ import type { SignInCtaProp } from "@stapel/core";
57
+ import { spacing } from "@stapel/tokens";
44
58
  import type { MyListingCard } from "../api/types.js";
45
59
  import { useMyListings } from "../headless/MyListings.js";
46
60
  import type { MyListingsSource } from "../model/mineSource.js";
47
61
  import { useListingActions } from "../headless/ListingActions.js";
48
- import { myListingTitle, showsDraft } from "../model/mine.js";
62
+ import { myListingTitle } from "../model/mine.js";
49
63
  import { listingStatusView } from "../model/status.js";
50
64
  import type { MyListingsTab } from "../model/status.js";
51
65
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
52
- import { ErrorAlert } from "./ErrorAlert.js";
66
+ import { ListingPhoto } from "./ListingPhoto.js";
67
+ import { SignInLink } from "./SignInLink.js";
53
68
  import { ListingStatusBlock } from "./StatusTags.js";
54
- import { ListingsSkinTheme } from "./theme.js";
55
69
  import type { ThemeModeProp } from "./types.js";
56
70
 
57
71
  const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
@@ -67,54 +81,22 @@ const TAB_EMPTY: Readonly<Record<MyListingsTab, string>> = {
67
81
  archived: LISTINGS_I18N_KEYS.mineEmptyArchived,
68
82
  };
69
83
 
70
- /** A control plus the sentence that explains it when it is off. Written once
71
- * here because this pane has six of them and a grey button with no reason is
72
- * the one shape `ActionAvailability` exists to make impossible. */
73
- function GatedButton(props: {
74
- gate: ActionAvailability;
75
- label: string;
76
- testId: string;
77
- onClick: () => void;
78
- danger?: boolean;
79
- /** The call sites mark themselves `data-analytics="none"` with a
80
- * `passthrough` reason (the convention `stapel/clickable-needs-event`
81
- * documents for a forwarding component); the real click point is the antd
82
- * button below, which declares its own outcome. */
83
- "data-analytics"?: string;
84
- "data-analytics-reason"?: string;
85
- }): ReactElement {
86
- const t = useT();
87
- return (
88
- <Tooltip
89
- title={
90
- props.gate.available
91
- ? props.label
92
- : t(props.gate.block.code, props.gate.block.params)
93
- }
94
- >
95
- <span>
96
- <Button
97
- size="small"
98
- danger={props.danger === true}
99
- disabled={!props.gate.available}
100
- data-testid={props.testId}
101
- data-analytics="none"
102
- data-analytics-reason="business action — host app wraps with its own tracked()"
103
- onClick={props.onClick}
104
- >
105
- {props.label}
106
- </Button>
107
- </span>
108
- </Tooltip>
109
- );
110
- }
84
+ /** The thumbnail column. A photo marketplace whose seller dashboard is a
85
+ * column of text is missing the one thing that identifies a row at a glance;
86
+ * a fixed measure because the column must not reflow per row. */
87
+ const THUMB_WIDTH = "4.5rem";
88
+
89
+ /** How wide a dashboard row may get. A 1280px window gave the four wrapped
90
+ * action buttons ~560px of empty space to split across, stranding the reason
91
+ * text between them. */
92
+ const MINE_MEASURE = "60rem";
111
93
 
112
94
  function MyListingRow(props: {
113
95
  listing: MyListingCard;
114
- onEdit?: (id: number) => void;
96
+ onEdit?: ((id: number) => void) | undefined;
97
+ onAskRemove: (id: number) => void;
115
98
  }): ReactElement {
116
99
  const t = useT();
117
- const describe = useDescribeFlowError();
118
100
  const actions = useListingActions(props.listing.id, props.listing.status);
119
101
  // Both axes, both real: `MyListingCardSerializer` puts `moderation_status`
120
102
  // on the owner's card, so the row no longer has to pass "approved" as a
@@ -128,119 +110,173 @@ function MyListingRow(props: {
128
110
  props.listing.moderation_status ?? "approved"
129
111
  );
130
112
  const heading = myListingTitle(props.listing);
113
+ const onEdit = props.onEdit;
114
+ // A control that offers something meaningless in the current state is the
115
+ // one shape §83 forbids: with no `onEdit` — which is exactly how the
116
+ // scripted scaffold mounts this pane — the button used to be enabled,
117
+ // clickable and inert. It is now switched off WITH the reason.
118
+ //
119
+ // "This app has no editing screen" is a fact about the BUILD, not about the
120
+ // row, so with six rows it printed six identical refusals down one column.
121
+ // A build-wide refusal is stated once, by the pane, and the button it
122
+ // refuses is not drawn at all.
123
+ const editGate = actions.editGate(true);
124
+ const hasEditor = onEdit !== undefined;
125
+ const cover = props.listing.images?.[0] ?? props.listing.images_draft?.[0];
131
126
 
132
127
  return (
133
128
  <List.Item
134
129
  data-testid="listings-mine-row"
135
130
  data-listing-id={props.listing.id}
136
- actions={[
137
- <GatedButton
138
- key="edit"
139
- data-analytics="none"
140
- data-analytics-reason="passthrough — GatedButton owns the real click point"
141
- gate={{ available: true }}
142
- label={t(LISTINGS_I18N_KEYS.mineEdit)}
143
- testId="listings-mine-edit"
144
- onClick={() => {
145
- props.onEdit?.(props.listing.id);
146
- }}
147
- />,
148
- <GatedButton
149
- key="complete"
150
- data-analytics="none"
151
- data-analytics-reason="passthrough — GatedButton owns the real click point"
152
- gate={actions.complete}
153
- label={t(LISTINGS_I18N_KEYS.mineComplete)}
154
- testId="listings-mine-complete"
155
- onClick={actions.doComplete}
156
- />,
157
- <GatedButton
158
- key="archive"
159
- data-analytics="none"
160
- data-analytics-reason="passthrough — GatedButton owns the real click point"
161
- gate={actions.archive}
162
- label={t(LISTINGS_I18N_KEYS.mineArchive)}
163
- testId="listings-mine-archive"
164
- onClick={actions.doArchive}
165
- />,
166
- <GatedButton
167
- key="delete"
168
- data-analytics="none"
169
- data-analytics-reason="passthrough — GatedButton owns the real click point"
170
- danger
171
- gate={actions.remove}
172
- label={t(LISTINGS_I18N_KEYS.mineDelete)}
173
- testId="listings-mine-delete"
174
- onClick={actions.doRemove}
175
- />,
176
- ]}
131
+ style={{ alignItems: "flex-start" }}
177
132
  >
178
- <List.Item.Meta
179
- title={
180
- <Space size={6}>
133
+ <Flex gap={spacing[3]} style={{ width: "100%", minWidth: 0 }}>
134
+ <div style={{ flex: `0 0 ${THUMB_WIDTH}`, width: THUMB_WIDTH }}>
135
+ <ListingPhoto
136
+ imageRef={cover}
137
+ alt={heading ?? `#${String(props.listing.id)}`}
138
+ />
139
+ </div>
140
+
141
+ {/* `min-width: 0` is what stops a long title from pushing the status
142
+ out of the row and splitting a word across two lines. */}
143
+ <Flex vertical gap={spacing[2]} style={{ flex: "1 1 auto", minWidth: 0 }}>
144
+ <Typography.Text strong ellipsis data-testid="listings-mine-title">
181
145
  {heading ?? `#${String(props.listing.id)}`}
182
- {/* A heading that came off the draft twin is something nobody
183
- else can read yet. Saying so is the difference between a
184
- dashboard and a shop window that happens to be yours. */}
185
- {showsDraft(props.listing) ? (
186
- <Typography.Text type="secondary" data-testid="listings-mine-draft-title">
187
- {t(LISTINGS_I18N_KEYS.statusDraft)}
188
- </Typography.Text>
189
- ) : null}
190
- </Space>
191
- }
192
- description={
193
- <Flex vertical gap={4}>
194
- {status !== undefined ? <ListingStatusBlock status={status} /> : null}
195
- {actions.error !== undefined && actions.error !== null ? (
196
- <ErrorAlert
197
- testId="listings-mine-action-error"
198
- error={describe({
199
- code: LISTINGS_I18N_KEYS.unknownError,
200
- params: {},
201
- status: 0,
202
- message: undefined,
203
- language: undefined,
204
- })}
205
- />
146
+ </Typography.Text>
147
+
148
+ {/* ONE status treatment. The word "Draft" beside the title was a
149
+ second, redundant rendering of what this tag already says. */}
150
+ {status !== undefined ? <ListingStatusBlock status={status} /> : null}
151
+
152
+ {actions.error !== undefined && actions.error !== null ? (
153
+ <ErrorAlert
154
+ testId="listings-mine-action-error"
155
+ thrown={actions.error}
156
+ variant="inline"
157
+ />
158
+ ) : null}
159
+
160
+ {/* Wrap, never overflow: four actions do not fit across 390px and a
161
+ clipped "Delete" is a control that is not there. */}
162
+ <Flex wrap gap={spacing[2]} align="flex-start">
163
+ {hasEditor ? (
164
+ <GatedButton
165
+ gate={editGate}
166
+ size="small"
167
+ testId="listings-mine-edit"
168
+ data-analytics="none"
169
+ data-analytics-reason="business action — host app wraps with its own tracked()"
170
+ onClick={() => {
171
+ onEdit?.(props.listing.id);
172
+ }}
173
+ >
174
+ {t(LISTINGS_I18N_KEYS.mineEdit)}
175
+ </GatedButton>
206
176
  ) : null}
177
+ <GatedButton
178
+ gate={actions.complete}
179
+ size="small"
180
+ testId="listings-mine-complete"
181
+ data-analytics="none"
182
+ data-analytics-reason="business action — host app wraps with its own tracked()"
183
+ onClick={actions.doComplete}
184
+ >
185
+ {t(LISTINGS_I18N_KEYS.mineComplete)}
186
+ </GatedButton>
187
+ <GatedButton
188
+ gate={actions.archive}
189
+ size="small"
190
+ testId="listings-mine-archive"
191
+ data-analytics="none"
192
+ data-analytics-reason="business action — host app wraps with its own tracked()"
193
+ onClick={actions.doArchive}
194
+ >
195
+ {t(LISTINGS_I18N_KEYS.mineArchive)}
196
+ </GatedButton>
197
+ <GatedButton
198
+ gate={actions.remove}
199
+ size="small"
200
+ danger
201
+ testId="listings-mine-delete"
202
+ data-analytics="none"
203
+ data-analytics-reason="opens the delete confirmation"
204
+ onClick={() => {
205
+ props.onAskRemove(props.listing.id);
206
+ }}
207
+ >
208
+ {t(LISTINGS_I18N_KEYS.mineDelete)}
209
+ </GatedButton>
207
210
  </Flex>
208
- }
209
- />
211
+ </Flex>
212
+ </Flex>
210
213
  </List.Item>
211
214
  );
212
215
  }
213
216
 
214
- export interface MyListingsPaneProps extends ThemeModeProp {
217
+ export interface MyListingsPaneProps extends ThemeModeProp, SignInCtaProp {
215
218
  /** How the host gets the caller's own rows. Absent: the contract's own
216
219
  * `GET my/listings/`, which is what a storefront wants. */
217
220
  readonly source?: MyListingsSource;
221
+ /**
222
+ * Open the composer on one of these listings. ABSENT IS A REAL ANSWER: the
223
+ * Edit button then states that this app has no editing screen instead of
224
+ * offering a click that does nothing.
225
+ */
218
226
  readonly onEdit?: (id: number) => void;
219
227
  }
220
228
 
221
229
  export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
222
230
  const t = useT();
223
- const describe = useDescribeFlowError();
231
+ const tPlural = useTPlural();
224
232
  const bag = useMyListings(
225
233
  props.source !== undefined ? { source: props.source } : {}
226
234
  );
235
+ // ONE confirmation for the whole list, keyed by the row that asked — not one
236
+ // mounted dialog per row.
237
+ const [removingId, setRemovingId] = useState<number | null>(null);
238
+ const removal = useListingActions(
239
+ removingId ?? 0,
240
+ bag.rows.status === "ready"
241
+ ? bag.rows.data.find((row) => row.id === removingId)?.status
242
+ : undefined
243
+ );
244
+
245
+ const paged = bag.prevPage.available || bag.nextPage.available;
246
+
247
+ const renderRow = (row: MyListingCard): ReactElement => (
248
+ <MyListingRow
249
+ listing={row}
250
+ onEdit={props.onEdit}
251
+ onAskRemove={setRemovingId}
252
+ />
253
+ );
227
254
 
228
255
  return (
229
- <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
230
- <Flex vertical gap={16} data-testid="listings-mine">
256
+ <SkinTheme
257
+ surface="base"
258
+ style={{ padding: spacing[4], maxWidth: MINE_MEASURE }}
259
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
260
+ >
261
+ <Flex vertical gap={spacing[4]} data-testid="listings-mine">
231
262
  <Typography.Title level={3}>
232
263
  {t(LISTINGS_I18N_KEYS.mineTitle)}
233
264
  </Typography.Title>
234
265
 
266
+
235
267
  {!bag.gate.available ? (
236
- <Alert
237
- type="info"
238
- showIcon
239
- data-testid="listings-mine-blocked"
240
- message={t(bag.gate.block.code, bag.gate.block.params)}
268
+ // A refused pane is ONE state, not a banner above a live-looking
269
+ // dashboard: the tabs still advertised "Active 2 · Drafts 3" to a
270
+ // visitor who could not read a single row.
271
+ <EmptyState
272
+ testId="listings-mine-blocked"
273
+ title={t(bag.gate.block.code, bag.gate.block.params)}
274
+ action={
275
+ <SignInLink cta={props.signIn} testId="listings-mine-sign-in" />
276
+ }
241
277
  />
242
- ) : null}
243
-
278
+ ) : (
279
+ <>
244
280
  {/* The rows no tab folds in — see the header, point 2. Rendered
245
281
  only when there are some: an empty takedown section is a scare,
246
282
  and a failure to CHECK is not the same as "none", so it says so. */}
@@ -256,28 +292,18 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
256
292
  ),
257
293
  empty: () => null,
258
294
  ready: (rows) => (
259
- <Alert
260
- type="error"
261
- showIcon
262
- data-testid="listings-mine-takedowns"
263
- message={t(LISTINGS_I18N_KEYS.mineBlockedTitle, {
264
- count: rows.length,
265
- })}
266
- description={
267
- <List
268
- dataSource={[...rows]}
269
- rowKey={(row) => row.id}
270
- renderItem={(row) => (
271
- <MyListingRow
272
- listing={row}
273
- {...(props.onEdit !== undefined
274
- ? { onEdit: props.onEdit }
275
- : {})}
276
- />
277
- )}
278
- />
279
- }
280
- />
295
+ <Flex vertical gap={spacing[2]} data-testid="listings-mine-takedowns">
296
+ <Typography.Text type="warning" strong>
297
+ {tPlural(LISTINGS_I18N_KEYS.mineBlockedTitle, {
298
+ count: rows.length,
299
+ })}
300
+ </Typography.Text>
301
+ <List
302
+ dataSource={[...rows]}
303
+ rowKey={(row) => row.id}
304
+ renderItem={renderRow}
305
+ />
306
+ </Flex>
281
307
  ),
282
308
  })}
283
309
 
@@ -290,108 +316,140 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
290
316
  items={bag.tabs.map((tab) => ({
291
317
  key: tab,
292
318
  label: (
293
- <Space size={6}>
319
+ <>
294
320
  {t(TAB_LABEL[tab])}
295
321
  {matchLoad(bag.counters, {
296
322
  loading: () => null,
297
- // A count we could not fetch is not zero. The badge is
323
+ // A count we could not fetch is not zero. The number is
298
324
  // simply absent and the failure is stated once, below.
299
325
  failed: () => null,
326
+ // A plain secondary number, not a red antd Badge: red is the
327
+ // danger token and "Active 2" is not a warning. It also
328
+ // keeps the tab short enough that three of them fit on a
329
+ // phone instead of collapsing into an overflow menu.
300
330
  ready: (counters) => (
301
- <Badge
302
- count={counters[tab]}
303
- showZero
331
+ <Typography.Text
332
+ type="secondary"
304
333
  data-testid={`listings-mine-count-${tab}`}
305
- />
334
+ >
335
+ {` ${String(counters[tab])}`}
336
+ </Typography.Text>
306
337
  ),
307
338
  })}
308
- </Space>
339
+ </>
309
340
  ),
310
341
  }))}
311
342
  />
312
343
 
313
344
  {matchLoad(bag.counters, {
314
345
  loading: () => null,
346
+ // One failure, one treatment: the rows' failure is an ErrorAlert, so
347
+ // the counters' is one too — inline, because it costs a number, not
348
+ // the screen.
315
349
  failed: () => (
316
- <Typography.Text type="secondary" data-testid="listings-mine-counters-failed">
317
- {t(LISTINGS_I18N_KEYS.mineCountersFailed)}
318
- </Typography.Text>
350
+ <ErrorAlert
351
+ variant="inline"
352
+ testId="listings-mine-counters-failed"
353
+ message={t(LISTINGS_I18N_KEYS.mineCountersFailed)}
354
+ />
319
355
  ),
320
356
  ready: () => null,
321
357
  })}
322
358
 
359
+ {/* A build-wide refusal, said once. It used to be printed beside a
360
+ switched-off Edit button on every row. */}
361
+ {props.onEdit === undefined ? (
362
+ <Typography.Text type="secondary" data-testid="listings-mine-no-editor">
363
+ {t(LISTINGS_I18N_KEYS.blockedNoEditor)}
364
+ </Typography.Text>
365
+ ) : null}
366
+
323
367
  {matchList(bag.rows, {
324
368
  loading: () => (
325
- <Flex justify="center" data-testid="listings-mine-loading">
326
- <Spin aria-label={t(LISTINGS_I18N_KEYS.mineLoading)} />
327
- </Flex>
369
+ <div
370
+ role="status"
371
+ aria-busy="true"
372
+ aria-label={t(LISTINGS_I18N_KEYS.mineLoading)}
373
+ data-testid="listings-mine-loading"
374
+ data-stapel-load-state="loading"
375
+ />
328
376
  ),
329
- failed: () => (
377
+ failed: (error) => (
330
378
  <ErrorAlert
331
379
  testId="listings-mine-error"
332
- error={describe({
333
- code: LISTINGS_I18N_KEYS.mineLoadFailed,
334
- params: {},
335
- status: 0,
336
- message: undefined,
337
- language: undefined,
338
- })}
339
- action={
340
- <Button
341
- size="small"
342
- data-analytics="none"
343
- data-analytics-reason="retrying a read the person already asked for; not a business action"
344
- onClick={bag.refetch}
345
- >
346
- {t(LISTINGS_I18N_KEYS.mineRetry)}
347
- </Button>
348
- }
380
+ thrown={error}
381
+ message={t(LISTINGS_I18N_KEYS.mineLoadFailed)}
382
+ onRetry={bag.refetch}
383
+ retryLabel={t(LISTINGS_I18N_KEYS.mineRetry)}
349
384
  />
350
385
  ),
351
386
  empty: () => (
352
- <Empty
353
- data-testid="listings-mine-empty"
354
- data-empty-tab={bag.tab}
355
- description={t(TAB_EMPTY[bag.tab])}
356
- />
387
+ // The tab is stamped on the wrapper, not only spoken in the copy:
388
+ // "no drafts" and "nothing sold yet" are different emptinesses and
389
+ // a test has to be able to tell which one is on screen.
390
+ <div data-testid="listings-mine-empty" data-empty-tab={bag.tab}>
391
+ <EmptyState title={t(TAB_EMPTY[bag.tab])} />
392
+ </div>
357
393
  ),
358
394
  ready: (rows) => (
359
395
  <List
360
396
  dataSource={[...rows]}
361
397
  rowKey={(row) => row.id}
362
- renderItem={(row) => (
363
- <MyListingRow
364
- listing={row}
365
- {...(props.onEdit !== undefined ? { onEdit: props.onEdit } : {})}
366
- />
367
- )}
398
+ renderItem={renderRow}
368
399
  />
369
400
  ),
370
401
  })}
371
402
 
372
- <Space>
373
- <Button
374
- size="small"
375
- disabled={!bag.prevPage.available}
376
- data-testid="listings-mine-prev"
377
- data-analytics="none"
378
- data-analytics-reason="paging a list the person is already reading; not a business action"
379
- onClick={bag.goPrev}
380
- >
381
- {t(LISTINGS_I18N_KEYS.pagePrev)}
382
- </Button>
383
- <Button
384
- size="small"
385
- disabled={!bag.nextPage.available}
386
- data-testid="listings-mine-next"
387
- data-analytics="none"
388
- data-analytics-reason="paging a list the person is already reading; not a business action"
389
- onClick={bag.goNext}
390
- >
391
- {t(LISTINGS_I18N_KEYS.pageNext)}
392
- </Button>
393
- </Space>
403
+ {/* A pager over a list with one page is two controls that mean
404
+ nothing. It renders when there is somewhere to go, and not
405
+ before. */}
406
+ {paged ? (
407
+ <Flex gap={spacing[2]} wrap>
408
+ <GatedButton
409
+ gate={bag.prevPage}
410
+ size="small"
411
+ layout="inline"
412
+ testId="listings-mine-prev"
413
+ data-analytics="none"
414
+ data-analytics-reason="paging a list the person is already reading; not a business action"
415
+ onClick={bag.goPrev}
416
+ >
417
+ {t(LISTINGS_I18N_KEYS.pagePrev)}
418
+ </GatedButton>
419
+ <GatedButton
420
+ gate={bag.nextPage}
421
+ size="small"
422
+ layout="inline"
423
+ testId="listings-mine-next"
424
+ data-analytics="none"
425
+ data-analytics-reason="paging a list the person is already reading; not a business action"
426
+ onClick={bag.goNext}
427
+ >
428
+ {t(LISTINGS_I18N_KEYS.pageNext)}
429
+ </GatedButton>
430
+ </Flex>
431
+ ) : null}
432
+
433
+ </>
434
+ )}
435
+
436
+ <SkinConfirm
437
+ open={removingId !== null}
438
+ danger
439
+ title={t(LISTINGS_I18N_KEYS.mineDeleteConfirmTitle)}
440
+ body={t(LISTINGS_I18N_KEYS.mineDeleteConfirmBody)}
441
+ confirmLabel={t(LISTINGS_I18N_KEYS.mineDelete)}
442
+ confirming={removal.inFlight}
443
+ data-testid="listings-mine-delete-confirm"
444
+ onConfirm={() => {
445
+ removal.doRemove();
446
+ setRemovingId(null);
447
+ }}
448
+ onCancel={() => {
449
+ setRemovingId(null);
450
+ }}
451
+ />
394
452
  </Flex>
395
- </ListingsSkinTheme>
453
+ </SkinTheme>
396
454
  );
397
455
  }
@@ -33,12 +33,12 @@ export function SignInLink(props: SignInLinkProps): ReactElement | null {
33
33
  const t = useT();
34
34
  const { cta } = props;
35
35
  if (cta === undefined) return null;
36
- // The separating space belongs HERE, not at the call site: a reason with no
37
- // door must render as exactly its own sentence, and a `{" "}` left behind by
38
- // an absent link is a trailing space in every caller's assertion.
36
+ // No separating space: since the shared `<GatedControl>` prints the reason
37
+ // as its own paragraph, the door is a standalone element rather than the
38
+ // tail of a sentence, and a leading `{" "}` would be a stray space in every
39
+ // caller's assertion.
39
40
  return (
40
41
  <>
41
- {" "}
42
42
  <Typography.Link
43
43
  data-testid={props.testId}
44
44
  data-analytics="none"