@stapel/listings-react 0.3.0 → 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 +53 -0
- package/MODULE.md +26 -10
- 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/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +1 -1
- package/dist/default/ListingDetailPane.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 +8 -3
- 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 +12 -2
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +18 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +8 -3
- 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 +4 -4
- 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/ListingDetailPane.tsx +6 -1
- package/src/default/MyListingsPane.tsx +98 -34
- package/src/headless/MyListings.tsx +62 -22
- package/src/i18n/es.ts +11 -4
- 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 +20 -5
- package/src/i18n/ru.ts +11 -4
- 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
|
@@ -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",
|
|
@@ -91,7 +93,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
91
93
|
"listings.detail.photo_alt": "Foto {index} de {total}",
|
|
92
94
|
"listings.detail.published_at": "Publicado el {date}",
|
|
93
95
|
"listings.detail.expires_at": "A la venta hasta el {date}",
|
|
94
|
-
"listings.detail.stock": "Disponibles
|
|
96
|
+
"listings.detail.stock": "Disponibles",
|
|
95
97
|
|
|
96
98
|
"listings.compose.new_title": "Anuncio nuevo",
|
|
97
99
|
"listings.compose.edit_title": "Editar el anuncio",
|
|
@@ -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`
|
package/src/i18n/keys.ts
CHANGED
|
@@ -84,6 +84,9 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
84
84
|
detailPhotoAlt: "listings.detail.photo_alt",
|
|
85
85
|
detailPublishedAt: "listings.detail.published_at",
|
|
86
86
|
detailExpiresAt: "listings.detail.expires_at",
|
|
87
|
+
/** The stock ROW'S LABEL. antd's `<Descriptions>` renders the colon and the
|
|
88
|
+
* quantity in the value cell, so this key carries no `{count}` — it used to,
|
|
89
|
+
* and the live page printed the placeholder. */
|
|
87
90
|
detailStock: "listings.detail.stock",
|
|
88
91
|
|
|
89
92
|
// ── composer ─────────────────────────────────────────────────────────────
|
|
@@ -141,8 +144,15 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
141
144
|
mineEmpty: "listings.mine.empty",
|
|
142
145
|
mineRetry: "listings.mine.retry",
|
|
143
146
|
mineCountersFailed: "listings.mine.counters_failed",
|
|
144
|
-
/**
|
|
145
|
-
|
|
147
|
+
/** Per-tab empties: "no drafts" and "nothing sold yet" are different
|
|
148
|
+
* sentences, and one generic "nothing here" says neither. */
|
|
149
|
+
mineEmptyActive: "listings.mine.empty.active",
|
|
150
|
+
mineEmptyDrafts: "listings.mine.empty.drafts",
|
|
151
|
+
mineEmptyArchived: "listings.mine.empty.archived",
|
|
152
|
+
/** The takedowns — the rows no tab folds in (`my/counters` counts them in
|
|
153
|
+
* none), shown outside the tabs so they cannot be missed. */
|
|
154
|
+
mineBlockedTitle: "listings.mine.blocked.title",
|
|
155
|
+
mineBlockedLoadFailed: "listings.mine.blocked.load_failed",
|
|
146
156
|
mineLiveUnderReview: "listings.mine.live_under_review",
|
|
147
157
|
mineEdit: "listings.mine.edit",
|
|
148
158
|
mineArchive: "listings.mine.archive",
|
|
@@ -241,7 +251,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
241
251
|
"listings.detail.photo_alt": "Photo {index} of {total}",
|
|
242
252
|
"listings.detail.published_at": "Published {date}",
|
|
243
253
|
"listings.detail.expires_at": "Listed until {date}",
|
|
244
|
-
"listings.detail.stock": "
|
|
254
|
+
"listings.detail.stock": "In stock",
|
|
245
255
|
|
|
246
256
|
"listings.compose.new_title": "New listing",
|
|
247
257
|
"listings.compose.edit_title": "Edit listing",
|
|
@@ -310,8 +320,13 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
310
320
|
"listings.mine.empty": "Nothing here yet",
|
|
311
321
|
"listings.mine.retry": "Try again",
|
|
312
322
|
"listings.mine.counters_failed": "We could not count your listings",
|
|
313
|
-
"listings.mine.
|
|
314
|
-
|
|
323
|
+
"listings.mine.empty.active": "Nothing of yours is live or awaiting review",
|
|
324
|
+
"listings.mine.empty.drafts": "No drafts — anything you start appears here",
|
|
325
|
+
"listings.mine.empty.archived": "Nothing archived, paused, expired or sold yet",
|
|
326
|
+
"listings.mine.blocked.title":
|
|
327
|
+
"{count} of your listings were taken down by moderation",
|
|
328
|
+
"listings.mine.blocked.load_failed":
|
|
329
|
+
"We could not check whether any of your listings were taken down",
|
|
315
330
|
"listings.mine.live_under_review":
|
|
316
331
|
"Published, changes under review",
|
|
317
332
|
"listings.mine.edit": "Edit",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -37,12 +37,14 @@ export { listingsErrorBundleRu } from "./generated/errors.ru.gen.js";
|
|
|
37
37
|
export const listingsI18nBundleRu: I18nDictionary = {
|
|
38
38
|
...listingsErrorBundleRu,
|
|
39
39
|
|
|
40
|
-
// ── the
|
|
40
|
+
// ── the 10 stapel_listings-owned codes, pair-authored ────────────────────
|
|
41
41
|
"error.400.category_required": "Нужно выбрать категорию",
|
|
42
42
|
"error.400.image_required":
|
|
43
43
|
"Чтобы опубликовать объявление, добавьте хотя бы одно фото",
|
|
44
44
|
"error.400.listing_feature_not_allowed":
|
|
45
45
|
"Характеристика «{feature}» не относится к этой категории",
|
|
46
|
+
"error.400.listing_invalid_status_filter":
|
|
47
|
+
"Неизвестный статус объявления: «{status}»",
|
|
46
48
|
"error.400.publish_validation_failed":
|
|
47
49
|
"Объявление не прошло проверку и не опубликовано",
|
|
48
50
|
"error.403.listing_not_owner": "Это не ваше объявление",
|
|
@@ -107,7 +109,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
107
109
|
"listings.detail.photo_alt": "Фото {index} из {total}",
|
|
108
110
|
"listings.detail.published_at": "Опубликовано {date}",
|
|
109
111
|
"listings.detail.expires_at": "В продаже до {date}",
|
|
110
|
-
"listings.detail.stock": "В
|
|
112
|
+
"listings.detail.stock": "В наличии",
|
|
111
113
|
|
|
112
114
|
"listings.compose.new_title": "Новое объявление",
|
|
113
115
|
"listings.compose.edit_title": "Редактирование объявления",
|
|
@@ -177,8 +179,13 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
177
179
|
"listings.mine.empty": "Здесь пока пусто",
|
|
178
180
|
"listings.mine.retry": "Повторить",
|
|
179
181
|
"listings.mine.counters_failed": "Не удалось посчитать ваши объявления",
|
|
180
|
-
"listings.mine.
|
|
181
|
-
|
|
182
|
+
"listings.mine.empty.active": "Ни одно ваше объявление не опубликовано и не ждёт проверки",
|
|
183
|
+
"listings.mine.empty.drafts": "Черновиков нет — всё начатое появится здесь",
|
|
184
|
+
"listings.mine.empty.archived": "Пока ничего не в архиве, не снято, не истекло и не продано",
|
|
185
|
+
"listings.mine.blocked.title":
|
|
186
|
+
"Модерация сняла ваших объявлений: {count}",
|
|
187
|
+
"listings.mine.blocked.load_failed":
|
|
188
|
+
"Не удалось проверить, снимала ли модерация ваши объявления",
|
|
182
189
|
"listings.mine.live_under_review": "Опубликовано, правки на проверке",
|
|
183
190
|
"listings.mine.edit": "Редактировать",
|
|
184
191
|
"listings.mine.archive": "В архив",
|
package/src/index.ts
CHANGED
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
* a grey button and never a hidden one.
|
|
39
39
|
* 4. **An absence is never rendered as a zero.** A soft-deleted listing says
|
|
40
40
|
* it was removed rather than 404-ing like a typo; a stored feature this
|
|
41
|
-
* build cannot read is counted rather than dropped;
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* build cannot read is counted rather than dropped; an empty dashboard tab
|
|
42
|
+
* says WHICH emptiness it is; and a moderation takedown — the one status
|
|
43
|
+
* `my/counters` counts in no tab at all — is shown outside the tabs rather
|
|
44
|
+
* than falling out of the screen (`headless/MyListings.tsx`).
|
|
44
45
|
*
|
|
45
46
|
* ── The seams, and why they are not imports ────────────────────────────────
|
|
46
47
|
*
|
|
@@ -88,7 +89,10 @@ export type {
|
|
|
88
89
|
ListingPageParams,
|
|
89
90
|
ListingStatusInfo,
|
|
90
91
|
MyCounters,
|
|
92
|
+
MyListingCard,
|
|
93
|
+
MyListingsParams,
|
|
91
94
|
PaginatedListingCards,
|
|
95
|
+
PaginatedMyListingCards,
|
|
92
96
|
PublishResponse,
|
|
93
97
|
Schemas,
|
|
94
98
|
} from "./api/types.js";
|
|
@@ -97,6 +101,7 @@ export type {
|
|
|
97
101
|
export {
|
|
98
102
|
MY_LISTINGS_TABS,
|
|
99
103
|
MY_LISTINGS_TAB_STATUSES,
|
|
104
|
+
MY_LISTINGS_UNTABBED_STATUSES,
|
|
100
105
|
isPubliclyVisible,
|
|
101
106
|
lifecycleCaption,
|
|
102
107
|
listingStatusView,
|
|
@@ -112,9 +117,15 @@ export type {
|
|
|
112
117
|
} from "./model/status.js";
|
|
113
118
|
export { LISTING_TRANSITIONS, canDelete, canTransition } from "./model/transitions.js";
|
|
114
119
|
|
|
115
|
-
// ── model: the owner
|
|
116
|
-
export {
|
|
120
|
+
// ── model: the owner's own rows ──────────────────────────────────────────────
|
|
121
|
+
export { defaultMyListingsSource } from "./model/mineSource.js";
|
|
117
122
|
export type { MyListingsSource } from "./model/mineSource.js";
|
|
123
|
+
export {
|
|
124
|
+
myListingImages,
|
|
125
|
+
myListingPrice,
|
|
126
|
+
myListingTitle,
|
|
127
|
+
showsDraft,
|
|
128
|
+
} from "./model/mine.js";
|
|
118
129
|
|
|
119
130
|
// ── model: the draft twin ────────────────────────────────────────────────────
|
|
120
131
|
export {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an owner's row SAYS, when half its fields may still be a draft.
|
|
3
|
+
*
|
|
4
|
+
* A listing carries every user-editable value twice: the `*_draft` twin the
|
|
5
|
+
* owner is writing, and the published field `publish` promotes it onto. The
|
|
6
|
+
* public card reads the published half — correct for a shop window, and empty
|
|
7
|
+
* for a listing that has never been published. A drafts tab built on it is a
|
|
8
|
+
* column of blank rows.
|
|
9
|
+
*
|
|
10
|
+
* So the rule, in one place rather than at three call sites: **show the
|
|
11
|
+
* published value when there is one, and the draft otherwise.** Not "always
|
|
12
|
+
* the draft" — a live listing being edited must go on reading as what
|
|
13
|
+
* strangers currently see, or the seller cannot tell what is on the shelf
|
|
14
|
+
* from what they are about to put there.
|
|
15
|
+
*
|
|
16
|
+
* Pure, so `test/mine.test.tsx` asserts the table directly.
|
|
17
|
+
*/
|
|
18
|
+
import type { MyListingCard } from "../api/types.js";
|
|
19
|
+
|
|
20
|
+
/** Is this string field's published half absent? `""` counts: the model
|
|
21
|
+
* declares `title = CharField(blank=True, default="")`, so an unpublished
|
|
22
|
+
* listing carries the empty string, not `null`. */
|
|
23
|
+
function empty(value: string | null | undefined): boolean {
|
|
24
|
+
return value === null || value === undefined || value === "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The row's heading: the published title, the draft title, or `undefined`.
|
|
29
|
+
*
|
|
30
|
+
* `undefined` and not the id: a caller that wants "Listing #41" as a last
|
|
31
|
+
* resort can say so, and one that wants to render a placeholder can do that
|
|
32
|
+
* instead. Inventing the fallback here would take the choice away.
|
|
33
|
+
*/
|
|
34
|
+
export function myListingTitle(row: MyListingCard): string | undefined {
|
|
35
|
+
if (!empty(row.title)) return row.title as string;
|
|
36
|
+
if (!empty(row.title_draft)) return row.title_draft as string;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The row's price, published half first. A decimal STRING on the wire
|
|
42
|
+
* (`DecimalField` → `"200.00"`), passed through untouched — formatting it is
|
|
43
|
+
* a skin's job and rounding it here would lose the trailing zero a currency
|
|
44
|
+
* needs.
|
|
45
|
+
*/
|
|
46
|
+
export function myListingPrice(row: MyListingCard): string | undefined {
|
|
47
|
+
if (!empty(row.price)) return row.price as string;
|
|
48
|
+
if (!empty(row.price_draft)) return row.price_draft as string;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The row's images, published half first — `[]` when neither has any. */
|
|
53
|
+
export function myListingImages(row: MyListingCard): readonly string[] {
|
|
54
|
+
const published = row.images;
|
|
55
|
+
if (published !== null && published !== undefined && published.length > 0) {
|
|
56
|
+
return published;
|
|
57
|
+
}
|
|
58
|
+
return row.images_draft ?? [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Is what this row shows the DRAFT rather than what strangers see?
|
|
63
|
+
*
|
|
64
|
+
* The half of the rule a person is owed on screen: a row whose title came off
|
|
65
|
+
* the draft twin is showing something nobody else can read yet, and a skin
|
|
66
|
+
* that did not mark it would be quietly claiming otherwise.
|
|
67
|
+
*/
|
|
68
|
+
export function showsDraft(row: MyListingCard): boolean {
|
|
69
|
+
return empty(row.title) && !empty(row.title_draft);
|
|
70
|
+
}
|
package/src/model/mineSource.ts
CHANGED
|
@@ -1,55 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE GAP
|
|
2
|
+
* THE GAP, AND THE ROUTE THAT CLOSED IT.
|
|
3
3
|
*
|
|
4
|
-
* This
|
|
5
|
-
*
|
|
4
|
+
* This file used to open "stapel-listings cannot list a person their own
|
|
5
|
+
* listings", and that was the only place in the pair where a contract was
|
|
6
|
+
* missing rather than awkward. `GET /listings/` answers `qs.published()` and
|
|
7
|
+
* takes no owner parameter (`views.ListingViewSet.get_queryset`): it returns
|
|
8
|
+
* the whole marketplace's shop window and can be narrowed to nobody. The only
|
|
9
|
+
* owner-scoped reads were `my/counters` (three integers) and `my/favorites`,
|
|
10
|
+
* so a seller's own DRAFTS were unreachable by any call the contract offered
|
|
11
|
+
* — and drafts are not indexed either, so routing around it through
|
|
12
|
+
* `@stapel/search-react`'s `owner=` would have returned the published subset
|
|
13
|
+
* and quietly called it "everything". The dashboard shipped with a
|
|
14
|
+
* host-injected source and NAMED the absence on screen, because "we cannot
|
|
15
|
+
* ask" and "you have no listings" are different sentences.
|
|
6
16
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* quietly call it "everything".
|
|
17
|
+
* **stapel-listings 0.7.0 answers it**: `GET /listings/my/listings/`, the
|
|
18
|
+
* caller's own rows in every status, `?status=` for a tab's set, the same
|
|
19
|
+
* `IDAnchorPagination` envelope the other two owner reads use. So
|
|
20
|
+
* {@link defaultMyListingsSource} is what the dashboard runs on now, and
|
|
21
|
+
* there is no failure state left to name — the missing-source error and its
|
|
22
|
+
* i18n key are gone rather than kept as a comment about a thing that no
|
|
23
|
+
* longer happens.
|
|
15
24
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* can be narrowed without a search module.
|
|
24
|
-
*
|
|
25
|
-
* Until then a host injects a {@link MyListingsSource} and the dashboard
|
|
26
|
-
* works completely; without one it shows the real counters and NAMES the
|
|
27
|
-
* absence, because "we cannot ask" and "you have no listings" are different
|
|
28
|
-
* sentences.
|
|
25
|
+
* {@link MyListingsSource} stays, as a seam and not as a workaround: a
|
|
26
|
+
* deployment that keeps its sellers' rows somewhere else (a read model, a
|
|
27
|
+
* legacy table, a host that has already fetched the page) hands one in and
|
|
28
|
+
* the dashboard renders it unchanged. What it must return is one page of the
|
|
29
|
+
* owner card — including the `moderation_status` and the `*_draft` twins,
|
|
30
|
+
* which is why the type is {@link PaginatedMyListingCards} and not the public
|
|
31
|
+
* card envelope.
|
|
29
32
|
*/
|
|
30
|
-
import {
|
|
31
|
-
import type {
|
|
33
|
+
import type { ListingsApi } from "../api/listingsApi.js";
|
|
34
|
+
import type { MyListingsParams, PaginatedMyListingCards } from "../api/types.js";
|
|
32
35
|
import type { MyListingsTab } from "./status.js";
|
|
33
|
-
import {
|
|
36
|
+
import { MY_LISTINGS_TAB_STATUSES } from "./status.js";
|
|
34
37
|
|
|
35
|
-
/** One page of the caller's own listings, however the host
|
|
38
|
+
/** One page of the caller's own listings, however the host gets them. */
|
|
36
39
|
export type MyListingsSource = (args: {
|
|
37
40
|
readonly tab: MyListingsTab;
|
|
38
|
-
readonly page:
|
|
41
|
+
readonly page: MyListingsParams;
|
|
39
42
|
readonly signal?: AbortSignal;
|
|
40
|
-
}) => Promise<
|
|
43
|
+
}) => Promise<PaginatedMyListingCards>;
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
|
-
* The
|
|
46
|
+
* The source the dashboard uses unless a host replaces it: the contract's own
|
|
47
|
+
* route, narrowed to the tab's statuses.
|
|
44
48
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
49
|
+
* The tab → statuses table is `MY_LISTINGS_TAB_STATUSES`, which is a copy of
|
|
50
|
+
* the SERVER's grouping in `views.my_counters`. That is the whole point: the
|
|
51
|
+
* rows this fetches and the count on the tab beside them are the same set,
|
|
52
|
+
* asserted upstream (`tests/test_my_listings.py`) and here.
|
|
48
53
|
*/
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
54
|
+
export function defaultMyListingsSource(api: ListingsApi): MyListingsSource {
|
|
55
|
+
return ({ tab, page, signal }) =>
|
|
56
|
+
api.myListings(
|
|
57
|
+
{ ...page, status: MY_LISTINGS_TAB_STATUSES[tab] },
|
|
58
|
+
signal !== undefined ? { signal } : {}
|
|
59
|
+
);
|
|
60
|
+
}
|
package/src/model/mutations.ts
CHANGED
|
@@ -27,10 +27,13 @@ import { listingsQueryKeys } from "./queryKeys.js";
|
|
|
27
27
|
* ── The invalidation rule ──────────────────────────────────────────────────
|
|
28
28
|
*
|
|
29
29
|
* A write that can move `status` or `moderation_status` invalidates the
|
|
30
|
-
* DETAIL, the STATUS probe and the
|
|
31
|
-
* three tabs are defined by status sets (`views.my_counters`),
|
|
32
|
-
* silently moves a row from "drafts" to "active" and a dashboard
|
|
33
|
-
* refetch would show the old number beside the new row
|
|
30
|
+
* DETAIL, the STATUS probe, the COUNTERS and the owner's ROWS — the counters
|
|
31
|
+
* because their three tabs are defined by status sets (`views.my_counters`),
|
|
32
|
+
* so a publish silently moves a row from "drafts" to "active" and a dashboard
|
|
33
|
+
* that did not refetch would show the old number beside the new row; the rows
|
|
34
|
+
* for the same reason, since stapel-listings 0.7.0 gave them a route and they
|
|
35
|
+
* are narrowed by exactly those status sets. Invalidating one and not the
|
|
36
|
+
* other is the shape of the bug where the badge says 2 and the tab shows 3.
|
|
34
37
|
*
|
|
35
38
|
* Nothing here is optimistic except the favourite toggle, and that exception
|
|
36
39
|
* is argued at its own hook.
|
|
@@ -46,6 +49,7 @@ function invalidateListing(queryClient: QueryClient, id: number): void {
|
|
|
46
49
|
void queryClient.invalidateQueries({
|
|
47
50
|
queryKey: listingsQueryKeys.validateDraft(id),
|
|
48
51
|
});
|
|
52
|
+
void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.allMine() });
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/** Start a draft. The server forces `owner` and `status=draft`. */
|
|
@@ -63,6 +67,9 @@ export function useCreateDraft(): UseMutationResult<
|
|
|
63
67
|
void queryClient.invalidateQueries({
|
|
64
68
|
queryKey: listingsQueryKeys.myCounters(),
|
|
65
69
|
});
|
|
70
|
+
void queryClient.invalidateQueries({
|
|
71
|
+
queryKey: listingsQueryKeys.allMine(),
|
|
72
|
+
});
|
|
66
73
|
},
|
|
67
74
|
});
|
|
68
75
|
}
|