@stapel/listings-react 0.1.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 +1 -0
- package/MODULE.md +191 -0
- package/README.md +169 -0
- package/dist/api/generated/schema.d.ts +1253 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/listingsApi.d.ts +145 -0
- package/dist/api/listingsApi.d.ts.map +1 -0
- package/dist/api/listingsApi.js +38 -0
- package/dist/api/listingsApi.js.map +1 -0
- package/dist/api/types.d.ts +156 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +37 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FavoritesPane.d.ts +18 -0
- package/dist/default/FavoritesPane.d.ts.map +1 -0
- package/dist/default/FavoritesPane.js +28 -0
- package/dist/default/FavoritesPane.js.map +1 -0
- package/dist/default/ListingCard.d.ts +42 -0
- package/dist/default/ListingCard.d.ts.map +1 -0
- package/dist/default/ListingCard.js +37 -0
- package/dist/default/ListingCard.js.map +1 -0
- package/dist/default/ListingComposerPage.d.ts +46 -0
- package/dist/default/ListingComposerPage.d.ts.map +1 -0
- package/dist/default/ListingComposerPage.js +99 -0
- package/dist/default/ListingComposerPage.js.map +1 -0
- package/dist/default/ListingDetailPane.d.ts +33 -0
- package/dist/default/ListingDetailPane.d.ts.map +1 -0
- package/dist/default/ListingDetailPane.js +48 -0
- package/dist/default/ListingDetailPane.js.map +1 -0
- package/dist/default/ListingPhoto.d.ts +25 -0
- package/dist/default/ListingPhoto.d.ts.map +1 -0
- package/dist/default/ListingPhoto.js +20 -0
- package/dist/default/ListingPhoto.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +25 -0
- package/dist/default/MyListingsPane.d.ts.map +1 -0
- package/dist/default/MyListingsPane.js +84 -0
- package/dist/default/MyListingsPane.js.map +1 -0
- package/dist/default/StatusTags.d.ts +31 -0
- package/dist/default/StatusTags.d.ts.map +1 -0
- package/dist/default/StatusTags.js +40 -0
- package/dist/default/StatusTags.js.map +1 -0
- package/dist/default/icons.d.ts +16 -0
- package/dist/default/icons.d.ts.map +1 -0
- package/dist/default/icons.js +5 -0
- package/dist/default/icons.js.map +1 -0
- package/dist/default/index.d.ts +35 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +26 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +25 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +40 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/flows/registry.d.ts +35 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +33 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/Favorites.d.ts +52 -0
- package/dist/headless/Favorites.d.ts.map +1 -0
- package/dist/headless/Favorites.js +78 -0
- package/dist/headless/Favorites.js.map +1 -0
- package/dist/headless/ListingActions.d.ts +30 -0
- package/dist/headless/ListingActions.d.ts.map +1 -0
- package/dist/headless/ListingActions.js +54 -0
- package/dist/headless/ListingActions.js.map +1 -0
- package/dist/headless/ListingComposer.d.ts +133 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -0
- package/dist/headless/ListingComposer.js +246 -0
- package/dist/headless/ListingComposer.js.map +1 -0
- package/dist/headless/ListingDetail.d.ts +81 -0
- package/dist/headless/ListingDetail.d.ts.map +1 -0
- package/dist/headless/ListingDetail.js +101 -0
- package/dist/headless/ListingDetail.js.map +1 -0
- package/dist/headless/ListingsProvider.d.ts +17 -0
- package/dist/headless/ListingsProvider.d.ts.map +1 -0
- package/dist/headless/ListingsProvider.js +16 -0
- package/dist/headless/ListingsProvider.js.map +1 -0
- package/dist/headless/MyListings.d.ts +44 -0
- package/dist/headless/MyListings.d.ts.map +1 -0
- package/dist/headless/MyListings.js +82 -0
- package/dist/headless/MyListings.js.map +1 -0
- package/dist/headless/useMandateGate.d.ts +28 -0
- package/dist/headless/useMandateGate.d.ts.map +1 -0
- package/dist/headless/useMandateGate.js +39 -0
- package/dist/headless/useMandateGate.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +34 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +45 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +21 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +146 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +413 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +210 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +170 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +295 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +37 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +162 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +98 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/draft.d.ts +138 -0
- package/dist/model/draft.d.ts.map +1 -0
- package/dist/model/draft.js +192 -0
- package/dist/model/draft.js.map +1 -0
- package/dist/model/features.d.ts +69 -0
- package/dist/model/features.d.ts.map +1 -0
- package/dist/model/features.js +73 -0
- package/dist/model/features.js.map +1 -0
- package/dist/model/mineSource.d.ts +47 -0
- package/dist/model/mineSource.d.ts.map +1 -0
- package/dist/model/mineSource.js +45 -0
- package/dist/model/mineSource.js.map +1 -0
- package/dist/model/mutations.d.ts +60 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +165 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +75 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +117 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +50 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +24 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +69 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +14 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/model/status.d.ts +117 -0
- package/dist/model/status.d.ts.map +1 -0
- package/dist/model/status.js +164 -0
- package/dist/model/status.js.map +1 -0
- package/dist/model/transitions.d.ts +41 -0
- package/dist/model/transitions.d.ts.map +1 -0
- package/dist/model/transitions.js +25 -0
- package/dist/model/transitions.js.map +1 -0
- package/dist/model/validation.d.ts +135 -0
- package/dist/model/validation.d.ts.map +1 -0
- package/dist/model/validation.js +191 -0
- package/dist/model/validation.js.map +1 -0
- package/dist/nav/manifest.d.ts +46 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +56 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +118 -0
- package/manifest.json +1044 -0
- package/nav-manifest.json +84 -0
- package/package.json +121 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/generated/schema.ts +1255 -0
- package/src/api/listingsApi.ts +237 -0
- package/src/api/types.ts +193 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FavoritesPane.tsx +123 -0
- package/src/default/ListingCard.tsx +178 -0
- package/src/default/ListingComposerPage.tsx +466 -0
- package/src/default/ListingDetailPane.tsx +271 -0
- package/src/default/ListingPhoto.tsx +62 -0
- package/src/default/MyListingsPane.tsx +333 -0
- package/src/default/StatusTags.tsx +86 -0
- package/src/default/icons.tsx +31 -0
- package/src/default/index.ts +35 -0
- package/src/default/theme.tsx +67 -0
- package/src/default/types.ts +12 -0
- package/src/flows/registry.ts +45 -0
- package/src/headless/Favorites.tsx +137 -0
- package/src/headless/ListingActions.tsx +101 -0
- package/src/headless/ListingComposer.tsx +476 -0
- package/src/headless/ListingDetail.tsx +231 -0
- package/src/headless/ListingsProvider.tsx +20 -0
- package/src/headless/MyListings.tsx +148 -0
- package/src/headless/useMandateGate.ts +40 -0
- package/src/i18n/errorsMap.ts +62 -0
- package/src/i18n/es.ts +198 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +241 -0
- package/src/i18n/generated/errors.json +499 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +356 -0
- package/src/i18n/ru.ts +215 -0
- package/src/index.ts +260 -0
- package/src/model/context.tsx +28 -0
- package/src/model/draft.ts +307 -0
- package/src/model/features.ts +122 -0
- package/src/model/mineSource.ts +55 -0
- package/src/model/mutations.ts +224 -0
- package/src/model/queries.ts +150 -0
- package/src/model/queryKeys.ts +76 -0
- package/src/model/runtime.ts +87 -0
- package/src/model/status.ts +279 -0
- package/src/model/transitions.ts +62 -0
- package/src/model/validation.ts +271 -0
- package/src/nav/manifest.ts +97 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stored DAO projection → something `@stapel/attributes-react` can draw.
|
|
3
|
+
*
|
|
4
|
+
* A listing carries its attribute values THREE times, all of them DAO lists:
|
|
5
|
+
* `features` (everything, ordered), `features_title` (the ones flagged for
|
|
6
|
+
* the title line) and `features_badges` (the ones flagged for the card). A
|
|
7
|
+
* DAO is the value together with the display configuration its type needs —
|
|
8
|
+
* `prefix`, `precision`, `trueLabel`, `unitType`, … all inline beside
|
|
9
|
+
* `value` — which is the single most useful property of this contract:
|
|
10
|
+
*
|
|
11
|
+
* **a card can render its badges without ever fetching the category.**
|
|
12
|
+
*
|
|
13
|
+
* `formatFeatureValue` wants a `(FeatureDef, FeatureValueDto)` pair, so this
|
|
14
|
+
* module splits a DAO back into one. The split is mechanical, and it is
|
|
15
|
+
* written down once here rather than at each of the three call sites.
|
|
16
|
+
*
|
|
17
|
+
* ── Where the config comes from, and why the whole row is handed over ──────
|
|
18
|
+
*
|
|
19
|
+
* `FeatureConfig` has an index signature, so passing the DAO itself as the
|
|
20
|
+
* config costs nothing and loses nothing: the type-specific keys are already
|
|
21
|
+
* at the top level of the row, and the four presentation flags (`name`,
|
|
22
|
+
* `order`, `title`, `badge`) are simply ignored by every formatter. Copying a
|
|
23
|
+
* hand-picked subset instead would be a list to keep in step with ten types.
|
|
24
|
+
*
|
|
25
|
+
* ── What a DAO does NOT carry ──────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* `select`'s `options`. `normalize_to_dao` stores the chosen VALUES, not the
|
|
28
|
+
* option table, so `formatFeatureValue` falls back to the raw option value —
|
|
29
|
+
* which is a translation KEY when the config is translatable (the default),
|
|
30
|
+
* so a host whose bundle carries the catalogue's option copy still reads a
|
|
31
|
+
* word. A host whose bundle does not sees the key, on purpose: a visible
|
|
32
|
+
* `option.condition.used` gets fixed, an invented "Used" ships wrong.
|
|
33
|
+
*/
|
|
34
|
+
import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
|
|
35
|
+
import type { ListingFeatureDao, ListingFeatureView } from "../api/types.js";
|
|
36
|
+
|
|
37
|
+
/** Keys that belong to the DAO envelope rather than to the type's config. */
|
|
38
|
+
const ENVELOPE = new Set(["slug", "value", "name", "order", "title", "badge"]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One DAO row → the definition/value pair the display half consumes.
|
|
42
|
+
*
|
|
43
|
+
* `undefined` for a row with no `slug`: the stored projection always injects
|
|
44
|
+
* one (`build_features_list`: `{**dao, "slug": slug}`), so a row without it
|
|
45
|
+
* is a malformed record, and a synthesized index would key a badge to a
|
|
46
|
+
* position that changes whenever the category does.
|
|
47
|
+
*/
|
|
48
|
+
export function featureFromDao(
|
|
49
|
+
dao: ListingFeatureDao
|
|
50
|
+
): ListingFeatureView | undefined {
|
|
51
|
+
if (typeof dao.slug !== "string" || dao.slug.length === 0) return undefined;
|
|
52
|
+
|
|
53
|
+
const config: Record<string, unknown> = {};
|
|
54
|
+
for (const [key, value] of Object.entries(dao)) {
|
|
55
|
+
if (!ENVELOPE.has(key)) config[key] = value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const feature: FeatureDef = {
|
|
59
|
+
slug: dao.slug,
|
|
60
|
+
config,
|
|
61
|
+
name: dao.name ?? null,
|
|
62
|
+
...(dao.translate !== undefined ? { translate: dao.translate } : {}),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const value: FeatureValueDto | undefined =
|
|
66
|
+
typeof dao.type === "string" && dao.type.length > 0
|
|
67
|
+
? { type: dao.type, value: dao.value }
|
|
68
|
+
: undefined;
|
|
69
|
+
|
|
70
|
+
return { feature, value };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A stored projection → the pairs a display component iterates. Malformed
|
|
74
|
+
* rows are dropped HERE, in one place, and counted by {@link
|
|
75
|
+
* unreadableFeatureCount} so a skin can say how many rather than pretend the
|
|
76
|
+
* listing had fewer attributes. */
|
|
77
|
+
export function featuresFromDaoList(
|
|
78
|
+
daos: readonly ListingFeatureDao[] | null | undefined
|
|
79
|
+
): readonly ListingFeatureView[] {
|
|
80
|
+
const out: ListingFeatureView[] = [];
|
|
81
|
+
for (const dao of daos ?? []) {
|
|
82
|
+
const view = featureFromDao(dao);
|
|
83
|
+
if (view !== undefined) out.push(view);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many rows of a stored projection this build could not key. */
|
|
89
|
+
export function unreadableFeatureCount(
|
|
90
|
+
daos: readonly ListingFeatureDao[] | null | undefined
|
|
91
|
+
): number {
|
|
92
|
+
return (daos ?? []).length - featuresFromDaoList(daos).length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A stored projection → the `{slug: {type, value}}` envelope, which is what
|
|
97
|
+
* a composer reopening a PUBLISHED listing needs: `draftValuesFromDetail`
|
|
98
|
+
* seeds its editors from the published values, and the published values live
|
|
99
|
+
* only in the DAO list.
|
|
100
|
+
*/
|
|
101
|
+
export function featuresDtoFromDaoList(
|
|
102
|
+
daos: readonly ListingFeatureDao[] | null | undefined
|
|
103
|
+
): Readonly<Record<string, FeatureValueDto>> {
|
|
104
|
+
const out: Record<string, FeatureValueDto> = {};
|
|
105
|
+
for (const view of featuresFromDaoList(daos)) {
|
|
106
|
+
if (view.value !== undefined) out[view.feature.slug] = view.value;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The `features` field of a detail response, typed.
|
|
113
|
+
*
|
|
114
|
+
* The generated `FeatureDao` union is unusable (the discriminator defect —
|
|
115
|
+
* see `api/types.ts`), and `features` is a `JSONField` on the serializer, so
|
|
116
|
+
* this cast is the boundary between "the schema's description" and "the row
|
|
117
|
+
* the JSONField actually emitted". It lives in ONE function so there is one
|
|
118
|
+
* place to change when upstream fixes the mapping.
|
|
119
|
+
*/
|
|
120
|
+
export function asFeatureDaoList(value: unknown): readonly ListingFeatureDao[] {
|
|
121
|
+
return Array.isArray(value) ? (value as readonly ListingFeatureDao[]) : [];
|
|
122
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GAP: stapel-listings cannot list a person their own listings.
|
|
3
|
+
*
|
|
4
|
+
* This is the one place in the pair where a contract is missing rather than
|
|
5
|
+
* awkward, so it gets its own module and its own name.
|
|
6
|
+
*
|
|
7
|
+
* `GET /listings/` answers `qs.published()` and takes no owner parameter
|
|
8
|
+
* (`views.ListingViewSet.get_queryset`): it returns the whole marketplace's
|
|
9
|
+
* shop window and can be narrowed to nobody. The only owner-scoped reads in
|
|
10
|
+
* the module are `my/counters` (three integers) and `my/favorites`. So a
|
|
11
|
+
* seller's own DRAFTS are unreachable by any call this contract offers — and
|
|
12
|
+
* drafts are not indexed either, so routing around it through
|
|
13
|
+
* `@stapel/search-react`'s `owner=` would return the published subset and
|
|
14
|
+
* quietly call it "everything".
|
|
15
|
+
*
|
|
16
|
+
* The storefront spec assumed `GET /listings/` was "for mine" (§4.1). It is
|
|
17
|
+
* not, and this pair records the gap rather than papering over it.
|
|
18
|
+
*
|
|
19
|
+
* Two upstream asks, in preference order:
|
|
20
|
+
* 1. an `?owner=me` filter (or a `my/listings` action) on the list endpoint,
|
|
21
|
+
* with the same `IDAnchorPagination` envelope the other two use;
|
|
22
|
+
* 2. failing that, a `status` filter on it, so at least the published subset
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
import { StapelApiError } from "@stapel/core";
|
|
31
|
+
import type { ListingPageParams, PaginatedListingCards } from "../api/types.js";
|
|
32
|
+
import type { MyListingsTab } from "./status.js";
|
|
33
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
34
|
+
|
|
35
|
+
/** One page of the caller's own listings, however the host can get them. */
|
|
36
|
+
export type MyListingsSource = (args: {
|
|
37
|
+
readonly tab: MyListingsTab;
|
|
38
|
+
readonly page: ListingPageParams;
|
|
39
|
+
readonly signal?: AbortSignal;
|
|
40
|
+
}) => Promise<PaginatedListingCards>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The error a dashboard without a source reports.
|
|
44
|
+
*
|
|
45
|
+
* `status: 0` on purpose: this is a fact about the deployment's wiring, not
|
|
46
|
+
* something a server said, and a client-side refusal must never be
|
|
47
|
+
* indistinguishable from one that came over the wire.
|
|
48
|
+
*/
|
|
49
|
+
export const MY_LISTINGS_SOURCE_MISSING: StapelApiError = new StapelApiError({
|
|
50
|
+
code: LISTINGS_I18N_KEYS.mineSourceMissing,
|
|
51
|
+
message:
|
|
52
|
+
"No MyListingsSource was supplied, and stapel-listings has no " +
|
|
53
|
+
"owner-scoped list endpoint to fall back on.",
|
|
54
|
+
status: 0,
|
|
55
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { QueryClient, UseMutationResult } from "@tanstack/react-query";
|
|
3
|
+
import type { StapelApiError } from "@stapel/core";
|
|
4
|
+
import type {
|
|
5
|
+
DeleteResponse,
|
|
6
|
+
FavoriteToggleResponse,
|
|
7
|
+
ListingActionResponse,
|
|
8
|
+
ListingDraft,
|
|
9
|
+
ListingDraftPatch,
|
|
10
|
+
PublishResponse,
|
|
11
|
+
} from "../api/types.js";
|
|
12
|
+
import { useListingsApi } from "./context.js";
|
|
13
|
+
import { listingsQueryKeys } from "./queryKeys.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every write in the module, each one saying what it invalidates and why.
|
|
17
|
+
*
|
|
18
|
+
* ── The listing id is a VARIABLE, not a hook argument ──────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Every mutation here takes its id in the mutation's variables rather than in
|
|
21
|
+
* the hook's closure, and that is a correctness decision rather than a style
|
|
22
|
+
* one. The composer creates the draft row and saves into it IN THE SAME user
|
|
23
|
+
* gesture: an id-bound hook would still be holding the id from the render
|
|
24
|
+
* before the create, so the save would go to the wrong path (or to none). A
|
|
25
|
+
* variable is read at call time, which is the only time the id is known.
|
|
26
|
+
*
|
|
27
|
+
* ── The invalidation rule ──────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* A write that can move `status` or `moderation_status` invalidates the
|
|
30
|
+
* DETAIL, the STATUS probe and the COUNTERS — the counters because their
|
|
31
|
+
* three tabs are defined by status sets (`views.my_counters`), so a publish
|
|
32
|
+
* silently moves a row from "drafts" to "active" and a dashboard that did not
|
|
33
|
+
* refetch would show the old number beside the new row.
|
|
34
|
+
*
|
|
35
|
+
* Nothing here is optimistic except the favourite toggle, and that exception
|
|
36
|
+
* is argued at its own hook.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Everything a status-moving write must freshen. */
|
|
40
|
+
function invalidateListing(queryClient: QueryClient, id: number): void {
|
|
41
|
+
void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.detail(id) });
|
|
42
|
+
void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.status(id) });
|
|
43
|
+
void queryClient.invalidateQueries({
|
|
44
|
+
queryKey: listingsQueryKeys.myCounters(),
|
|
45
|
+
});
|
|
46
|
+
void queryClient.invalidateQueries({
|
|
47
|
+
queryKey: listingsQueryKeys.validateDraft(id),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Start a draft. The server forces `owner` and `status=draft`. */
|
|
52
|
+
export function useCreateDraft(): UseMutationResult<
|
|
53
|
+
ListingDraft,
|
|
54
|
+
StapelApiError,
|
|
55
|
+
ListingDraftPatch
|
|
56
|
+
> {
|
|
57
|
+
const api = useListingsApi();
|
|
58
|
+
const queryClient = useQueryClient();
|
|
59
|
+
return useMutation({
|
|
60
|
+
mutationFn: (body: ListingDraftPatch) => api.createDraft(body),
|
|
61
|
+
retry: false,
|
|
62
|
+
onSuccess: () => {
|
|
63
|
+
void queryClient.invalidateQueries({
|
|
64
|
+
queryKey: listingsQueryKeys.myCounters(),
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a `save-draft` write carries. */
|
|
71
|
+
export interface SaveDraftInput {
|
|
72
|
+
readonly id: number;
|
|
73
|
+
readonly body: ListingDraftPatch;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Persist draft fields. Always partial — send what the form holds.
|
|
78
|
+
*
|
|
79
|
+
* The validate-draft entry is invalidated rather than written: the saved row
|
|
80
|
+
* comes back, but it carries no verdict, and a stale verdict beside fresh
|
|
81
|
+
* content is the one combination a composer must never show.
|
|
82
|
+
*/
|
|
83
|
+
export function useSaveDraft(): UseMutationResult<
|
|
84
|
+
ListingDraft,
|
|
85
|
+
StapelApiError,
|
|
86
|
+
SaveDraftInput
|
|
87
|
+
> {
|
|
88
|
+
const api = useListingsApi();
|
|
89
|
+
const queryClient = useQueryClient();
|
|
90
|
+
return useMutation({
|
|
91
|
+
mutationFn: (input: SaveDraftInput) => api.saveDraft(input.id, input.body),
|
|
92
|
+
retry: false,
|
|
93
|
+
onSuccess: (_data, input) => {
|
|
94
|
+
invalidateListing(queryClient, input.id);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Promote the draft.
|
|
101
|
+
*
|
|
102
|
+
* `retry: false` — the 400 here is a VERDICT about the content (a bare
|
|
103
|
+
* `ValidationBatchResult` body, see `model/validation.ts`), and repeating a
|
|
104
|
+
* verdict three times only delays the moment the composer can lay it on the
|
|
105
|
+
* controls.
|
|
106
|
+
*/
|
|
107
|
+
export function usePublishListing(): UseMutationResult<
|
|
108
|
+
PublishResponse,
|
|
109
|
+
StapelApiError,
|
|
110
|
+
number
|
|
111
|
+
> {
|
|
112
|
+
const api = useListingsApi();
|
|
113
|
+
const queryClient = useQueryClient();
|
|
114
|
+
return useMutation({
|
|
115
|
+
mutationFn: (id: number) => api.publish(id),
|
|
116
|
+
retry: false,
|
|
117
|
+
onSuccess: (_data, id) => {
|
|
118
|
+
invalidateListing(queryClient, id);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Move to ARCHIVED. 409 `error.409.invalid_listing_transition` carries
|
|
124
|
+
* `params.from_status`, which is what turns the refusal into a sentence. */
|
|
125
|
+
export function useArchiveListing(): UseMutationResult<
|
|
126
|
+
ListingActionResponse,
|
|
127
|
+
StapelApiError,
|
|
128
|
+
number
|
|
129
|
+
> {
|
|
130
|
+
const api = useListingsApi();
|
|
131
|
+
const queryClient = useQueryClient();
|
|
132
|
+
return useMutation({
|
|
133
|
+
mutationFn: (id: number) => api.archive(id),
|
|
134
|
+
retry: false,
|
|
135
|
+
onSuccess: (_data, id) => {
|
|
136
|
+
invalidateListing(queryClient, id);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Mark SOLD. Same 409 contract as {@link useArchiveListing}. */
|
|
142
|
+
export function useCompleteListing(): UseMutationResult<
|
|
143
|
+
ListingActionResponse,
|
|
144
|
+
StapelApiError,
|
|
145
|
+
number
|
|
146
|
+
> {
|
|
147
|
+
const api = useListingsApi();
|
|
148
|
+
const queryClient = useQueryClient();
|
|
149
|
+
return useMutation({
|
|
150
|
+
mutationFn: (id: number) => api.complete(id),
|
|
151
|
+
retry: false,
|
|
152
|
+
onSuccess: (_data, id) => {
|
|
153
|
+
invalidateListing(queryClient, id);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Soft-delete. Refused with `error.409.listing_cannot_delete_active` while
|
|
159
|
+
* the listing is PUBLISHED or PENDING. */
|
|
160
|
+
export function useDeleteListing(): UseMutationResult<
|
|
161
|
+
DeleteResponse,
|
|
162
|
+
StapelApiError,
|
|
163
|
+
number
|
|
164
|
+
> {
|
|
165
|
+
const api = useListingsApi();
|
|
166
|
+
const queryClient = useQueryClient();
|
|
167
|
+
return useMutation({
|
|
168
|
+
mutationFn: (id: number) => api.remove(id),
|
|
169
|
+
retry: false,
|
|
170
|
+
onSuccess: (_data, id) => {
|
|
171
|
+
invalidateListing(queryClient, id);
|
|
172
|
+
void queryClient.invalidateQueries({ queryKey: listingsQueryKeys.all });
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What a favourite toggle carries: the id and the state it should END in. */
|
|
178
|
+
export interface FavoriteInput {
|
|
179
|
+
readonly id: number;
|
|
180
|
+
readonly favorited: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Favourite / un-favourite, as ONE mutation over the intended next state.
|
|
185
|
+
*
|
|
186
|
+
* Two endpoints, one intent — and modelling it as two hooks would let a
|
|
187
|
+
* caller call the wrong one for the state it is in. The server is idempotent
|
|
188
|
+
* on both sides (`get_or_create` / `filter().delete()`), so a double-click
|
|
189
|
+
* cannot desynchronise anything.
|
|
190
|
+
*
|
|
191
|
+
* This is the pair's ONE write whose next state the client may predict, and
|
|
192
|
+
* the reason is that the truth is a single boolean the caller already holds.
|
|
193
|
+
* Everything else here moves a LIFECYCLE whose next state is the server's to
|
|
194
|
+
* decide — guessing `pending` versus `published` after a publish would be
|
|
195
|
+
* inventing the very verdict the pair exists to report faithfully.
|
|
196
|
+
*
|
|
197
|
+
* The invalidation reaches the favourites LIST as well as the row: a heart
|
|
198
|
+
* turned off on the favourites page must remove the card, not leave a hollow
|
|
199
|
+
* one behind.
|
|
200
|
+
*/
|
|
201
|
+
export function useFavoriteListing(): UseMutationResult<
|
|
202
|
+
FavoriteToggleResponse,
|
|
203
|
+
StapelApiError,
|
|
204
|
+
FavoriteInput
|
|
205
|
+
> {
|
|
206
|
+
const api = useListingsApi();
|
|
207
|
+
const queryClient = useQueryClient();
|
|
208
|
+
return useMutation({
|
|
209
|
+
mutationFn: (input: FavoriteInput) =>
|
|
210
|
+
input.favorited ? api.favorite(input.id) : api.unfavorite(input.id),
|
|
211
|
+
retry: false,
|
|
212
|
+
onSettled: (_data, _error, input) => {
|
|
213
|
+
void queryClient.invalidateQueries({
|
|
214
|
+
queryKey: listingsQueryKeys.detail(input.id),
|
|
215
|
+
});
|
|
216
|
+
void queryClient.invalidateQueries({
|
|
217
|
+
queryKey: listingsQueryKeys.allFavorites(),
|
|
218
|
+
});
|
|
219
|
+
void queryClient.invalidateQueries({
|
|
220
|
+
queryKey: listingsQueryKeys.allLists(),
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { UseQueryResult } from "@tanstack/react-query";
|
|
3
|
+
import { useActiveSessionReady } from "@stapel/core";
|
|
4
|
+
import type { StapelApiError } from "@stapel/core";
|
|
5
|
+
import type { ValidationBatchResult } from "@stapel/attributes-react";
|
|
6
|
+
import type {
|
|
7
|
+
ListingDetail,
|
|
8
|
+
ListingPageParams,
|
|
9
|
+
ListingStatusInfo,
|
|
10
|
+
MyCounters,
|
|
11
|
+
PaginatedListingCards,
|
|
12
|
+
} from "../api/types.js";
|
|
13
|
+
import { useListingsApi } from "./context.js";
|
|
14
|
+
import { listingsQueryKeys, pageKey } from "./queryKeys.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read hooks over the listings API (frontend-standard §2). Keys are
|
|
18
|
+
* namespaced (see `listingsQueryKeys`).
|
|
19
|
+
*
|
|
20
|
+
* ── Two gating regimes in one module, and the line between them ────────────
|
|
21
|
+
*
|
|
22
|
+
* `useListing` / `useListingStatus` / `useListingCards` call
|
|
23
|
+
* `IsAuthenticatedOrReadOnly` and `AllowAny` endpoints: a visitor reads them
|
|
24
|
+
* with no session at all, so gating on `useActiveSessionReady` would make a
|
|
25
|
+
* shop window wait for a login bootstrap it has no stake in. Core's own doc
|
|
26
|
+
* comment carves out exactly this case.
|
|
27
|
+
*
|
|
28
|
+
* `useMyCounters` / `useMyFavorites` / `useValidateDraft` are
|
|
29
|
+
* `IsAuthenticated` and DO gate: firing them before the session substrate has
|
|
30
|
+
* settled produces a 401 that means "we asked too early", which is
|
|
31
|
+
* indistinguishable on screen from "you are signed out".
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One listing in full.
|
|
36
|
+
*
|
|
37
|
+
* `retry: false` on purpose. The failures here are verdicts about the
|
|
38
|
+
* request: a 404 for a listing that is gone, a 403 for one that is not
|
|
39
|
+
* yours. Retrying a 404 three times only delays the moment the page can say
|
|
40
|
+
* which it was — and for a soft-deleted listing, `useListingStatus` is the
|
|
41
|
+
* read that can actually explain it (see `headless/ListingDetail.tsx`).
|
|
42
|
+
*/
|
|
43
|
+
export function useListing(
|
|
44
|
+
id: number | undefined,
|
|
45
|
+
options?: { readonly enabled?: boolean }
|
|
46
|
+
): UseQueryResult<ListingDetail, StapelApiError> {
|
|
47
|
+
const api = useListingsApi();
|
|
48
|
+
return useQuery({
|
|
49
|
+
queryKey: listingsQueryKeys.detail(id ?? -1),
|
|
50
|
+
queryFn: ({ signal }) => api.retrieve(id as number, { signal }),
|
|
51
|
+
enabled: (options?.enabled ?? true) && id !== undefined,
|
|
52
|
+
retry: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The status probe — both axes plus `is_deleted`, for ANY listing id.
|
|
58
|
+
*
|
|
59
|
+
* The one read that survives a soft delete (`Listing.all_objects`), which is
|
|
60
|
+
* why a detail page runs it beside the detail rather than only after a
|
|
61
|
+
* failure: "this listing was removed" is a sentence, and a bare 404 is not.
|
|
62
|
+
*/
|
|
63
|
+
export function useListingStatus(
|
|
64
|
+
id: number | undefined,
|
|
65
|
+
options?: { readonly enabled?: boolean }
|
|
66
|
+
): UseQueryResult<ListingStatusInfo, StapelApiError> {
|
|
67
|
+
const api = useListingsApi();
|
|
68
|
+
return useQuery({
|
|
69
|
+
queryKey: listingsQueryKeys.status(id ?? -1),
|
|
70
|
+
queryFn: ({ signal }) => api.status(id as number, { signal }),
|
|
71
|
+
enabled: (options?.enabled ?? true) && id !== undefined,
|
|
72
|
+
retry: false,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A keyset page of PUBLISHED cards.
|
|
78
|
+
*
|
|
79
|
+
* `keepPreviousData`: paging a grid that blanks between pages reads as a
|
|
80
|
+
* failure, and the previous page is still a true answer about the previous
|
|
81
|
+
* cursor. The FIRST load has nothing to keep and is `loading`; a failure is
|
|
82
|
+
* still `failed` rather than stale rows pretending to be current.
|
|
83
|
+
*/
|
|
84
|
+
export function useListingCards(
|
|
85
|
+
params?: ListingPageParams,
|
|
86
|
+
options?: { readonly enabled?: boolean }
|
|
87
|
+
): UseQueryResult<PaginatedListingCards, StapelApiError> {
|
|
88
|
+
const api = useListingsApi();
|
|
89
|
+
return useQuery({
|
|
90
|
+
queryKey: listingsQueryKeys.list(pageKey(params)),
|
|
91
|
+
queryFn: ({ signal }) => api.list(params, { signal }),
|
|
92
|
+
enabled: options?.enabled ?? true,
|
|
93
|
+
placeholderData: keepPreviousData,
|
|
94
|
+
retry: false,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The dashboard's three counts. Session-gated. */
|
|
99
|
+
export function useMyCounters(options?: {
|
|
100
|
+
readonly enabled?: boolean;
|
|
101
|
+
}): UseQueryResult<MyCounters, StapelApiError> {
|
|
102
|
+
const api = useListingsApi();
|
|
103
|
+
const sessionReady = useActiveSessionReady();
|
|
104
|
+
return useQuery({
|
|
105
|
+
queryKey: listingsQueryKeys.myCounters(),
|
|
106
|
+
queryFn: ({ signal }) => api.myCounters({ signal }),
|
|
107
|
+
enabled: (options?.enabled ?? true) && sessionReady,
|
|
108
|
+
retry: false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A keyset page of the caller's favourites. Session-gated. */
|
|
113
|
+
export function useMyFavorites(
|
|
114
|
+
params?: ListingPageParams,
|
|
115
|
+
options?: { readonly enabled?: boolean }
|
|
116
|
+
): UseQueryResult<PaginatedListingCards, StapelApiError> {
|
|
117
|
+
const api = useListingsApi();
|
|
118
|
+
const sessionReady = useActiveSessionReady();
|
|
119
|
+
return useQuery({
|
|
120
|
+
queryKey: listingsQueryKeys.myFavorites(pageKey(params)),
|
|
121
|
+
queryFn: ({ signal }) => api.myFavorites(params, { signal }),
|
|
122
|
+
enabled: (options?.enabled ?? true) && sessionReady,
|
|
123
|
+
placeholderData: keepPreviousData,
|
|
124
|
+
retry: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What publishing WOULD say, without publishing.
|
|
130
|
+
*
|
|
131
|
+
* Disabled by default: it is a round trip per call and the composer's own
|
|
132
|
+
* mirror answers the same question for free most of the time. A composer
|
|
133
|
+
* turns it on when the person asks ("check my listing") and after a save, so
|
|
134
|
+
* the SERVER's opinion of the saved draft is what the button reports —
|
|
135
|
+
* `staleTime: 0` because the draft is exactly the thing that just moved.
|
|
136
|
+
*/
|
|
137
|
+
export function useValidateDraft(
|
|
138
|
+
id: number | undefined,
|
|
139
|
+
options?: { readonly enabled?: boolean }
|
|
140
|
+
): UseQueryResult<ValidationBatchResult, StapelApiError> {
|
|
141
|
+
const api = useListingsApi();
|
|
142
|
+
const sessionReady = useActiveSessionReady();
|
|
143
|
+
return useQuery({
|
|
144
|
+
queryKey: listingsQueryKeys.validateDraft(id ?? -1),
|
|
145
|
+
queryFn: ({ signal }) => api.validateDraft(id as number, { signal }),
|
|
146
|
+
enabled: (options?.enabled ?? false) && id !== undefined && sessionReady,
|
|
147
|
+
staleTime: 0,
|
|
148
|
+
retry: false,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
|
|
3
|
+
* Everything under the `"listings"` root so a host can invalidate the whole
|
|
4
|
+
* module or match a single read. Explicit tuple return types satisfy
|
|
5
|
+
* `--isolatedDeclarations`.
|
|
6
|
+
*
|
|
7
|
+
* THE KEY IS THE REQUEST for the paginated reads: a page is keyed on the very
|
|
8
|
+
* parameter object the client is handed, normalized so that "no cursor" and
|
|
9
|
+
* "an explicitly undefined cursor" cannot cache twice. Two states that
|
|
10
|
+
* produce the same request therefore share a cache entry, and one that
|
|
11
|
+
* produces a different request cannot silently reuse a page — the stale-page
|
|
12
|
+
* bug (new filter, cached rows) is unwritable rather than merely avoided.
|
|
13
|
+
*/
|
|
14
|
+
import type { ListingPageParams } from "../api/types.js";
|
|
15
|
+
|
|
16
|
+
/** The normalized page cursor a list read is keyed on. */
|
|
17
|
+
export interface ListingPageKey {
|
|
18
|
+
readonly anchor: string | null;
|
|
19
|
+
readonly direction: string | null;
|
|
20
|
+
readonly limit: number | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalize a page's parameters into its key. Exported because a test that
|
|
24
|
+
* asserts "these two states hit one cache entry" must build the key the same
|
|
25
|
+
* way the hook does. */
|
|
26
|
+
export function pageKey(params?: ListingPageParams): ListingPageKey {
|
|
27
|
+
return {
|
|
28
|
+
anchor: params?.anchor ?? null,
|
|
29
|
+
direction: params?.direction ?? null,
|
|
30
|
+
limit: params?.limit ?? null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ROOT = "listings" as const;
|
|
35
|
+
|
|
36
|
+
export const listingsQueryKeys: {
|
|
37
|
+
readonly all: readonly ["listings"];
|
|
38
|
+
/** The published card list. */
|
|
39
|
+
list(page: ListingPageKey): readonly ["listings", "list", ListingPageKey];
|
|
40
|
+
/** One listing in full. */
|
|
41
|
+
detail(id: number): readonly ["listings", "detail", number];
|
|
42
|
+
/** The AllowAny status probe — a SEPARATE entry from the detail on purpose:
|
|
43
|
+
* it answers for a soft-deleted listing the detail 404s on, so caching them
|
|
44
|
+
* together would let a 404 evict the one read that can explain it. */
|
|
45
|
+
status(id: number): readonly ["listings", "status", number];
|
|
46
|
+
/** The dashboard's three counts. */
|
|
47
|
+
myCounters(): readonly ["listings", "my", "counters"];
|
|
48
|
+
/** The caller's favourites, one keyset page. */
|
|
49
|
+
myFavorites(
|
|
50
|
+
page: ListingPageKey
|
|
51
|
+
): readonly ["listings", "my", "favorites", ListingPageKey];
|
|
52
|
+
/** The owner's own rows, per tab — see `headless/MyListings.tsx` for why
|
|
53
|
+
* this read goes through an injected source. */
|
|
54
|
+
mine(
|
|
55
|
+
tab: string,
|
|
56
|
+
page: ListingPageKey
|
|
57
|
+
): readonly ["listings", "my", "listings", string, ListingPageKey];
|
|
58
|
+
/** `GET /{pk}/validate-draft/` — the dry run of a publish. */
|
|
59
|
+
validateDraft(id: number): readonly ["listings", "validate-draft", number];
|
|
60
|
+
/** Every favourites PAGE, for an invalidation after a toggle — the cursor
|
|
61
|
+
* is not known at the write, so the prefix is what a write can target. */
|
|
62
|
+
allFavorites(): readonly ["listings", "my", "favorites"];
|
|
63
|
+
/** Every published-card PAGE, same reason. */
|
|
64
|
+
allLists(): readonly ["listings", "list"];
|
|
65
|
+
} = {
|
|
66
|
+
all: [ROOT],
|
|
67
|
+
list: (page) => [ROOT, "list", page],
|
|
68
|
+
detail: (id) => [ROOT, "detail", id],
|
|
69
|
+
status: (id) => [ROOT, "status", id],
|
|
70
|
+
myCounters: () => [ROOT, "my", "counters"],
|
|
71
|
+
myFavorites: (page) => [ROOT, "my", "favorites", page],
|
|
72
|
+
mine: (tab, page) => [ROOT, "my", "listings", tab, page],
|
|
73
|
+
validateDraft: (id) => [ROOT, "validate-draft", id],
|
|
74
|
+
allFavorites: () => [ROOT, "my", "favorites"],
|
|
75
|
+
allLists: () => [ROOT, "list"],
|
|
76
|
+
};
|