@stapel/listings-react 0.3.1 → 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 (164) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/MODULE.md +12 -6
  3. package/README.md +33 -17
  4. package/dist/api/generated/schema.d.ts +129 -3
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +35 -22
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js +17 -0
  9. package/dist/api/listingsApi.js.map +1 -1
  10. package/dist/api/types.d.ts +43 -2
  11. package/dist/api/types.d.ts.map +1 -1
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/FavoritesPane.d.ts +40 -3
  14. package/dist/default/FavoritesPane.d.ts.map +1 -1
  15. package/dist/default/FavoritesPane.js +30 -18
  16. package/dist/default/FavoritesPane.js.map +1 -1
  17. package/dist/default/ListingCard.d.ts +25 -4
  18. package/dist/default/ListingCard.d.ts.map +1 -1
  19. package/dist/default/ListingCard.js +19 -12
  20. package/dist/default/ListingCard.js.map +1 -1
  21. package/dist/default/ListingComposerPage.d.ts +163 -23
  22. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  23. package/dist/default/ListingComposerPage.js +85 -43
  24. package/dist/default/ListingComposerPage.js.map +1 -1
  25. package/dist/default/ListingDetailPane.d.ts +41 -4
  26. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  27. package/dist/default/ListingDetailPane.js +38 -25
  28. package/dist/default/ListingDetailPane.js.map +1 -1
  29. package/dist/default/ListingPhoto.d.ts +13 -1
  30. package/dist/default/ListingPhoto.d.ts.map +1 -1
  31. package/dist/default/ListingPhoto.js +47 -9
  32. package/dist/default/ListingPhoto.js.map +1 -1
  33. package/dist/default/ListingPrice.d.ts +13 -0
  34. package/dist/default/ListingPrice.d.ts.map +1 -0
  35. package/dist/default/ListingPrice.js +60 -0
  36. package/dist/default/ListingPrice.js.map +1 -0
  37. package/dist/default/MyListingsPane.d.ts +9 -17
  38. package/dist/default/MyListingsPane.d.ts.map +1 -1
  39. package/dist/default/MyListingsPane.js +148 -63
  40. package/dist/default/MyListingsPane.js.map +1 -1
  41. package/dist/default/SignInLink.js +8 -7
  42. package/dist/default/SignInLink.js.map +1 -1
  43. package/dist/default/StatusTags.d.ts +16 -5
  44. package/dist/default/StatusTags.d.ts.map +1 -1
  45. package/dist/default/StatusTags.js +19 -13
  46. package/dist/default/StatusTags.js.map +1 -1
  47. package/dist/default/index.d.ts +15 -9
  48. package/dist/default/index.d.ts.map +1 -1
  49. package/dist/default/index.js +13 -6
  50. package/dist/default/index.js.map +1 -1
  51. package/dist/headless/Favorites.d.ts +6 -0
  52. package/dist/headless/Favorites.d.ts.map +1 -1
  53. package/dist/headless/Favorites.js +4 -0
  54. package/dist/headless/Favorites.js.map +1 -1
  55. package/dist/headless/ListingActions.d.ts +12 -0
  56. package/dist/headless/ListingActions.d.ts.map +1 -1
  57. package/dist/headless/ListingActions.js +3 -0
  58. package/dist/headless/ListingActions.js.map +1 -1
  59. package/dist/headless/ListingComposer.d.ts +23 -0
  60. package/dist/headless/ListingComposer.d.ts.map +1 -1
  61. package/dist/headless/ListingComposer.js +33 -7
  62. package/dist/headless/ListingComposer.js.map +1 -1
  63. package/dist/headless/MyListings.d.ts +27 -12
  64. package/dist/headless/MyListings.d.ts.map +1 -1
  65. package/dist/headless/MyListings.js +29 -7
  66. package/dist/headless/MyListings.js.map +1 -1
  67. package/dist/i18n/es.d.ts +6 -1
  68. package/dist/i18n/es.d.ts.map +1 -1
  69. package/dist/i18n/es.js +28 -6
  70. package/dist/i18n/es.js.map +1 -1
  71. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  72. package/dist/i18n/generated/errors.es.gen.js +1 -1
  73. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  74. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  75. package/dist/i18n/generated/errors.gen.js +3 -0
  76. package/dist/i18n/generated/errors.gen.js.map +1 -1
  77. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  78. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  79. package/dist/i18n/keys.d.ts +28 -4
  80. package/dist/i18n/keys.d.ts.map +1 -1
  81. package/dist/i18n/keys.js +49 -8
  82. package/dist/i18n/keys.js.map +1 -1
  83. package/dist/i18n/ru.d.ts +16 -1
  84. package/dist/i18n/ru.d.ts.map +1 -1
  85. package/dist/i18n/ru.js +40 -6
  86. package/dist/i18n/ru.js.map +1 -1
  87. package/dist/index.d.ts +8 -6
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +8 -6
  90. package/dist/index.js.map +1 -1
  91. package/dist/model/draft.d.ts +9 -6
  92. package/dist/model/draft.d.ts.map +1 -1
  93. package/dist/model/draft.js +3 -1
  94. package/dist/model/draft.js.map +1 -1
  95. package/dist/model/features.d.ts +21 -7
  96. package/dist/model/features.d.ts.map +1 -1
  97. package/dist/model/features.js +26 -0
  98. package/dist/model/features.js.map +1 -1
  99. package/dist/model/mine.d.ts +44 -0
  100. package/dist/model/mine.d.ts.map +1 -0
  101. package/dist/model/mine.js +52 -0
  102. package/dist/model/mine.js.map +1 -0
  103. package/dist/model/mineSource.d.ts +39 -34
  104. package/dist/model/mineSource.d.ts.map +1 -1
  105. package/dist/model/mineSource.js +10 -41
  106. package/dist/model/mineSource.js.map +1 -1
  107. package/dist/model/mutations.d.ts.map +1 -1
  108. package/dist/model/mutations.js +11 -4
  109. package/dist/model/mutations.js.map +1 -1
  110. package/dist/model/queryKeys.d.ts +7 -2
  111. package/dist/model/queryKeys.d.ts.map +1 -1
  112. package/dist/model/queryKeys.js +2 -0
  113. package/dist/model/queryKeys.js.map +1 -1
  114. package/dist/model/status.d.ts +12 -31
  115. package/dist/model/status.d.ts.map +1 -1
  116. package/dist/model/status.js +44 -0
  117. package/dist/model/status.js.map +1 -1
  118. package/llms.txt +11 -8
  119. package/manifest.json +88 -14
  120. package/nav-manifest.json +1 -1
  121. package/package.json +17 -12
  122. package/src/analytics/generated/events.json +1 -1
  123. package/src/api/generated/schema.ts +129 -3
  124. package/src/api/listingsApi.ts +60 -21
  125. package/src/api/types.ts +46 -2
  126. package/src/default/FavoritesPane.tsx +156 -82
  127. package/src/default/ListingCard.tsx +88 -46
  128. package/src/default/ListingComposerPage.tsx +403 -187
  129. package/src/default/ListingDetailPane.tsx +200 -98
  130. package/src/default/ListingPhoto.tsx +89 -16
  131. package/src/default/ListingPrice.tsx +77 -0
  132. package/src/default/MyListingsPane.tsx +344 -222
  133. package/src/default/SignInLink.tsx +4 -4
  134. package/src/default/StatusTags.tsx +36 -18
  135. package/src/default/index.ts +18 -7
  136. package/src/headless/Favorites.tsx +10 -0
  137. package/src/headless/ListingActions.tsx +19 -0
  138. package/src/headless/ListingComposer.tsx +56 -5
  139. package/src/headless/MyListings.tsx +62 -22
  140. package/src/i18n/es.ts +36 -7
  141. package/src/i18n/generated/errors.es.gen.ts +1 -1
  142. package/src/i18n/generated/errors.gen.ts +3 -0
  143. package/src/i18n/generated/errors.json +9 -0
  144. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  145. package/src/i18n/keys.ts +58 -9
  146. package/src/i18n/ru.ts +48 -7
  147. package/src/index.ts +16 -5
  148. package/src/model/draft.ts +12 -7
  149. package/src/model/features.ts +48 -7
  150. package/src/model/mine.ts +70 -0
  151. package/src/model/mineSource.ts +46 -41
  152. package/src/model/mutations.ts +11 -4
  153. package/src/model/queryKeys.ts +9 -2
  154. package/src/model/status.ts +15 -0
  155. package/dist/default/ErrorAlert.d.ts +0 -9
  156. package/dist/default/ErrorAlert.d.ts.map +0 -1
  157. package/dist/default/ErrorAlert.js +0 -27
  158. package/dist/default/ErrorAlert.js.map +0 -1
  159. package/dist/default/theme.d.ts +0 -25
  160. package/dist/default/theme.d.ts.map +0 -1
  161. package/dist/default/theme.js +0 -40
  162. package/dist/default/theme.js.map +0 -1
  163. package/src/default/ErrorAlert.tsx +0 -46
  164. package/src/default/theme.tsx +0 -67
@@ -1,43 +1,71 @@
1
1
  /**
2
2
  * `<MyListingsPane>` — the seller's dashboard.
3
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.
4
+ * Three counts and the rows behind them, both real since stapel-listings
5
+ * 0.7.0 gave the owner's own listings a route (`GET my/listings/`). Until
6
+ * then this pane showed the counts and NAMED the missing endpoint where the
7
+ * rows should be; `model/mineSource.ts` keeps that history and the seam it
8
+ * left behind.
9
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.
10
+ * Three things this pane refuses to smooth over:
11
+ *
12
+ * 1. **Both axes on every row.** "Published" and "changes under review" appear
13
+ * beside each other, which is the entire reason `model/status.ts` exists: a
14
+ * dashboard that derived one from the other would tell a seller their live
15
+ * listing is offline, or never tell them their edit is being screened.
16
+ * `moderation_status` is on the owner card, so the row reads the real
17
+ * value rather than the `"approved"` stand-in it used before 0.7.0.
18
+ * 2. **Takedowns are not in a tab.** The three tabs are the SERVER's status
19
+ * groupings and `blocked` is in none of them, because `my/counters` counts
20
+ * it in none of them. Folding it into one would make a tab's rows and its
21
+ * badge describe different sets; leaving it out entirely would hide the
22
+ * one listing whose owner most needs to know. So it sits above the tabs,
23
+ * where it cannot be missed.
24
+ * 3. **An empty tab says which emptiness it is.** "No drafts" and "nothing
25
+ * sold yet" are different sentences and one generic "nothing here" is
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.
14
44
  */
45
+ import { useState } from "react";
15
46
  import type { ReactElement } from "react";
47
+ import { Flex, List, Tabs, Typography } from "antd";
16
48
  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";
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";
58
+ import type { MyListingCard } from "../api/types.js";
32
59
  import { useMyListings } from "../headless/MyListings.js";
33
60
  import type { MyListingsSource } from "../model/mineSource.js";
34
61
  import { useListingActions } from "../headless/ListingActions.js";
62
+ import { myListingTitle } from "../model/mine.js";
35
63
  import { listingStatusView } from "../model/status.js";
36
64
  import type { MyListingsTab } from "../model/status.js";
37
65
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
38
- import { ErrorAlert } from "./ErrorAlert.js";
66
+ import { ListingPhoto } from "./ListingPhoto.js";
67
+ import { SignInLink } from "./SignInLink.js";
39
68
  import { ListingStatusBlock } from "./StatusTags.js";
40
- import { ListingsSkinTheme } from "./theme.js";
41
69
  import type { ThemeModeProp } from "./types.js";
42
70
 
43
71
  const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
@@ -46,164 +74,238 @@ const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
46
74
  archived: LISTINGS_I18N_KEYS.mineTabArchived,
47
75
  };
48
76
 
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
- }
77
+ /** One empty sentence per tab see the header, point 3. */
78
+ const TAB_EMPTY: Readonly<Record<MyListingsTab, string>> = {
79
+ active: LISTINGS_I18N_KEYS.mineEmptyActive,
80
+ drafts: LISTINGS_I18N_KEYS.mineEmptyDrafts,
81
+ archived: LISTINGS_I18N_KEYS.mineEmptyArchived,
82
+ };
83
+
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";
90
93
 
91
94
  function MyListingRow(props: {
92
- listing: ListingCardData;
93
- onEdit?: (id: number) => void;
95
+ listing: MyListingCard;
96
+ onEdit?: ((id: number) => void) | undefined;
97
+ onAskRemove: (id: number) => void;
94
98
  }): ReactElement {
95
99
  const t = useT();
96
- const describe = useDescribeFlowError();
97
100
  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.
101
+ // Both axes, both real: `MyListingCardSerializer` puts `moderation_status`
102
+ // on the owner's card, so the row no longer has to pass "approved" as a
103
+ // stand-in and lose the one combination that matters a LIVE listing whose
104
+ // edit is under review.
103
105
  const status =
104
106
  props.listing.status === undefined
105
107
  ? undefined
106
- : listingStatusView(props.listing.status, "approved");
108
+ : listingStatusView(
109
+ props.listing.status,
110
+ props.listing.moderation_status ?? "approved"
111
+ );
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];
107
126
 
108
127
  return (
109
128
  <List.Item
110
129
  data-testid="listings-mine-row"
111
130
  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
- ]}
131
+ style={{ alignItems: "flex-start" }}
153
132
  >
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
- />
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">
145
+ {heading ?? `#${String(props.listing.id)}`}
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>
170
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>
171
210
  </Flex>
172
- }
173
- />
211
+ </Flex>
212
+ </Flex>
174
213
  </List.Item>
175
214
  );
176
215
  }
177
216
 
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. */
217
+ export interface MyListingsPaneProps extends ThemeModeProp, SignInCtaProp {
218
+ /** How the host gets the caller's own rows. Absent: the contract's own
219
+ * `GET my/listings/`, which is what a storefront wants. */
181
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
+ */
182
226
  readonly onEdit?: (id: number) => void;
183
227
  }
184
228
 
185
229
  export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
186
230
  const t = useT();
187
- const describe = useDescribeFlowError();
231
+ const tPlural = useTPlural();
188
232
  const bag = useMyListings(
189
233
  props.source !== undefined ? { source: props.source } : {}
190
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
+ );
191
254
 
192
255
  return (
193
- <ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
194
- <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">
195
262
  <Typography.Title level={3}>
196
263
  {t(LISTINGS_I18N_KEYS.mineTitle)}
197
264
  </Typography.Title>
198
265
 
266
+
199
267
  {!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)}
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
+ }
205
277
  />
206
- ) : null}
278
+ ) : (
279
+ <>
280
+ {/* The rows no tab folds in — see the header, point 2. Rendered
281
+ only when there are some: an empty takedown section is a scare,
282
+ and a failure to CHECK is not the same as "none", so it says so. */}
283
+ {matchList(bag.blockedRows, {
284
+ loading: () => null,
285
+ failed: () => (
286
+ <Typography.Text
287
+ type="secondary"
288
+ data-testid="listings-mine-takedowns-failed"
289
+ >
290
+ {t(LISTINGS_I18N_KEYS.mineBlockedLoadFailed)}
291
+ </Typography.Text>
292
+ ),
293
+ empty: () => null,
294
+ ready: (rows) => (
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>
307
+ ),
308
+ })}
207
309
 
208
310
  <Tabs
209
311
  activeKey={bag.tab}
@@ -214,120 +316,140 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
214
316
  items={bag.tabs.map((tab) => ({
215
317
  key: tab,
216
318
  label: (
217
- <Space size={6}>
319
+ <>
218
320
  {t(TAB_LABEL[tab])}
219
321
  {matchLoad(bag.counters, {
220
322
  loading: () => null,
221
- // 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
222
324
  // simply absent and the failure is stated once, below.
223
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.
224
330
  ready: (counters) => (
225
- <Badge
226
- count={counters[tab]}
227
- showZero
331
+ <Typography.Text
332
+ type="secondary"
228
333
  data-testid={`listings-mine-count-${tab}`}
229
- />
334
+ >
335
+ {` ${String(counters[tab])}`}
336
+ </Typography.Text>
230
337
  ),
231
338
  })}
232
- </Space>
339
+ </>
233
340
  ),
234
341
  }))}
235
342
  />
236
343
 
237
344
  {matchLoad(bag.counters, {
238
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.
239
349
  failed: () => (
240
- <Typography.Text type="secondary" data-testid="listings-mine-counters-failed">
241
- {t(LISTINGS_I18N_KEYS.mineCountersFailed)}
242
- </Typography.Text>
350
+ <ErrorAlert
351
+ variant="inline"
352
+ testId="listings-mine-counters-failed"
353
+ message={t(LISTINGS_I18N_KEYS.mineCountersFailed)}
354
+ />
243
355
  ),
244
356
  ready: () => null,
245
357
  })}
246
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
+
247
367
  {matchList(bag.rows, {
248
368
  loading: () => (
249
- <Flex justify="center" data-testid="listings-mine-loading">
250
- <Spin aria-label={t(LISTINGS_I18N_KEYS.mineLoading)} />
251
- </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
+ />
252
376
  ),
253
- failed: () => (
377
+ failed: (error) => (
254
378
  <ErrorAlert
255
379
  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
- }
380
+ thrown={error}
381
+ message={t(LISTINGS_I18N_KEYS.mineLoadFailed)}
382
+ onRetry={bag.refetch}
383
+ retryLabel={t(LISTINGS_I18N_KEYS.mineRetry)}
273
384
  />
274
385
  ),
275
386
  empty: () => (
276
- <Empty
277
- data-testid="listings-mine-empty"
278
- description={t(LISTINGS_I18N_KEYS.mineEmpty)}
279
- />
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>
280
393
  ),
281
394
  ready: (rows) => (
282
395
  <List
283
396
  dataSource={[...rows]}
284
397
  rowKey={(row) => row.id}
285
- renderItem={(row) => (
286
- <MyListingRow
287
- listing={row}
288
- {...(props.onEdit !== undefined ? { onEdit: props.onEdit } : {})}
289
- />
290
- )}
398
+ renderItem={renderRow}
291
399
  />
292
400
  ),
293
401
  })}
294
402
 
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
- />
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>
306
431
  ) : null}
307
432
 
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>
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
+ />
330
452
  </Flex>
331
- </ListingsSkinTheme>
453
+ </SkinTheme>
332
454
  );
333
455
  }