@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.
- package/CHANGELOG.md +180 -0
- package/dist/api/generated/schema.d.ts +94 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FavoritesPane.d.ts +40 -3
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +30 -18
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +81 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +97 -27
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +163 -23
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +85 -43
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +41 -4
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +38 -25
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +13 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +47 -9
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -0
- package/dist/default/ListingPrice.d.ts.map +1 -0
- package/dist/default/ListingPrice.js +60 -0
- package/dist/default/ListingPrice.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +7 -28
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +136 -67
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/SignInLink.js +8 -7
- package/dist/default/SignInLink.js.map +1 -1
- package/dist/default/StatusTags.d.ts +16 -5
- package/dist/default/StatusTags.d.ts.map +1 -1
- package/dist/default/StatusTags.js +19 -13
- package/dist/default/StatusTags.js.map +1 -1
- package/dist/default/index.d.ts +15 -9
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -6
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Favorites.d.ts +6 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +23 -7
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +12 -0
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +3 -0
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +23 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +33 -7
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +7 -5
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/headless/useMandateGate.d.ts +33 -2
- package/dist/headless/useMandateGate.d.ts.map +1 -1
- package/dist/headless/useMandateGate.js +42 -4
- package/dist/headless/useMandateGate.js.map +1 -1
- package/dist/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +23 -5
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +30 -3
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +47 -7
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +16 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +35 -5
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/draft.d.ts +9 -6
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +3 -1
- package/dist/model/draft.js.map +1 -1
- package/dist/model/features.d.ts +21 -7
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +26 -0
- package/dist/model/features.js.map +1 -1
- package/llms.txt +9 -8
- package/manifest.json +82 -31
- package/nav-manifest.json +1 -1
- package/package.json +19 -14
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +94 -4
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +283 -116
- package/src/default/ListingComposerPage.tsx +403 -187
- package/src/default/ListingDetailPane.tsx +200 -98
- package/src/default/ListingPhoto.tsx +89 -16
- package/src/default/ListingPrice.tsx +77 -0
- package/src/default/MyListingsPane.tsx +286 -228
- package/src/default/SignInLink.tsx +4 -4
- package/src/default/StatusTags.tsx +36 -18
- package/src/default/index.ts +18 -7
- package/src/headless/Favorites.tsx +36 -7
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/ListingDetail.tsx +12 -5
- package/src/headless/useMandateGate.ts +49 -5
- package/src/i18n/es.ts +29 -5
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +7 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +54 -7
- package/src/i18n/ru.ts +41 -5
- package/src/index.ts +5 -1
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -25
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -40
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
71
|
-
*
|
|
72
|
-
* the
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
179
|
-
|
|
180
|
-
<
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
-
<
|
|
230
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
) :
|
|
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
|
-
<
|
|
260
|
-
type="
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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={
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
</
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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"
|