@stapel/listings-react 0.3.1 → 0.4.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 +20 -0
- package/MODULE.md +12 -6
- package/README.md +33 -17
- package/dist/api/generated/schema.d.ts +127 -1
- 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/MyListingsPane.d.ts +24 -11
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +26 -10
- package/dist/default/MyListingsPane.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.map +1 -1
- package/dist/i18n/es.js +7 -2
- 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 +9 -2
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +14 -3
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +7 -2
- 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/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 +6 -4
- package/manifest.json +38 -6
- package/nav-manifest.json +1 -1
- package/package.json +1 -1
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +127 -1
- package/src/api/listingsApi.ts +60 -21
- package/src/api/types.ts +46 -2
- package/src/default/MyListingsPane.tsx +98 -34
- package/src/headless/MyListings.tsx +62 -22
- package/src/i18n/es.ts +10 -3
- 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 +16 -4
- package/src/i18n/ru.ts +10 -3
- package/src/index.ts +16 -5
- 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/src/api/listingsApi.ts
CHANGED
|
@@ -9,7 +9,9 @@ import type {
|
|
|
9
9
|
ListingPageParams,
|
|
10
10
|
ListingStatusInfo,
|
|
11
11
|
MyCounters,
|
|
12
|
+
MyListingsParams,
|
|
12
13
|
PaginatedListingCards,
|
|
14
|
+
PaginatedMyListingCards,
|
|
13
15
|
PublishResponse,
|
|
14
16
|
} from "./types.js";
|
|
15
17
|
import type { ValidationBatchResult } from "@stapel/attributes-react";
|
|
@@ -22,21 +24,16 @@ import type { ValidationBatchResult } from "@stapel/attributes-react";
|
|
|
22
24
|
*
|
|
23
25
|
* ── The two operations that are on the contract and NOT here ───────────────
|
|
24
26
|
*
|
|
25
|
-
* `PUT /{pk}/` and `PATCH /{pk}/` are absent
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* check, so the pair uses it and nothing is lost. Both stay in the generated
|
|
36
|
-
* schema and therefore in `manifest.json` — the contract is not hidden, this
|
|
37
|
-
* pair simply declines to be the client that exercises it. Upstream ask,
|
|
38
|
-
* recorded in MODULE.md: put `_get_own` in front of `update`/`partial_update`
|
|
39
|
-
* (or drop them from the router).
|
|
27
|
+
* `PUT /{pk}/` and `PATCH /{pk}/` are absent. That began as a safety decision:
|
|
28
|
+
* until stapel-listings 0.6.2 both were the plain `ModelViewSet`
|
|
29
|
+
* implementations under `IsAuthenticatedOrReadOnly` over
|
|
30
|
+
* `Listing.objects.all()`, so any authenticated caller could write any
|
|
31
|
+
* listing's draft fields through them — this pair filed the ask and declined
|
|
32
|
+
* to be the client that exercised the hole. 0.6.2 put `views._get_own` in
|
|
33
|
+
* front of both, so the hole is closed and the absence is now a plain scope
|
|
34
|
+
* decision: `POST /{pk}/save-draft/` performs the SAME write (the same
|
|
35
|
+
* `ListingDraftSerializer`, `partial=True`) and one write path is enough.
|
|
36
|
+
* Both stay in the generated schema and therefore in `manifest.json`.
|
|
40
37
|
*
|
|
41
38
|
* These operations will be GENERATED from schema.json operationIds by gen-api
|
|
42
39
|
* v2 (task `core-typed-ops`); until then they are hand-authored here (the ONE
|
|
@@ -70,12 +67,12 @@ export interface ListingsApi {
|
|
|
70
67
|
/**
|
|
71
68
|
* One listing in full.
|
|
72
69
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* `
|
|
77
|
-
*
|
|
78
|
-
*
|
|
70
|
+
* Since 0.6.2 the queryset is `visible_to(user)` — the indexed statuses for
|
|
71
|
+
* everyone plus one's OWN rows in any status — so a stranger's draft 404s
|
|
72
|
+
* from the same code path an absent id does. The `publiclyVisible` report
|
|
73
|
+
* `useListing` derives from `status` (`model/status.ts`) stays, and is now
|
|
74
|
+
* addressed at the one reader who still reaches an unpublished listing
|
|
75
|
+
* here: its owner, who needs to be told it is not on the shelf.
|
|
79
76
|
*/
|
|
80
77
|
retrieve(
|
|
81
78
|
id: number,
|
|
@@ -100,6 +97,26 @@ export interface ListingsApi {
|
|
|
100
97
|
* PENDING in with PUBLISHED, `drafts` folds REJECTED in with DRAFT. */
|
|
101
98
|
myCounters(options?: { readonly signal?: AbortSignal }): Promise<MyCounters>;
|
|
102
99
|
|
|
100
|
+
/**
|
|
101
|
+
* One keyset page of the caller's OWN listings, in every status.
|
|
102
|
+
*
|
|
103
|
+
* The route `list` is not and cannot be made into: `list` answers
|
|
104
|
+
* `published()` and takes no owner parameter, so before stapel-listings
|
|
105
|
+
* 0.7.0 a seller's own DRAFTS were unreachable by any call this contract
|
|
106
|
+
* offered — the gap `model/mineSource.ts` used to name on screen. Owner
|
|
107
|
+
* scope is a property of the ROUTE (`owned_by(request.user)`), not of a
|
|
108
|
+
* parameter a caller supplies, so there is no way to point it at anyone
|
|
109
|
+
* else.
|
|
110
|
+
*
|
|
111
|
+
* `params.status` narrows to a set of lifecycle statuses; omit it for all
|
|
112
|
+
* nine. Rows are {@link MyListingCard} — the public card plus
|
|
113
|
+
* `moderation_status` and the `*_draft` twins.
|
|
114
|
+
*/
|
|
115
|
+
myListings(
|
|
116
|
+
params?: MyListingsParams,
|
|
117
|
+
options?: { readonly signal?: AbortSignal }
|
|
118
|
+
): Promise<PaginatedMyListingCards>;
|
|
119
|
+
|
|
103
120
|
/** One keyset page of the caller's favourites. */
|
|
104
121
|
myFavorites(
|
|
105
122
|
params?: ListingPageParams,
|
|
@@ -182,6 +199,22 @@ function pageQuery(
|
|
|
182
199
|
};
|
|
183
200
|
}
|
|
184
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The `?status=` half of a `my/listings` query.
|
|
204
|
+
*
|
|
205
|
+
* One comma-separated value rather than a repeated parameter: both spellings
|
|
206
|
+
* are accepted upstream, and this one survives every `query` serializer a
|
|
207
|
+
* host's `StapelClient` might carry without depending on how it encodes an
|
|
208
|
+
* array. An empty set sends nothing at all — the route's own "every status".
|
|
209
|
+
*/
|
|
210
|
+
function statusQuery(
|
|
211
|
+
statuses: readonly string[] | undefined
|
|
212
|
+
): Record<string, string> {
|
|
213
|
+
return statuses !== undefined && statuses.length > 0
|
|
214
|
+
? { status: statuses.join(",") }
|
|
215
|
+
: {};
|
|
216
|
+
}
|
|
217
|
+
|
|
185
218
|
function signal(
|
|
186
219
|
options: { readonly signal?: AbortSignal } | undefined
|
|
187
220
|
): { signal?: AbortSignal } {
|
|
@@ -209,6 +242,12 @@ export function createListingsApi(client: StapelClient): ListingsApi {
|
|
|
209
242
|
myCounters: (options) =>
|
|
210
243
|
client.get(`${COLLECTION}my/counters/`, signal(options)),
|
|
211
244
|
|
|
245
|
+
myListings: (params, options) =>
|
|
246
|
+
client.get(`${COLLECTION}my/listings/`, {
|
|
247
|
+
query: { ...pageQuery(params), ...statusQuery(params?.status) },
|
|
248
|
+
...signal(options),
|
|
249
|
+
}),
|
|
250
|
+
|
|
212
251
|
myFavorites: (params, options) =>
|
|
213
252
|
client.get(`${COLLECTION}my/favorites/`, {
|
|
214
253
|
query: pageQuery(params),
|
package/src/api/types.ts
CHANGED
|
@@ -72,9 +72,34 @@ export type FavoriteToggleResponse = Schemas["FavoriteToggleResponse"];
|
|
|
72
72
|
/** `DELETE /{pk}/` 200. */
|
|
73
73
|
export type DeleteResponse = Schemas["DeleteResponse"];
|
|
74
74
|
|
|
75
|
-
/** The keyset envelope both card lists come back in
|
|
75
|
+
/** The keyset envelope both public card lists come back in
|
|
76
|
+
* (`IDAnchorPagination`). */
|
|
76
77
|
export type PaginatedListingCards = Schemas["PaginatedListingCardList"];
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* One row of `GET /listings/my/listings/` — the OWNER's card.
|
|
81
|
+
*
|
|
82
|
+
* A superset of {@link ListingCard}, and the two additions are the two things
|
|
83
|
+
* only an owner is owed (stapel-listings 0.7.0, `MyListingCardSerializer`):
|
|
84
|
+
*
|
|
85
|
+
* - `moderation_status`, the second axis. `model/status.ts` argues at length
|
|
86
|
+
* that a dashboard cannot derive "your edit is being screened" from
|
|
87
|
+
* `status` — a re-published LIVE listing keeps `status: "published"` and
|
|
88
|
+
* moves only this field. The public card omits it, so before 0.7.0 the
|
|
89
|
+
* dashboard row passed `"approved"` as an honest stand-in and simply could
|
|
90
|
+
* not show the row that most needed showing.
|
|
91
|
+
* - the `*_draft` twins. `title`/`price`/`images` are the PUBLISHED fields
|
|
92
|
+
* and are empty on a listing that has never been published, so a drafts
|
|
93
|
+
* tab keyed off them is a column of blank rows. `myListingTitle` /
|
|
94
|
+
* `myListingPrice` (`model/mine.ts`) are the one place the fallback lives.
|
|
95
|
+
*/
|
|
96
|
+
export type MyListingCard = Schemas["MyListingCard"];
|
|
97
|
+
|
|
98
|
+
/** The keyset envelope `GET /listings/my/listings/` comes back in — the same
|
|
99
|
+
* `IDAnchorPagination` shape as {@link PaginatedListingCards}, over the owner
|
|
100
|
+
* row. */
|
|
101
|
+
export type PaginatedMyListingCards = Schemas["PaginatedMyListingCardList"];
|
|
102
|
+
|
|
78
103
|
/**
|
|
79
104
|
* The nine lifecycle states, as `models.ListingStatus` declares them.
|
|
80
105
|
*
|
|
@@ -173,7 +198,7 @@ export interface ListingFeatureView {
|
|
|
173
198
|
* `@stapel/attributes-react` directly. */
|
|
174
199
|
export type { FeatureConfig, FeatureDef, FeatureValueDto };
|
|
175
200
|
|
|
176
|
-
/** Query parameters of
|
|
201
|
+
/** Query parameters of every keyset-paginated card list. */
|
|
177
202
|
export interface ListingPageParams {
|
|
178
203
|
/** Opaque cursor from a previous answer. */
|
|
179
204
|
readonly anchor?: string;
|
|
@@ -181,6 +206,25 @@ export interface ListingPageParams {
|
|
|
181
206
|
readonly limit?: number;
|
|
182
207
|
}
|
|
183
208
|
|
|
209
|
+
/**
|
|
210
|
+
* `GET /listings/my/listings/` — a page, plus the statuses to narrow to.
|
|
211
|
+
*
|
|
212
|
+
* `status` is a SET because a dashboard tab is one (`active` is
|
|
213
|
+
* `published` + `pending`, `drafts` is `draft` + `rejected` — the groupings
|
|
214
|
+
* are the server's, copied in `model/status.ts` so a tab's rows and its count
|
|
215
|
+
* cannot describe different sets). An empty or absent array means every
|
|
216
|
+
* status, which is what the route answers with no parameter at all.
|
|
217
|
+
*
|
|
218
|
+
* On the wire it goes as one comma-separated value; stapel-listings accepts
|
|
219
|
+
* that and the repeated-parameter spelling interchangeably
|
|
220
|
+
* (`views.parse_status_filter`). An unknown value is a `400`
|
|
221
|
+
* `error.400.listing_invalid_status_filter`, not an empty page — so a status
|
|
222
|
+
* this pair does not know about cannot silently look like "you have none".
|
|
223
|
+
*/
|
|
224
|
+
export interface MyListingsParams extends ListingPageParams {
|
|
225
|
+
readonly status?: readonly ListingLifecycleStatus[];
|
|
226
|
+
}
|
|
227
|
+
|
|
184
228
|
/**
|
|
185
229
|
* The currency a composer starts a new listing in.
|
|
186
230
|
*
|
|
@@ -1,16 +1,29 @@
|
|
|
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.
|
|
14
27
|
*/
|
|
15
28
|
import type { ReactElement } from "react";
|
|
16
29
|
import {
|
|
@@ -28,10 +41,11 @@ import {
|
|
|
28
41
|
} from "antd";
|
|
29
42
|
import { matchList, matchLoad, useDescribeFlowError, useT } from "@stapel/core";
|
|
30
43
|
import type { ActionAvailability } from "@stapel/core";
|
|
31
|
-
import type {
|
|
44
|
+
import type { MyListingCard } from "../api/types.js";
|
|
32
45
|
import { useMyListings } from "../headless/MyListings.js";
|
|
33
46
|
import type { MyListingsSource } from "../model/mineSource.js";
|
|
34
47
|
import { useListingActions } from "../headless/ListingActions.js";
|
|
48
|
+
import { myListingTitle, showsDraft } from "../model/mine.js";
|
|
35
49
|
import { listingStatusView } from "../model/status.js";
|
|
36
50
|
import type { MyListingsTab } from "../model/status.js";
|
|
37
51
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -46,6 +60,13 @@ const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
|
|
|
46
60
|
archived: LISTINGS_I18N_KEYS.mineTabArchived,
|
|
47
61
|
};
|
|
48
62
|
|
|
63
|
+
/** One empty sentence per tab — see the header, point 3. */
|
|
64
|
+
const TAB_EMPTY: Readonly<Record<MyListingsTab, string>> = {
|
|
65
|
+
active: LISTINGS_I18N_KEYS.mineEmptyActive,
|
|
66
|
+
drafts: LISTINGS_I18N_KEYS.mineEmptyDrafts,
|
|
67
|
+
archived: LISTINGS_I18N_KEYS.mineEmptyArchived,
|
|
68
|
+
};
|
|
69
|
+
|
|
49
70
|
/** A control plus the sentence that explains it when it is off. Written once
|
|
50
71
|
* here because this pane has six of them and a grey button with no reason is
|
|
51
72
|
* the one shape `ActionAvailability` exists to make impossible. */
|
|
@@ -89,21 +110,24 @@ function GatedButton(props: {
|
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
function MyListingRow(props: {
|
|
92
|
-
listing:
|
|
113
|
+
listing: MyListingCard;
|
|
93
114
|
onEdit?: (id: number) => void;
|
|
94
115
|
}): ReactElement {
|
|
95
116
|
const t = useT();
|
|
96
117
|
const describe = useDescribeFlowError();
|
|
97
118
|
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.
|
|
119
|
+
// Both axes, both real: `MyListingCardSerializer` puts `moderation_status`
|
|
120
|
+
// on the owner's card, so the row no longer has to pass "approved" as a
|
|
121
|
+
// stand-in and lose the one combination that matters — a LIVE listing whose
|
|
122
|
+
// edit is under review.
|
|
103
123
|
const status =
|
|
104
124
|
props.listing.status === undefined
|
|
105
125
|
? undefined
|
|
106
|
-
: listingStatusView(
|
|
126
|
+
: listingStatusView(
|
|
127
|
+
props.listing.status,
|
|
128
|
+
props.listing.moderation_status ?? "approved"
|
|
129
|
+
);
|
|
130
|
+
const heading = myListingTitle(props.listing);
|
|
107
131
|
|
|
108
132
|
return (
|
|
109
133
|
<List.Item
|
|
@@ -152,7 +176,19 @@ function MyListingRow(props: {
|
|
|
152
176
|
]}
|
|
153
177
|
>
|
|
154
178
|
<List.Item.Meta
|
|
155
|
-
title={
|
|
179
|
+
title={
|
|
180
|
+
<Space size={6}>
|
|
181
|
+
{heading ?? `#${String(props.listing.id)}`}
|
|
182
|
+
{/* A heading that came off the draft twin is something nobody
|
|
183
|
+
else can read yet. Saying so is the difference between a
|
|
184
|
+
dashboard and a shop window that happens to be yours. */}
|
|
185
|
+
{showsDraft(props.listing) ? (
|
|
186
|
+
<Typography.Text type="secondary" data-testid="listings-mine-draft-title">
|
|
187
|
+
{t(LISTINGS_I18N_KEYS.statusDraft)}
|
|
188
|
+
</Typography.Text>
|
|
189
|
+
) : null}
|
|
190
|
+
</Space>
|
|
191
|
+
}
|
|
156
192
|
description={
|
|
157
193
|
<Flex vertical gap={4}>
|
|
158
194
|
{status !== undefined ? <ListingStatusBlock status={status} /> : null}
|
|
@@ -176,8 +212,8 @@ function MyListingRow(props: {
|
|
|
176
212
|
}
|
|
177
213
|
|
|
178
214
|
export interface MyListingsPaneProps extends ThemeModeProp {
|
|
179
|
-
/** How the host gets the caller's own rows. Absent: the
|
|
180
|
-
*
|
|
215
|
+
/** How the host gets the caller's own rows. Absent: the contract's own
|
|
216
|
+
* `GET my/listings/`, which is what a storefront wants. */
|
|
181
217
|
readonly source?: MyListingsSource;
|
|
182
218
|
readonly onEdit?: (id: number) => void;
|
|
183
219
|
}
|
|
@@ -205,6 +241,46 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
|
205
241
|
/>
|
|
206
242
|
) : null}
|
|
207
243
|
|
|
244
|
+
{/* The rows no tab folds in — see the header, point 2. Rendered
|
|
245
|
+
only when there are some: an empty takedown section is a scare,
|
|
246
|
+
and a failure to CHECK is not the same as "none", so it says so. */}
|
|
247
|
+
{matchList(bag.blockedRows, {
|
|
248
|
+
loading: () => null,
|
|
249
|
+
failed: () => (
|
|
250
|
+
<Typography.Text
|
|
251
|
+
type="secondary"
|
|
252
|
+
data-testid="listings-mine-takedowns-failed"
|
|
253
|
+
>
|
|
254
|
+
{t(LISTINGS_I18N_KEYS.mineBlockedLoadFailed)}
|
|
255
|
+
</Typography.Text>
|
|
256
|
+
),
|
|
257
|
+
empty: () => null,
|
|
258
|
+
ready: (rows) => (
|
|
259
|
+
<Alert
|
|
260
|
+
type="error"
|
|
261
|
+
showIcon
|
|
262
|
+
data-testid="listings-mine-takedowns"
|
|
263
|
+
message={t(LISTINGS_I18N_KEYS.mineBlockedTitle, {
|
|
264
|
+
count: rows.length,
|
|
265
|
+
})}
|
|
266
|
+
description={
|
|
267
|
+
<List
|
|
268
|
+
dataSource={[...rows]}
|
|
269
|
+
rowKey={(row) => row.id}
|
|
270
|
+
renderItem={(row) => (
|
|
271
|
+
<MyListingRow
|
|
272
|
+
listing={row}
|
|
273
|
+
{...(props.onEdit !== undefined
|
|
274
|
+
? { onEdit: props.onEdit }
|
|
275
|
+
: {})}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
278
|
+
/>
|
|
279
|
+
}
|
|
280
|
+
/>
|
|
281
|
+
),
|
|
282
|
+
})}
|
|
283
|
+
|
|
208
284
|
<Tabs
|
|
209
285
|
activeKey={bag.tab}
|
|
210
286
|
data-testid="listings-mine-tabs"
|
|
@@ -275,7 +351,8 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
|
275
351
|
empty: () => (
|
|
276
352
|
<Empty
|
|
277
353
|
data-testid="listings-mine-empty"
|
|
278
|
-
|
|
354
|
+
data-empty-tab={bag.tab}
|
|
355
|
+
description={t(TAB_EMPTY[bag.tab])}
|
|
279
356
|
/>
|
|
280
357
|
),
|
|
281
358
|
ready: (rows) => (
|
|
@@ -292,19 +369,6 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
|
292
369
|
),
|
|
293
370
|
})}
|
|
294
371
|
|
|
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
|
-
/>
|
|
306
|
-
) : null}
|
|
307
|
-
|
|
308
372
|
<Space>
|
|
309
373
|
<Button
|
|
310
374
|
size="small"
|
|
@@ -11,28 +11,36 @@ import {
|
|
|
11
11
|
} from "@stapel/core";
|
|
12
12
|
import type { ActionAvailability, LoadState } from "@stapel/core";
|
|
13
13
|
import type {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
MyListingCard,
|
|
15
|
+
MyListingsParams,
|
|
16
16
|
MyCounters,
|
|
17
17
|
} from "../api/types.js";
|
|
18
|
-
import {
|
|
18
|
+
import { defaultMyListingsSource } from "../model/mineSource.js";
|
|
19
19
|
import type { MyListingsSource } from "../model/mineSource.js";
|
|
20
|
+
import { useListingsApi } from "../model/context.js";
|
|
20
21
|
import { useMyCounters } from "../model/queries.js";
|
|
21
22
|
import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
|
|
22
|
-
import { MY_LISTINGS_TABS } from "../model/status.js";
|
|
23
|
+
import { MY_LISTINGS_TABS, MY_LISTINGS_UNTABBED_STATUSES } from "../model/status.js";
|
|
23
24
|
import type { MyListingsTab } from "../model/status.js";
|
|
24
25
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
25
26
|
import { useMandateGate } from "./useMandateGate.js";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* The owner's dashboard
|
|
29
|
-
* backend cannot supply.
|
|
29
|
+
* The owner's dashboard.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* it.
|
|
31
|
+
* Three counts and the rows behind them, both owner-scoped reads of
|
|
32
|
+
* stapel-listings: `my/counters` and — since 0.7.0 — `my/listings`. Until
|
|
33
|
+
* that release the rows had no route at all and this hook failed them with a
|
|
34
|
+
* named reason rather than rendering an empty grid; `model/mineSource.ts`
|
|
35
|
+
* keeps the argument and the seam that came out of it.
|
|
36
|
+
*
|
|
37
|
+
* TWO reads, not one, and the second one is the point of this file's shape:
|
|
38
|
+
* the three tabs are the SERVER's status groupings, and `blocked` — a
|
|
39
|
+
* moderation takedown — is in none of them, because `my/counters` counts it
|
|
40
|
+
* in none of them. A dashboard that only ever asked for a tab's statuses
|
|
41
|
+
* would hide exactly the listing whose owner most needs to know. So
|
|
42
|
+
* `blockedRows` is fetched beside them, off the same route, narrowed to
|
|
43
|
+
* whatever `MY_LISTINGS_UNTABBED_STATUSES` derives.
|
|
36
44
|
*/
|
|
37
45
|
|
|
38
46
|
export interface MyListingsBag {
|
|
@@ -41,10 +49,16 @@ export interface MyListingsBag {
|
|
|
41
49
|
setTab(tab: MyListingsTab): void;
|
|
42
50
|
/** The three real counts. */
|
|
43
51
|
readonly counters: LoadState<MyCounters>;
|
|
44
|
-
/** The rows for the current tab.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
/** The rows for the current tab. */
|
|
53
|
+
readonly rows: LoadState<readonly MyListingCard[]>;
|
|
54
|
+
/**
|
|
55
|
+
* The rows no tab folds in — a moderation takedown, today. Empty for
|
|
56
|
+
* almost every seller; when it is not, it is the most important thing on
|
|
57
|
+
* the screen. Never `failed` in a way that hides the tabs: this read is
|
|
58
|
+
* independent of `rows` and a skin renders it beside them.
|
|
59
|
+
*/
|
|
60
|
+
readonly blockedRows: LoadState<readonly MyListingCard[]>;
|
|
61
|
+
readonly page: MyListingsParams;
|
|
48
62
|
readonly nextPage: ActionAvailability;
|
|
49
63
|
readonly prevPage: ActionAvailability;
|
|
50
64
|
goNext(): void;
|
|
@@ -55,6 +69,8 @@ export interface MyListingsBag {
|
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
export interface UseMyListingsOptions {
|
|
72
|
+
/** Replace the contract's own `my/listings` read — a deployment that keeps
|
|
73
|
+
* its sellers' rows somewhere else. Absent: {@link defaultMyListingsSource}. */
|
|
58
74
|
readonly source?: MyListingsSource;
|
|
59
75
|
readonly initialTab?: MyListingsTab;
|
|
60
76
|
readonly limit?: number;
|
|
@@ -68,27 +84,49 @@ export function useMyListings(
|
|
|
68
84
|
const [tab, setTabState] = useState<MyListingsTab>(
|
|
69
85
|
options.initialTab ?? "active"
|
|
70
86
|
);
|
|
71
|
-
const [page, setPage] = useState<
|
|
87
|
+
const [page, setPage] = useState<MyListingsParams>(
|
|
72
88
|
options.limit !== undefined ? { limit: options.limit } : {}
|
|
73
89
|
);
|
|
74
90
|
|
|
75
91
|
const counters = useMyCounters();
|
|
76
|
-
const
|
|
92
|
+
const api = useListingsApi();
|
|
93
|
+
const injected = options.source;
|
|
94
|
+
const source = useMemo(
|
|
95
|
+
() => injected ?? defaultMyListingsSource(api),
|
|
96
|
+
[injected, api]
|
|
97
|
+
);
|
|
98
|
+
const ready = sessionReady && gate.available;
|
|
77
99
|
|
|
78
100
|
const rowsQuery = useQuery({
|
|
79
101
|
queryKey: listingsQueryKeys.mine(tab, pageKey(page)),
|
|
102
|
+
queryFn: ({ signal }) => source({ tab, page, signal }),
|
|
103
|
+
enabled: ready,
|
|
104
|
+
retry: false,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The takedowns, off the same route and deliberately NOT paged: a seller
|
|
108
|
+
// with a page of blocked listings has a problem no "next" button improves,
|
|
109
|
+
// and this sits above a dashboard rather than being one.
|
|
110
|
+
const blockedQuery = useQuery({
|
|
111
|
+
queryKey: listingsQueryKeys.mineUntabbed(),
|
|
80
112
|
queryFn: ({ signal }) =>
|
|
81
|
-
(
|
|
82
|
-
enabled:
|
|
113
|
+
api.myListings({ status: MY_LISTINGS_UNTABBED_STATUSES }, { signal }),
|
|
114
|
+
enabled: ready && MY_LISTINGS_UNTABBED_STATUSES.length > 0,
|
|
83
115
|
retry: false,
|
|
84
116
|
});
|
|
85
117
|
|
|
86
|
-
const rows: LoadState<readonly
|
|
87
|
-
if (source === undefined) return loadFailed(MY_LISTINGS_SOURCE_MISSING);
|
|
118
|
+
const rows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
88
119
|
if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
|
|
89
120
|
if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
|
|
90
121
|
return loadLoading();
|
|
91
|
-
}, [
|
|
122
|
+
}, [rowsQuery.status, rowsQuery.error, rowsQuery.data]);
|
|
123
|
+
|
|
124
|
+
const blockedRows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
125
|
+
if (MY_LISTINGS_UNTABBED_STATUSES.length === 0) return loadReady([]);
|
|
126
|
+
if (blockedQuery.status === "error") return loadFailed(blockedQuery.error);
|
|
127
|
+
if (blockedQuery.data !== undefined) return loadReady(blockedQuery.data.items);
|
|
128
|
+
return loadLoading();
|
|
129
|
+
}, [blockedQuery.status, blockedQuery.error, blockedQuery.data]);
|
|
92
130
|
|
|
93
131
|
const envelope = rowsQuery.data;
|
|
94
132
|
|
|
@@ -110,6 +148,7 @@ export function useMyListings(
|
|
|
110
148
|
? loadReady(counters.data)
|
|
111
149
|
: loadLoading(),
|
|
112
150
|
rows,
|
|
151
|
+
blockedRows,
|
|
113
152
|
page,
|
|
114
153
|
nextPage:
|
|
115
154
|
envelope?.has_next === true && envelope.next_anchor != null
|
|
@@ -133,6 +172,7 @@ export function useMyListings(
|
|
|
133
172
|
refetch: () => {
|
|
134
173
|
void counters.refetch();
|
|
135
174
|
void rowsQuery.refetch();
|
|
175
|
+
void blockedQuery.refetch();
|
|
136
176
|
},
|
|
137
177
|
};
|
|
138
178
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -21,12 +21,14 @@ export { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
|
21
21
|
export const listingsI18nBundleEs: I18nDictionary = {
|
|
22
22
|
...listingsErrorBundleEs,
|
|
23
23
|
|
|
24
|
-
// ── the
|
|
24
|
+
// ── the 10 stapel_listings-owned codes, pair-authored ────────────────────
|
|
25
25
|
"error.400.category_required": "Hay que elegir una categoría",
|
|
26
26
|
"error.400.image_required":
|
|
27
27
|
"Para publicar el anuncio hace falta al menos una foto",
|
|
28
28
|
"error.400.listing_feature_not_allowed":
|
|
29
29
|
"El detalle «{feature}» no pertenece a esta categoría",
|
|
30
|
+
"error.400.listing_invalid_status_filter":
|
|
31
|
+
"Estado de anuncio desconocido: «{status}»",
|
|
30
32
|
"error.400.publish_validation_failed":
|
|
31
33
|
"El anuncio no pasó la revisión y no se publicó",
|
|
32
34
|
"error.403.listing_not_owner": "Este anuncio no es tuyo",
|
|
@@ -160,8 +162,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
160
162
|
"listings.mine.empty": "Aquí todavía no hay nada",
|
|
161
163
|
"listings.mine.retry": "Reintentar",
|
|
162
164
|
"listings.mine.counters_failed": "No pudimos contar tus anuncios",
|
|
163
|
-
"listings.mine.
|
|
164
|
-
|
|
165
|
+
"listings.mine.empty.active": "No tienes nada publicado ni en revisión",
|
|
166
|
+
"listings.mine.empty.drafts": "Sin borradores: lo que empieces aparecerá aquí",
|
|
167
|
+
"listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
|
|
168
|
+
"listings.mine.blocked.title":
|
|
169
|
+
"Moderación retiró {count} de tus anuncios",
|
|
170
|
+
"listings.mine.blocked.load_failed":
|
|
171
|
+
"No pudimos comprobar si alguno de tus anuncios fue retirado",
|
|
165
172
|
"listings.mine.live_under_review": "Publicado, cambios en revisión",
|
|
166
173
|
"listings.mine.edit": "Editar",
|
|
167
174
|
"listings.mine.archive": "Archivar",
|
|
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
|
|
|
8
8
|
/**
|
|
9
9
|
* `es` texts for the backend error codes this catalog carries.
|
|
10
10
|
*
|
|
11
|
-
* PARTIAL, and deliberately typed to say so:
|
|
11
|
+
* PARTIAL, and deliberately typed to say so: 22 key(s) owned by
|
|
12
12
|
* stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
|
|
13
13
|
* (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
|
|
14
14
|
* artifact via the en bundle; the pair layers its own authored `es`
|
|
@@ -66,6 +66,7 @@ export const LISTINGS_ERRORS = {
|
|
|
66
66
|
"error.400.image_required": { status: 400, params: [], remediation: "fix_input", en: "At least one image is required to publish" },
|
|
67
67
|
"error.400.invalid_ad_id": { status: 400, params: [], remediation: "fix_input", en: "Invalid advertisement ID" },
|
|
68
68
|
"error.400.listing_feature_not_allowed": { status: 400, params: ["feature"], remediation: "fix_input", en: "Feature '{feature}' is not allowed for this category" },
|
|
69
|
+
"error.400.listing_invalid_status_filter": { status: 400, params: ["status"], remediation: "fix_input", en: "Unknown listing status '{status}'" },
|
|
69
70
|
"error.400.publish_validation_failed": { status: 400, params: [], remediation: "fix_input", en: "Listing validation failed" },
|
|
70
71
|
"error.400.validation_error": { status: 400, params: [], remediation: "fix_input", en: "Validation error" },
|
|
71
72
|
"error.400.verification_failed": { status: 400, params: [], remediation: "verify", en: "Verification failed" },
|
|
@@ -135,6 +136,7 @@ export const LISTINGS_ERROR_CODES: readonly ListingsErrorCode[] = [
|
|
|
135
136
|
"error.400.image_required",
|
|
136
137
|
"error.400.invalid_ad_id",
|
|
137
138
|
"error.400.listing_feature_not_allowed",
|
|
139
|
+
"error.400.listing_invalid_status_filter",
|
|
138
140
|
"error.400.publish_validation_failed",
|
|
139
141
|
"error.400.validation_error",
|
|
140
142
|
"error.400.verification_failed",
|
|
@@ -206,6 +208,7 @@ export const listingsErrorBundleEn: Record<ListingsErrorCode, string> = {
|
|
|
206
208
|
"error.400.image_required": "At least one image is required to publish",
|
|
207
209
|
"error.400.invalid_ad_id": "Invalid advertisement ID",
|
|
208
210
|
"error.400.listing_feature_not_allowed": "Feature '{feature}' is not allowed for this category",
|
|
211
|
+
"error.400.listing_invalid_status_filter": "Unknown listing status '{status}'",
|
|
209
212
|
"error.400.publish_validation_failed": "Listing validation failed",
|
|
210
213
|
"error.400.validation_error": "Validation error",
|
|
211
214
|
"error.400.verification_failed": "Verification failed",
|
|
@@ -268,6 +268,15 @@
|
|
|
268
268
|
"remediation": "fix_input",
|
|
269
269
|
"en": "Feature '{feature}' is not allowed for this category"
|
|
270
270
|
},
|
|
271
|
+
{
|
|
272
|
+
"code": "error.400.listing_invalid_status_filter",
|
|
273
|
+
"status": 400,
|
|
274
|
+
"params": [
|
|
275
|
+
"status"
|
|
276
|
+
],
|
|
277
|
+
"remediation": "fix_input",
|
|
278
|
+
"en": "Unknown listing status '{status}'"
|
|
279
|
+
},
|
|
271
280
|
{
|
|
272
281
|
"code": "error.400.publish_validation_failed",
|
|
273
282
|
"status": 400,
|
|
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
|
|
|
8
8
|
/**
|
|
9
9
|
* `ru` texts for the backend error codes this catalog carries.
|
|
10
10
|
*
|
|
11
|
-
* PARTIAL, and deliberately typed to say so:
|
|
11
|
+
* PARTIAL, and deliberately typed to say so: 22 key(s) owned by
|
|
12
12
|
* stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
|
|
13
13
|
* (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
|
|
14
14
|
* artifact via the en bundle; the pair layers its own authored `ru`
|