@stapel/listings-react 0.3.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/MODULE.md +12 -6
- package/README.md +33 -17
- package/dist/api/generated/schema.d.ts +129 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +35 -22
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +17 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +43 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FavoritesPane.d.ts +40 -3
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +30 -18
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +25 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +19 -12
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +163 -23
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +85 -43
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +41 -4
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +38 -25
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +13 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +47 -9
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -0
- package/dist/default/ListingPrice.d.ts.map +1 -0
- package/dist/default/ListingPrice.js +60 -0
- package/dist/default/ListingPrice.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +9 -17
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +148 -63
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/SignInLink.js +8 -7
- package/dist/default/SignInLink.js.map +1 -1
- package/dist/default/StatusTags.d.ts +16 -5
- package/dist/default/StatusTags.d.ts.map +1 -1
- package/dist/default/StatusTags.js +19 -13
- package/dist/default/StatusTags.js.map +1 -1
- package/dist/default/index.d.ts +15 -9
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -6
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Favorites.d.ts +6 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +4 -0
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +12 -0
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +3 -0
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +23 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +33 -7
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/MyListings.d.ts +27 -12
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +29 -7
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +28 -6
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +28 -4
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +49 -8
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +16 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +40 -6
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/model/draft.d.ts +9 -6
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +3 -1
- package/dist/model/draft.js.map +1 -1
- package/dist/model/features.d.ts +21 -7
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +26 -0
- package/dist/model/features.js.map +1 -1
- package/dist/model/mine.d.ts +44 -0
- package/dist/model/mine.d.ts.map +1 -0
- package/dist/model/mine.js +52 -0
- package/dist/model/mine.js.map +1 -0
- package/dist/model/mineSource.d.ts +39 -34
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js +10 -41
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +11 -4
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queryKeys.d.ts +7 -2
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +2 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/status.d.ts +12 -31
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +44 -0
- package/dist/model/status.js.map +1 -1
- package/llms.txt +11 -8
- package/manifest.json +88 -14
- package/nav-manifest.json +1 -1
- package/package.json +17 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +129 -3
- package/src/api/listingsApi.ts +60 -21
- package/src/api/types.ts +46 -2
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +88 -46
- package/src/default/ListingComposerPage.tsx +403 -187
- package/src/default/ListingDetailPane.tsx +200 -98
- package/src/default/ListingPhoto.tsx +89 -16
- package/src/default/ListingPrice.tsx +77 -0
- package/src/default/MyListingsPane.tsx +344 -222
- package/src/default/SignInLink.tsx +4 -4
- package/src/default/StatusTags.tsx +36 -18
- package/src/default/index.ts +18 -7
- package/src/headless/Favorites.tsx +10 -0
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/MyListings.tsx +62 -22
- package/src/i18n/es.ts +36 -7
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +58 -9
- package/src/i18n/ru.ts +48 -7
- package/src/index.ts +16 -5
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/src/model/mine.ts +70 -0
- package/src/model/mineSource.ts +46 -41
- package/src/model/mutations.ts +11 -4
- package/src/model/queryKeys.ts +9 -2
- package/src/model/status.ts +15 -0
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -25
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -40
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -67
|
@@ -42,7 +42,9 @@ export interface paths {
|
|
|
42
42
|
*/
|
|
43
43
|
get: operations["listings_api_v1_listings_retrieve"];
|
|
44
44
|
/**
|
|
45
|
-
* @description
|
|
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 {
|
|
@@ -631,7 +664,7 @@ export interface components {
|
|
|
631
664
|
images_draft?: string[];
|
|
632
665
|
location_id_draft?: string;
|
|
633
666
|
location_label_draft?: string;
|
|
634
|
-
geohash_draft
|
|
667
|
+
readonly geohash_draft: string;
|
|
635
668
|
/** Format: decimal */
|
|
636
669
|
lat_draft?: string | null;
|
|
637
670
|
/** Format: decimal */
|
|
@@ -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
|
*
|
|
@@ -707,7 +805,7 @@ export interface components {
|
|
|
707
805
|
images_draft?: string[];
|
|
708
806
|
location_id_draft?: string;
|
|
709
807
|
location_label_draft?: string;
|
|
710
|
-
geohash_draft?: string;
|
|
808
|
+
readonly geohash_draft?: string;
|
|
711
809
|
/** Format: decimal */
|
|
712
810
|
lat_draft?: string | null;
|
|
713
811
|
/** Format: decimal */
|
|
@@ -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
|
}
|
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
|
*
|
|
@@ -6,19 +6,53 @@
|
|
|
6
6
|
* "You have not saved anything yet" and "we could not load your favourites"
|
|
7
7
|
* are the pair a `data ?? []` would have merged, and merging them is exactly
|
|
8
8
|
* the substitution that cost the fleet an incident (spec §7.4).
|
|
9
|
+
*
|
|
10
|
+
* ── A visitor is told ONE thing ────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* `useFavorites` disables the query without a mandate, so a visitor's `rows`
|
|
13
|
+
* sit in `loading` forever — and the pane rendered the blocked notice AND the
|
|
14
|
+
* spinner underneath it, saying "you cannot see this" and "we are fetching it"
|
|
15
|
+
* at the same time, with no sign-in link anywhere on the screen. The gate is
|
|
16
|
+
* now the WHOLE body: one designed state, with the door the container supplies
|
|
17
|
+
* (`signIn`) inside it, and nothing else drawn.
|
|
18
|
+
*
|
|
19
|
+
* The pager obeys the same rule. `Previous`/`Next` were rendered disabled in
|
|
20
|
+
* every state including empty, failed and blocked — two controls that meant
|
|
21
|
+
* nothing, three times over. They render when there is a page to go to.
|
|
22
|
+
*
|
|
23
|
+
* And each one renders only when ITS page exists. The keyset gates name the
|
|
24
|
+
* missing page with the button's own label key, so a blocked `Previous` drew
|
|
25
|
+
* the word twice — once as a dead button, once as its own "reason". A pager
|
|
26
|
+
* does not explain itself: the page indicator says where you are, and a
|
|
27
|
+
* direction with nowhere to go is simply not drawn.
|
|
9
28
|
*/
|
|
10
|
-
import type { ReactElement } from "react";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
29
|
+
import type { ReactElement, ReactNode } from "react";
|
|
30
|
+
import { Button, Flex, Typography } from "antd";
|
|
31
|
+
import {
|
|
32
|
+
EmptyState,
|
|
33
|
+
ErrorAlert,
|
|
34
|
+
SkinTheme,
|
|
35
|
+
} from "@stapel/tokens-antd/skin";
|
|
36
|
+
import { matchList, useT } from "@stapel/core";
|
|
37
|
+
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
38
|
+
import { spacing } from "@stapel/tokens";
|
|
14
39
|
import { useFavorites } from "../headless/Favorites.js";
|
|
15
40
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
16
|
-
import { ErrorAlert } from "./ErrorAlert.js";
|
|
17
41
|
import { ListingCard } from "./ListingCard.js";
|
|
18
42
|
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
19
|
-
import {
|
|
43
|
+
import { SignInLink } from "./SignInLink.js";
|
|
20
44
|
import type { ThemeModeProp } from "./types.js";
|
|
21
45
|
|
|
46
|
+
/** The narrowest a card may get before the grid drops a column — the same
|
|
47
|
+
* element-relative rule the detail gallery uses. It was `width: 240`, which
|
|
48
|
+
* left two cards floating in a 2560px pane and overflowed a 390px one. */
|
|
49
|
+
export const FAVORITES_CARD_MIN = "15rem";
|
|
50
|
+
|
|
51
|
+
/** How wide the saved grid may get before it stops being a grid and becomes
|
|
52
|
+
* three cards adrift in a 1280px window. A measure, not a breakpoint: the
|
|
53
|
+
* cards keep filling it, and past it the page centres. */
|
|
54
|
+
export const FAVORITES_MEASURE = "72rem";
|
|
55
|
+
|
|
22
56
|
/** How a card in this grid opens — the same one-contract union `<ListingCard>`
|
|
23
57
|
* takes, one level up, so a pane cannot re-introduce the double navigation the
|
|
24
58
|
* card no longer allows. */
|
|
@@ -41,7 +75,21 @@ export type FavoritesPaneOpenProps =
|
|
|
41
75
|
readonly linkComponent?: undefined;
|
|
42
76
|
};
|
|
43
77
|
|
|
44
|
-
|
|
78
|
+
/** The way OUT of an empty favourites list. */
|
|
79
|
+
export interface FavoritesBrowseCtaProp {
|
|
80
|
+
/**
|
|
81
|
+
* What a person with nothing saved can do next — a "Browse listings"
|
|
82
|
+
* button, supplied by the container because only it knows where the
|
|
83
|
+
* catalogue lives. An empty state without one is a dead end, and this
|
|
84
|
+
* screen was one of four sharing a single stock illustration and no exit.
|
|
85
|
+
*/
|
|
86
|
+
readonly browseCta?: ReactNode;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type FavoritesPaneProps = ThemeModeProp &
|
|
90
|
+
SignInCtaProp &
|
|
91
|
+
FavoritesBrowseCtaProp &
|
|
92
|
+
FavoritesPaneOpenProps;
|
|
45
93
|
|
|
46
94
|
/** The card's own open props for one row. One arm, never two. */
|
|
47
95
|
function cardOpenProps(
|
|
@@ -60,93 +108,119 @@ function cardOpenProps(
|
|
|
60
108
|
|
|
61
109
|
export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
|
|
62
110
|
const t = useT();
|
|
63
|
-
const describe = useDescribeFlowError();
|
|
64
111
|
const bag = useFavorites();
|
|
112
|
+
const paged = bag.prevPage.available || bag.nextPage.available;
|
|
65
113
|
|
|
66
114
|
return (
|
|
67
|
-
<
|
|
68
|
-
|
|
115
|
+
<SkinTheme
|
|
116
|
+
surface="base"
|
|
117
|
+
style={{ padding: spacing[4], maxWidth: FAVORITES_MEASURE }}
|
|
118
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
119
|
+
>
|
|
120
|
+
<Flex vertical gap={spacing[4]} data-testid="listings-favorites">
|
|
69
121
|
<Typography.Title level={3}>
|
|
70
122
|
{t(LISTINGS_I18N_KEYS.favoritesTitle)}
|
|
71
123
|
</Typography.Title>
|
|
72
124
|
|
|
73
125
|
{!bag.gate.available ? (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
126
|
+
// The whole body, not a banner over a spinner. A blocked screen has
|
|
127
|
+
// one state and it carries its own way out.
|
|
128
|
+
<EmptyState
|
|
129
|
+
testId="listings-favorites-blocked"
|
|
130
|
+
title={t(bag.gate.block.code, bag.gate.block.params)}
|
|
131
|
+
hint={t(LISTINGS_I18N_KEYS.favoritesSignInHint)}
|
|
132
|
+
action={
|
|
133
|
+
<SignInLink cta={props.signIn} testId="listings-favorites-sign-in" />
|
|
134
|
+
}
|
|
79
135
|
/>
|
|
80
|
-
) :
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
) : (
|
|
137
|
+
<>
|
|
138
|
+
{matchList(bag.rows, {
|
|
139
|
+
loading: () => (
|
|
140
|
+
<div
|
|
141
|
+
role="status"
|
|
142
|
+
aria-busy="true"
|
|
143
|
+
aria-label={t(LISTINGS_I18N_KEYS.favoritesLoading)}
|
|
144
|
+
data-testid="listings-favorites-loading"
|
|
145
|
+
data-stapel-load-state="loading"
|
|
146
|
+
/>
|
|
147
|
+
),
|
|
148
|
+
failed: (error) => (
|
|
149
|
+
<ErrorAlert
|
|
150
|
+
testId="listings-favorites-error"
|
|
151
|
+
thrown={error}
|
|
152
|
+
message={t(LISTINGS_I18N_KEYS.favoritesLoadFailed)}
|
|
153
|
+
onRetry={bag.refetch}
|
|
154
|
+
retryLabel={t(LISTINGS_I18N_KEYS.mineRetry)}
|
|
155
|
+
/>
|
|
156
|
+
),
|
|
157
|
+
empty: () => (
|
|
158
|
+
<EmptyState
|
|
159
|
+
testId="listings-favorites-empty"
|
|
160
|
+
title={t(LISTINGS_I18N_KEYS.favoritesEmpty)}
|
|
161
|
+
hint={t(LISTINGS_I18N_KEYS.favoritesEmptyHint)}
|
|
162
|
+
{...(props.browseCta !== undefined
|
|
163
|
+
? { action: props.browseCta }
|
|
164
|
+
: {})}
|
|
165
|
+
/>
|
|
166
|
+
),
|
|
167
|
+
ready: (rows) => (
|
|
168
|
+
<div
|
|
169
|
+
data-testid="listings-favorites-grid"
|
|
170
|
+
style={{
|
|
171
|
+
display: "grid",
|
|
172
|
+
gridTemplateColumns: `repeat(auto-fill, minmax(${FAVORITES_CARD_MIN}, 1fr))`,
|
|
173
|
+
gap: spacing[4],
|
|
174
|
+
}}
|
|
104
175
|
>
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
/>
|
|
115
|
-
),
|
|
116
|
-
ready: (rows) => (
|
|
117
|
-
<Flex wrap gap={16} data-testid="listings-favorites-grid">
|
|
118
|
-
{rows.map((row) => (
|
|
119
|
-
<div key={row.id} style={{ width: 240 }}>
|
|
120
|
-
<ListingCard listing={row} {...cardOpenProps(props, row.id)} />
|
|
176
|
+
{rows.map((row) => (
|
|
177
|
+
<ListingCard
|
|
178
|
+
key={row.id}
|
|
179
|
+
listing={row}
|
|
180
|
+
blockedReason="line"
|
|
181
|
+
{...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
|
|
182
|
+
{...cardOpenProps(props, row.id)}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
121
185
|
</div>
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
),
|
|
125
|
-
})}
|
|
186
|
+
),
|
|
187
|
+
})}
|
|
126
188
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
189
|
+
{paged ? (
|
|
190
|
+
<Flex gap={spacing[3]} wrap align="center">
|
|
191
|
+
{bag.prevPage.available ? (
|
|
192
|
+
<Button
|
|
193
|
+
size="small"
|
|
194
|
+
data-testid="listings-favorites-prev"
|
|
195
|
+
data-analytics="none"
|
|
196
|
+
data-analytics-reason="paging a list the person is already reading; not a business action"
|
|
197
|
+
onClick={bag.goPrev}
|
|
198
|
+
>
|
|
199
|
+
{t(LISTINGS_I18N_KEYS.pagePrev)}
|
|
200
|
+
</Button>
|
|
201
|
+
) : null}
|
|
202
|
+
<Typography.Text
|
|
203
|
+
type="secondary"
|
|
204
|
+
data-testid="listings-favorites-page"
|
|
205
|
+
>
|
|
206
|
+
{t(LISTINGS_I18N_KEYS.pageIndicator, { page: bag.pageNumber })}
|
|
207
|
+
</Typography.Text>
|
|
208
|
+
{bag.nextPage.available ? (
|
|
209
|
+
<Button
|
|
210
|
+
size="small"
|
|
211
|
+
data-testid="listings-favorites-next"
|
|
212
|
+
data-analytics="none"
|
|
213
|
+
data-analytics-reason="paging a list the person is already reading; not a business action"
|
|
214
|
+
onClick={bag.goNext}
|
|
215
|
+
>
|
|
216
|
+
{t(LISTINGS_I18N_KEYS.pageNext)}
|
|
217
|
+
</Button>
|
|
218
|
+
) : null}
|
|
219
|
+
</Flex>
|
|
220
|
+
) : null}
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
149
223
|
</Flex>
|
|
150
|
-
</
|
|
224
|
+
</SkinTheme>
|
|
151
225
|
);
|
|
152
226
|
}
|