@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/MODULE.md +26 -10
  3. package/README.md +33 -17
  4. package/dist/api/generated/schema.d.ts +127 -1
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +35 -22
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js +17 -0
  9. package/dist/api/listingsApi.js.map +1 -1
  10. package/dist/api/types.d.ts +43 -2
  11. package/dist/api/types.d.ts.map +1 -1
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  14. package/dist/default/ListingDetailPane.js +1 -1
  15. package/dist/default/ListingDetailPane.js.map +1 -1
  16. package/dist/default/MyListingsPane.d.ts +24 -11
  17. package/dist/default/MyListingsPane.d.ts.map +1 -1
  18. package/dist/default/MyListingsPane.js +26 -10
  19. package/dist/default/MyListingsPane.js.map +1 -1
  20. package/dist/headless/MyListings.d.ts +27 -12
  21. package/dist/headless/MyListings.d.ts.map +1 -1
  22. package/dist/headless/MyListings.js +29 -7
  23. package/dist/headless/MyListings.js.map +1 -1
  24. package/dist/i18n/es.d.ts.map +1 -1
  25. package/dist/i18n/es.js +8 -3
  26. package/dist/i18n/es.js.map +1 -1
  27. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  28. package/dist/i18n/generated/errors.es.gen.js +1 -1
  29. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  30. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  31. package/dist/i18n/generated/errors.gen.js +3 -0
  32. package/dist/i18n/generated/errors.gen.js.map +1 -1
  33. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  34. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  35. package/dist/i18n/keys.d.ts +12 -2
  36. package/dist/i18n/keys.d.ts.map +1 -1
  37. package/dist/i18n/keys.js +18 -4
  38. package/dist/i18n/keys.js.map +1 -1
  39. package/dist/i18n/ru.d.ts.map +1 -1
  40. package/dist/i18n/ru.js +8 -3
  41. package/dist/i18n/ru.js.map +1 -1
  42. package/dist/index.d.ts +8 -6
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +8 -6
  45. package/dist/index.js.map +1 -1
  46. package/dist/model/mine.d.ts +44 -0
  47. package/dist/model/mine.d.ts.map +1 -0
  48. package/dist/model/mine.js +52 -0
  49. package/dist/model/mine.js.map +1 -0
  50. package/dist/model/mineSource.d.ts +39 -34
  51. package/dist/model/mineSource.d.ts.map +1 -1
  52. package/dist/model/mineSource.js +10 -41
  53. package/dist/model/mineSource.js.map +1 -1
  54. package/dist/model/mutations.d.ts.map +1 -1
  55. package/dist/model/mutations.js +11 -4
  56. package/dist/model/mutations.js.map +1 -1
  57. package/dist/model/queryKeys.d.ts +7 -2
  58. package/dist/model/queryKeys.d.ts.map +1 -1
  59. package/dist/model/queryKeys.js +2 -0
  60. package/dist/model/queryKeys.js.map +1 -1
  61. package/dist/model/status.d.ts +12 -31
  62. package/dist/model/status.d.ts.map +1 -1
  63. package/dist/model/status.js +44 -0
  64. package/dist/model/status.js.map +1 -1
  65. package/llms.txt +6 -4
  66. package/manifest.json +38 -6
  67. package/nav-manifest.json +1 -1
  68. package/package.json +4 -4
  69. package/src/analytics/generated/events.json +1 -1
  70. package/src/api/generated/schema.ts +127 -1
  71. package/src/api/listingsApi.ts +60 -21
  72. package/src/api/types.ts +46 -2
  73. package/src/default/ListingDetailPane.tsx +6 -1
  74. package/src/default/MyListingsPane.tsx +98 -34
  75. package/src/headless/MyListings.tsx +62 -22
  76. package/src/i18n/es.ts +11 -4
  77. package/src/i18n/generated/errors.es.gen.ts +1 -1
  78. package/src/i18n/generated/errors.gen.ts +3 -0
  79. package/src/i18n/generated/errors.json +9 -0
  80. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  81. package/src/i18n/keys.ts +20 -5
  82. package/src/i18n/ru.ts +11 -4
  83. package/src/index.ts +16 -5
  84. package/src/model/mine.ts +70 -0
  85. package/src/model/mineSource.ts +46 -41
  86. package/src/model/mutations.ts +11 -4
  87. package/src/model/queryKeys.ts +9 -2
  88. package/src/model/status.ts +15 -0
@@ -42,7 +42,9 @@ export interface paths {
42
42
  */
43
43
  get: operations["listings_api_v1_listings_retrieve"];
44
44
  /**
45
- * @description Listings CRUD plus owner lifecycle actions and favorites.
45
+ * @description Write the draft fields the write ``save-draft`` also performs.
46
+ *
47
+ * Owner only: 404 for an absent listing, 403 for someone else's.
46
48
  *
47
49
  * **Permissions:** `IsAuthenticatedOrReadOnly`
48
50
  */
@@ -274,6 +276,37 @@ export interface paths {
274
276
  patch?: never;
275
277
  trace?: never;
276
278
  };
279
+ "/listings/api/v1/listings/my/listings/": {
280
+ parameters: {
281
+ query?: never;
282
+ header?: never;
283
+ path?: never;
284
+ cookie?: never;
285
+ };
286
+ /**
287
+ * @description The caller's OWN listings, in every status.
288
+ *
289
+ * The counterpart of ``my/counters``: the same owner scope and the same
290
+ * status grouping, but the rows behind the three numbers. ``list`` is
291
+ * the shop window (``published()``, narrowable to nobody), so this is
292
+ * the only route by which a person can be shown their own drafts.
293
+ *
294
+ * Owner-scoped at the queryset via ``owned_by`` — a stranger's listing
295
+ * cannot be reached from here at any status, and soft-deleted rows are
296
+ * excluded by the default manager (a deleted listing is gone from the
297
+ * owner's dashboard exactly as it is gone from everywhere else).
298
+ *
299
+ * **Permissions:** `IsAuthenticated`
300
+ */
301
+ get: operations["listings_api_v1_listings_my_listings_list"];
302
+ put?: never;
303
+ post?: never;
304
+ delete?: never;
305
+ options?: never;
306
+ head?: never;
307
+ patch?: never;
308
+ trace?: never;
309
+ };
277
310
  }
278
311
  export type webhooks = Record<string, never>;
279
312
  export interface components {
@@ -673,6 +706,58 @@ export interface components {
673
706
  archived: number;
674
707
  drafts: number;
675
708
  };
709
+ /**
710
+ * @description The owner's own card — the public card plus what only an owner sees.
711
+ *
712
+ * Same family as :class:`ListingCardSerializer` (one shape for every grid a
713
+ * product renders), extended along two axes and no further:
714
+ *
715
+ * - **the moderation axis** (``moderation_status``): visibility is decided
716
+ * by ``status`` alone, but since 0.5.0 a *published* listing can be under
717
+ * re-review, and its owner is the one person who has to be told. A
718
+ * dashboard cannot derive that sentence from ``status``.
719
+ * - **the draft twins** (``title_draft`` / ``price_draft`` /
720
+ * ``images_draft``): the published fields are empty on a listing that has
721
+ * never been published, so a drafts tab built on the public card would
722
+ * render a column of blank rows. This is the list half of the pair's
723
+ * upstream ask #2 — the detail read is unchanged and still serializes the
724
+ * published fields only.
725
+ *
726
+ * Owner-scoped by construction: this serializer is used by exactly one
727
+ * route, ``my/listings``, whose queryset is ``owned_by(request.user)``.
728
+ */
729
+ MyListingCard: {
730
+ readonly id: number;
731
+ title?: string;
732
+ /** Format: decimal */
733
+ price?: string;
734
+ /** Format: decimal */
735
+ price_base?: string | null;
736
+ currency?: string;
737
+ readonly images: string[] | null;
738
+ readonly features_title: components["schemas"]["FeatureDao"][];
739
+ readonly features_badges: components["schemas"]["FeatureDao"][];
740
+ location_label?: string;
741
+ geohash?: string;
742
+ /** Format: decimal */
743
+ lat?: string | null;
744
+ /** Format: decimal */
745
+ lon?: string | null;
746
+ countable?: boolean;
747
+ /** Format: int64 */
748
+ stock_quantity?: number | null;
749
+ status?: components["schemas"]["StatusD41Enum"];
750
+ readonly is_favorited: boolean | null;
751
+ moderation_status?: components["schemas"]["ModerationStatusEnum"];
752
+ title_draft?: string;
753
+ /** Format: decimal */
754
+ price_draft?: string | null;
755
+ readonly images_draft: string[] | null;
756
+ /** Format: date-time */
757
+ readonly created_at: string;
758
+ /** Format: date-time */
759
+ readonly updated_at: string;
760
+ };
676
761
  /** @enum {unknown} */
677
762
  NullEnum: null;
678
763
  PaginatedListingCardList: {
@@ -688,6 +773,19 @@ export interface components {
688
773
  /** @description Number of items in current page */
689
774
  count: number;
690
775
  };
776
+ PaginatedMyListingCardList: {
777
+ items: components["schemas"]["MyListingCard"][];
778
+ /** @description Anchor value for next page */
779
+ next_anchor?: string | null;
780
+ /** @description Anchor value for previous page */
781
+ prev_anchor?: string | null;
782
+ /** @description Whether there are more items after this page */
783
+ has_next: boolean;
784
+ /** @description Whether there are items before this page */
785
+ has_prev: boolean;
786
+ /** @description Number of items in current page */
787
+ count: number;
788
+ };
691
789
  /**
692
790
  * @description Create/update the draft twin fields.
693
791
  *
@@ -1252,4 +1350,32 @@ export interface operations {
1252
1350
  };
1253
1351
  };
1254
1352
  };
1353
+ listings_api_v1_listings_my_listings_list: {
1354
+ parameters: {
1355
+ query?: {
1356
+ /** @description Anchor value to paginate from (exclusive) */
1357
+ anchor?: string;
1358
+ /** @description Pagination direction */
1359
+ direction?: "next" | "prev" | "center";
1360
+ /** @description Number of items (default 100, max 1000) */
1361
+ limit?: number;
1362
+ /** @description Lifecycle status to narrow to. Repeat the parameter or pass one comma-separated value for a set (`?status=draft,rejected`); omit it for every status. An unknown value is a 400, not an empty page. */
1363
+ status?: ("archived" | "blocked" | "draft" | "expired" | "paused" | "pending" | "published" | "rejected" | "sold")[];
1364
+ };
1365
+ header?: never;
1366
+ path?: never;
1367
+ cookie?: never;
1368
+ };
1369
+ requestBody?: never;
1370
+ responses: {
1371
+ 200: {
1372
+ headers: {
1373
+ [name: string]: unknown;
1374
+ };
1375
+ content: {
1376
+ "application/json": components["schemas"]["PaginatedMyListingCardList"];
1377
+ };
1378
+ };
1379
+ };
1380
+ };
1255
1381
  }
@@ -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, and this is the one place in
26
- * the pair where an absence is a safety decision rather than a scope one.
27
- * Every OWNER operation in this module routes through `views._get_own`, which
28
- * answers `error.403.listing_not_owner` when the caller is not the owner —
29
- * every one except these two. `update`/`partial_update` are the plain
30
- * `ModelViewSet` implementations under the viewset's default
31
- * `IsAuthenticatedOrReadOnly`, and `get_queryset` hands them
32
- * `Listing.objects.all()`: any authenticated caller can write any listing's
33
- * draft fields through them. `POST /{pk}/save-draft/` performs the SAME write
34
- * (the same `ListingDraftSerializer`, `partial=True`) with the ownership
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
- * `IsAuthenticatedOrReadOnly` + a queryset with no `published()` filter:
74
- * this answers for a DRAFT and for a BLOCKED listing too, to anyone who
75
- * knows the id. The pair does not pretend otherwise `useListing` reports
76
- * `publiclyVisible` off `status` so a skin can say "this listing is not
77
- * published" instead of drawing a live-looking page (see `model/status.ts`,
78
- * and MODULE.md's upstream note).
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 (`IDAnchorPagination`). */
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 both keyset-paginated card lists. */
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
  *
@@ -246,9 +246,14 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
246
246
  ) : null}
247
247
 
248
248
  <Descriptions size="small" column={1}>
249
+ {/* Label cell and value cell, which is what a `<Descriptions>`
250
+ row IS: the label key carries no `{count}` (it did, and the
251
+ page printed the placeholder), the quantity is the value. */}
249
252
  {listing.stock_quantity != null ? (
250
253
  <Descriptions.Item label={t(LISTINGS_I18N_KEYS.detailStock)}>
251
- {listing.stock_quantity}
254
+ <span data-testid="listings-detail-stock">
255
+ {listing.stock_quantity}
256
+ </span>
252
257
  </Descriptions.Item>
253
258
  ) : null}
254
259
  {listing.location_label !== undefined &&
@@ -1,16 +1,29 @@
1
1
  /**
2
2
  * `<MyListingsPane>` — the seller's dashboard.
3
3
  *
4
- * Three counts that are real, and rows that may not be: stapel-listings 0.6.1
5
- * has no owner-scoped list endpoint (`headless/MyListings.tsx` argues it in
6
- * full), so the rows come from an injected source and the pane NAMES the gap
7
- * when there is none. The counters are shown either way, because they are the
8
- * one thing the contract can actually answer.
4
+ * Three counts and the rows behind them, both real since stapel-listings
5
+ * 0.7.0 gave the owner's own listings a route (`GET my/listings/`). Until
6
+ * then this pane showed the counts and NAMED the missing endpoint where the
7
+ * rows should be; `model/mineSource.ts` keeps that history and the seam it
8
+ * left behind.
9
9
  *
10
- * Each row carries both axes. "Published" and "changes under review" appear
11
- * beside each other, which is the entire reason `model/status.ts` exists: a
12
- * dashboard that derived one from the other would tell a seller their live
13
- * listing is offline, or never tell them their edit is being screened.
10
+ * Three things this pane refuses to smooth over:
11
+ *
12
+ * 1. **Both axes on every row.** "Published" and "changes under review" appear
13
+ * beside each other, which is the entire reason `model/status.ts` exists: a
14
+ * dashboard that derived one from the other would tell a seller their live
15
+ * listing is offline, or never tell them their edit is being screened.
16
+ * `moderation_status` is on the owner card, so the row reads the real
17
+ * value rather than the `"approved"` stand-in it used before 0.7.0.
18
+ * 2. **Takedowns are not in a tab.** The three tabs are the SERVER's status
19
+ * groupings and `blocked` is in none of them, because `my/counters` counts
20
+ * it in none of them. Folding it into one would make a tab's rows and its
21
+ * badge describe different sets; leaving it out entirely would hide the
22
+ * one listing whose owner most needs to know. So it sits above the tabs,
23
+ * where it cannot be missed.
24
+ * 3. **An empty tab says which emptiness it is.** "No drafts" and "nothing
25
+ * sold yet" are different sentences and one generic "nothing here" is
26
+ * neither.
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 { ListingCard as ListingCardData } from "../api/types.js";
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: ListingCardData;
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
- // The card list carries `status` but NOT `moderation_status`
99
- // (`ListingCardSerializer` omits it), so a row shows the moderation axis
100
- // only through the one thing the projection does say. Pending is the
101
- // honest default for the notice table's second argument here: it is the
102
- // value that produces a note only when the lifecycle also implies one.
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(props.listing.status, "approved");
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={props.listing.title ?? String(props.listing.id)}
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 pane says the
180
- * contract cannot answer, and still shows the counts. */
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
- description={t(LISTINGS_I18N_KEYS.mineEmpty)}
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"