@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.
- package/CHANGELOG.md +122 -0
- package/MODULE.md +12 -6
- package/README.md +33 -17
- package/dist/api/generated/schema.d.ts +129 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +35 -22
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +17 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +43 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.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 +25 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +19 -12
- 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 +9 -17
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +148 -63
- 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 +4 -0
- 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/MyListings.d.ts +27 -12
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +29 -7
- package/dist/headless/MyListings.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 +28 -6
- 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 +28 -4
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +49 -8
- 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 +40 -6
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- 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/dist/model/mine.d.ts +44 -0
- package/dist/model/mine.d.ts.map +1 -0
- package/dist/model/mine.js +52 -0
- package/dist/model/mine.js.map +1 -0
- package/dist/model/mineSource.d.ts +39 -34
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js +10 -41
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +11 -4
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queryKeys.d.ts +7 -2
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +2 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/status.d.ts +12 -31
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +44 -0
- package/dist/model/status.js.map +1 -1
- package/llms.txt +11 -8
- package/manifest.json +88 -14
- package/nav-manifest.json +1 -1
- package/package.json +17 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +129 -3
- package/src/api/listingsApi.ts +60 -21
- package/src/api/types.ts +46 -2
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +88 -46
- 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 +344 -222
- 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 +10 -0
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/MyListings.tsx +62 -22
- package/src/i18n/es.ts +36 -7
- 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 +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +58 -9
- package/src/i18n/ru.ts +48 -7
- package/src/index.ts +16 -5
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/src/model/mine.ts +70 -0
- package/src/model/mineSource.ts +46 -41
- package/src/model/mutations.ts +11 -4
- package/src/model/queryKeys.ts +9 -2
- package/src/model/status.ts +15 -0
- 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
|
@@ -1,43 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `<MyListingsPane>` — the seller's dashboard.
|
|
3
3
|
*
|
|
4
|
-
* Three counts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
180
|
-
*
|
|
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
|
|
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
|
-
<
|
|
194
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
) :
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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={
|
|
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
|
-
{/*
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
</
|
|
453
|
+
</SkinTheme>
|
|
332
454
|
);
|
|
333
455
|
}
|