@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,87 @@
|
|
|
1
|
+
import { createModuleRuntime } from "@stapel/core";
|
|
2
|
+
import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
|
|
3
|
+
import type { StapelImage } from "@stapel/image";
|
|
4
|
+
import { createListingsApi } from "../api/listingsApi.js";
|
|
5
|
+
import type { ListingsApi } from "../api/listingsApi.js";
|
|
6
|
+
import { DEFAULT_LISTING_CURRENCY } from "../api/types.js";
|
|
7
|
+
import { DEFAULT_DRAFT_LIMITS } from "./validation.js";
|
|
8
|
+
import type { ListingDraftLimits } from "./validation.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Turn a stored image reference into something renderable.
|
|
12
|
+
*
|
|
13
|
+
* ── Why this is a seam and not a URL builder ───────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* `Listing.images` is a list of OPAQUE CDN references (`<type>/<hash>`), and
|
|
16
|
+
* the listings contract offers no way to resolve one: there is no
|
|
17
|
+
* `GET /listings/{pk}/images/`, and stapel-cdn's `file/exists/` is
|
|
18
|
+
* owner-scoped (`uploaded_by=request.user`), so `@stapel/cdn-react`'s
|
|
19
|
+
* `useCdnRef` can resolve a person's OWN draft and can never render a
|
|
20
|
+
* stranger's gallery — it says so itself. There is therefore no public
|
|
21
|
+
* read-by-reference anywhere in this fleet's contracts today.
|
|
22
|
+
*
|
|
23
|
+
* The honest answer is a seam, not an invented URL convention: a deployment
|
|
24
|
+
* knows where its CDN serves from, and hands that knowledge in once. A pair
|
|
25
|
+
* that guessed `${cdnBase}/${ref}` would be writing a contract nobody agreed
|
|
26
|
+
* to, and it would break the first host that puts a signature on the path.
|
|
27
|
+
*
|
|
28
|
+
* Returning a full `StapelImage` rather than a string is what buys the
|
|
29
|
+
* variant ladder: with `variants` populated, `@stapel/image`'s `<Image>`
|
|
30
|
+
* measures the slot and picks a tier; with only `url`, it shows that one.
|
|
31
|
+
* Both are supported, and a resolver that has nothing for a reference returns
|
|
32
|
+
* `undefined` — the skin then says "photo unavailable" instead of drawing a
|
|
33
|
+
* broken `<img>`. Recorded as an upstream gap in MODULE.md.
|
|
34
|
+
*/
|
|
35
|
+
export type ListingImageResolver = (ref: string) => StapelImage | undefined;
|
|
36
|
+
|
|
37
|
+
export interface CreateListingsRuntimeOptions
|
|
38
|
+
extends CreateModuleRuntimeOptions {
|
|
39
|
+
/** See {@link ListingImageResolver}. Absent = no gallery is drawn, and the
|
|
40
|
+
* skin names the reason. */
|
|
41
|
+
readonly resolveImage?: ListingImageResolver;
|
|
42
|
+
/**
|
|
43
|
+
* The deployment's own ceilings, mirrored client-side. Partial: only the
|
|
44
|
+
* ones that differ from `stapel_listings/conf.py`'s library defaults.
|
|
45
|
+
*/
|
|
46
|
+
readonly limits?: Partial<ListingDraftLimits>;
|
|
47
|
+
/** Currency a new draft starts in. Owner verdict F6: RUB. */
|
|
48
|
+
readonly currency?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The wired listings runtime — core's `ModuleRuntime` bound to this pair's
|
|
53
|
+
* API (slim wave §21/S2), plus the three pieces of deployment knowledge the
|
|
54
|
+
* pair cannot derive: how to render a reference, where the ceilings are, and
|
|
55
|
+
* what currency the shop trades in.
|
|
56
|
+
*
|
|
57
|
+
* ```tsx
|
|
58
|
+
* const runtime = createListingsRuntime({
|
|
59
|
+
* baseUrl: "/listings/api/v1/",
|
|
60
|
+
* resolveImage: (ref) => myCdn.describe(ref),
|
|
61
|
+
* });
|
|
62
|
+
* <ListingsProvider runtime={runtime}>…</ListingsProvider>
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* NOT session-gated as a whole. The public reads (`retrieve`, `status`,
|
|
66
|
+
* `list`) are `IsAuthenticatedOrReadOnly` and answer a visitor who will never
|
|
67
|
+
* sign in; the owner reads and every write are `IsAuthenticated` and the
|
|
68
|
+
* hooks that call them gate on `useActiveSessionReady` individually. Gating
|
|
69
|
+
* the module would make a shop window wait for a login bootstrap.
|
|
70
|
+
*/
|
|
71
|
+
export interface ListingsRuntime extends ModuleRuntime<ListingsApi> {
|
|
72
|
+
readonly resolveImage: ListingImageResolver | undefined;
|
|
73
|
+
readonly limits: ListingDraftLimits;
|
|
74
|
+
readonly currency: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createListingsRuntime(
|
|
78
|
+
options: CreateListingsRuntimeOptions
|
|
79
|
+
): ListingsRuntime {
|
|
80
|
+
const base = createModuleRuntime((client) => createListingsApi(client), options);
|
|
81
|
+
return {
|
|
82
|
+
...base,
|
|
83
|
+
resolveImage: options.resolveImage,
|
|
84
|
+
limits: { ...DEFAULT_DRAFT_LIMITS, ...options.limits },
|
|
85
|
+
currency: options.currency ?? DEFAULT_LISTING_CURRENCY,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TWO AXES, BOTH ON SCREEN, NEITHER STANDING IN FOR THE OTHER.
|
|
3
|
+
*
|
|
4
|
+
* `status` (nine states) is the lifecycle and the ONLY thing that decides
|
|
5
|
+
* whether a listing is public: `Listing.objects.published()` filters on it
|
|
6
|
+
* alone, `INDEXED_STATUSES` is `{published}`, and the model says so out loud
|
|
7
|
+
* ("no visibility-reads-moderation_status coupling", `models.py`).
|
|
8
|
+
* `moderation_status` (four states) is the content verdict and decides
|
|
9
|
+
* NOTHING about visibility.
|
|
10
|
+
*
|
|
11
|
+
* Since stapel-listings 0.5.0 the two genuinely diverge, and that divergence
|
|
12
|
+
* is the reason this file exists:
|
|
13
|
+
*
|
|
14
|
+
* first publication status draft → PENDING, moderation → pending
|
|
15
|
+
* (nothing public until a verdict arrives)
|
|
16
|
+
* editing a LIVE listing status published (UNCHANGED), moderation → pending
|
|
17
|
+
* (the edit is visible immediately; a rejecting
|
|
18
|
+
* verdict later lands as PUBLISHED → BLOCKED)
|
|
19
|
+
*
|
|
20
|
+
* A dashboard that computed "under re-review" from `status` would show
|
|
21
|
+
* nothing at all for the second row — the listing looks exactly as it did —
|
|
22
|
+
* and the owner would never learn their edit is being screened. A dashboard
|
|
23
|
+
* that computed "visible" from `moderation_status` would tell them their live
|
|
24
|
+
* listing is offline while strangers are reading it. Both mistakes are one
|
|
25
|
+
* `if` away, which is why the sentence a person reads is produced HERE, from
|
|
26
|
+
* BOTH fields, once.
|
|
27
|
+
*
|
|
28
|
+
* Everything below is pure: no React, no antd, no fetch. `test/status.test.ts`
|
|
29
|
+
* asserts the whole 9 × 4 table, so a state added upstream is a red test and
|
|
30
|
+
* not a bare enum value on a page.
|
|
31
|
+
*/
|
|
32
|
+
import type {
|
|
33
|
+
ListingLifecycleStatus,
|
|
34
|
+
ListingModerationStatus,
|
|
35
|
+
} from "../api/types.js";
|
|
36
|
+
import { LISTING_TRANSITIONS } from "./transitions.js";
|
|
37
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How a status should READ, independent of the words: neutral (nothing is
|
|
41
|
+
* happening), waiting (something is in progress and needs no action), good
|
|
42
|
+
* (live), warning (the owner has something to do), stopped (the listing is
|
|
43
|
+
* over, by choice or by verdict).
|
|
44
|
+
*
|
|
45
|
+
* A tone rather than a colour: `/default` maps it to antd tags, another skin
|
|
46
|
+
* maps it to whatever it maps it to, and the token layer stays the only place
|
|
47
|
+
* a hex exists.
|
|
48
|
+
*/
|
|
49
|
+
export type ListingStatusTone =
|
|
50
|
+
| "neutral"
|
|
51
|
+
| "waiting"
|
|
52
|
+
| "good"
|
|
53
|
+
| "warning"
|
|
54
|
+
| "stopped";
|
|
55
|
+
|
|
56
|
+
/** The lifecycle half of what a person is told. */
|
|
57
|
+
export interface LifecycleCaption {
|
|
58
|
+
readonly status: ListingLifecycleStatus;
|
|
59
|
+
readonly labelKey: string;
|
|
60
|
+
readonly tone: ListingStatusTone;
|
|
61
|
+
/** Is the listing readable by a stranger right now? `published` and
|
|
62
|
+
* nothing else — the same predicate the queryset uses. */
|
|
63
|
+
readonly publiclyVisible: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const LIFECYCLE: Readonly<
|
|
67
|
+
Record<ListingLifecycleStatus, Omit<LifecycleCaption, "status">>
|
|
68
|
+
> = {
|
|
69
|
+
draft: {
|
|
70
|
+
labelKey: LISTINGS_I18N_KEYS.statusDraft,
|
|
71
|
+
tone: "neutral",
|
|
72
|
+
publiclyVisible: false,
|
|
73
|
+
},
|
|
74
|
+
pending: {
|
|
75
|
+
labelKey: LISTINGS_I18N_KEYS.statusPending,
|
|
76
|
+
tone: "waiting",
|
|
77
|
+
publiclyVisible: false,
|
|
78
|
+
},
|
|
79
|
+
published: {
|
|
80
|
+
labelKey: LISTINGS_I18N_KEYS.statusPublished,
|
|
81
|
+
tone: "good",
|
|
82
|
+
publiclyVisible: true,
|
|
83
|
+
},
|
|
84
|
+
paused: {
|
|
85
|
+
labelKey: LISTINGS_I18N_KEYS.statusPaused,
|
|
86
|
+
tone: "neutral",
|
|
87
|
+
publiclyVisible: false,
|
|
88
|
+
},
|
|
89
|
+
expired: {
|
|
90
|
+
labelKey: LISTINGS_I18N_KEYS.statusExpired,
|
|
91
|
+
tone: "warning",
|
|
92
|
+
publiclyVisible: false,
|
|
93
|
+
},
|
|
94
|
+
sold: {
|
|
95
|
+
labelKey: LISTINGS_I18N_KEYS.statusSold,
|
|
96
|
+
tone: "stopped",
|
|
97
|
+
publiclyVisible: false,
|
|
98
|
+
},
|
|
99
|
+
rejected: {
|
|
100
|
+
labelKey: LISTINGS_I18N_KEYS.statusRejected,
|
|
101
|
+
tone: "warning",
|
|
102
|
+
publiclyVisible: false,
|
|
103
|
+
},
|
|
104
|
+
blocked: {
|
|
105
|
+
labelKey: LISTINGS_I18N_KEYS.statusBlocked,
|
|
106
|
+
tone: "stopped",
|
|
107
|
+
publiclyVisible: false,
|
|
108
|
+
},
|
|
109
|
+
archived: {
|
|
110
|
+
labelKey: LISTINGS_I18N_KEYS.statusArchived,
|
|
111
|
+
tone: "neutral",
|
|
112
|
+
publiclyVisible: false,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** The lifecycle caption for one status. */
|
|
117
|
+
export function lifecycleCaption(
|
|
118
|
+
status: ListingLifecycleStatus
|
|
119
|
+
): LifecycleCaption {
|
|
120
|
+
return { status, ...LIFECYCLE[status] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Is this listing readable by someone who is not its owner?
|
|
125
|
+
*
|
|
126
|
+
* One predicate, one field, matching the server's. Exported because the
|
|
127
|
+
* DETAIL read needs it and cannot get it from a 404: `GET /listings/{pk}/`
|
|
128
|
+
* has no `published()` filter on its queryset, so a draft answers 200 to a
|
|
129
|
+
* stranger who knows the id (upstream note in MODULE.md). The pair reports
|
|
130
|
+
* the fact instead of rendering a draft as if it were a shop window.
|
|
131
|
+
*/
|
|
132
|
+
export function isPubliclyVisible(status: ListingLifecycleStatus): boolean {
|
|
133
|
+
return LIFECYCLE[status].publiclyVisible;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The owner-facing note about the moderation axis, when there is one. */
|
|
137
|
+
export interface ModerationNotice {
|
|
138
|
+
readonly moderationStatus: ListingModerationStatus;
|
|
139
|
+
readonly messageKey: string;
|
|
140
|
+
readonly tone: ListingStatusTone;
|
|
141
|
+
/**
|
|
142
|
+
* Is the listing live WHILE this verdict is pending? True only for the
|
|
143
|
+
* re-moderation row — a published listing whose edit is being screened.
|
|
144
|
+
* The sentence differs from a first submission's precisely here.
|
|
145
|
+
*/
|
|
146
|
+
readonly liveDuringReview: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What to say about the moderation axis, given BOTH fields.
|
|
151
|
+
*
|
|
152
|
+
* `undefined` means "nothing to add": an approved listing needs no note, and
|
|
153
|
+
* neither does a draft nobody has submitted. Every other combination has a
|
|
154
|
+
* sentence, including the ones that look contradictory — those are the ones
|
|
155
|
+
* worth naming.
|
|
156
|
+
*/
|
|
157
|
+
export function moderationNotice(
|
|
158
|
+
status: ListingLifecycleStatus,
|
|
159
|
+
moderationStatus: ListingModerationStatus
|
|
160
|
+
): ModerationNotice | undefined {
|
|
161
|
+
const live = status === "published";
|
|
162
|
+
|
|
163
|
+
if (moderationStatus === "approved") {
|
|
164
|
+
// A takedown keeps `approved` on the moderation axis in some flows (the
|
|
165
|
+
// lifecycle moved, the verdict did not), so an approved BLOCKED listing
|
|
166
|
+
// still owes its owner an explanation — and it is the lifecycle's.
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (moderationStatus === "pending") {
|
|
171
|
+
if (live) {
|
|
172
|
+
return {
|
|
173
|
+
moderationStatus,
|
|
174
|
+
messageKey: LISTINGS_I18N_KEYS.moderationLiveEditPending,
|
|
175
|
+
tone: "waiting",
|
|
176
|
+
liveDuringReview: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (status === "pending") {
|
|
180
|
+
return {
|
|
181
|
+
moderationStatus,
|
|
182
|
+
messageKey: LISTINGS_I18N_KEYS.moderationFirstReview,
|
|
183
|
+
tone: "waiting",
|
|
184
|
+
liveDuringReview: false,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// A draft/archived/sold row carrying a pending verdict: the submission
|
|
188
|
+
// was overtaken by a lifecycle move. Say that, rather than promising a
|
|
189
|
+
// review that will not put it back on the shelf.
|
|
190
|
+
return {
|
|
191
|
+
moderationStatus,
|
|
192
|
+
messageKey: LISTINGS_I18N_KEYS.moderationPendingOffline,
|
|
193
|
+
tone: "neutral",
|
|
194
|
+
liveDuringReview: false,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (moderationStatus === "needs_review") {
|
|
199
|
+
return {
|
|
200
|
+
moderationStatus,
|
|
201
|
+
messageKey: live
|
|
202
|
+
? LISTINGS_I18N_KEYS.moderationLiveNeedsReview
|
|
203
|
+
: LISTINGS_I18N_KEYS.moderationNeedsReview,
|
|
204
|
+
tone: "waiting",
|
|
205
|
+
liveDuringReview: live,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// rejected — and it matters whether the lifecycle followed the verdict.
|
|
210
|
+
return {
|
|
211
|
+
moderationStatus,
|
|
212
|
+
messageKey: live
|
|
213
|
+
? LISTINGS_I18N_KEYS.moderationRejectedStillLive
|
|
214
|
+
: LISTINGS_I18N_KEYS.moderationRejected,
|
|
215
|
+
tone: "warning",
|
|
216
|
+
liveDuringReview: live,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Both halves at once — what a dashboard row and a detail header render. */
|
|
221
|
+
export interface ListingStatusView {
|
|
222
|
+
readonly lifecycle: LifecycleCaption;
|
|
223
|
+
readonly moderation: ModerationNotice | undefined;
|
|
224
|
+
/** True when the two axes disagree in the way 0.5.0 made possible: live to
|
|
225
|
+
* the public, under review for its owner. The one combination a host is
|
|
226
|
+
* most likely to get wrong, so it is a named boolean and not an inference
|
|
227
|
+
* a caller repeats. */
|
|
228
|
+
readonly liveUnderReview: boolean;
|
|
229
|
+
/** What the owner can do next, in lifecycle terms — see
|
|
230
|
+
* {@link LISTING_TRANSITIONS}. */
|
|
231
|
+
readonly allowedTransitions: readonly ListingLifecycleStatus[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function listingStatusView(
|
|
235
|
+
status: ListingLifecycleStatus,
|
|
236
|
+
moderationStatus: ListingModerationStatus
|
|
237
|
+
): ListingStatusView {
|
|
238
|
+
const moderation = moderationNotice(status, moderationStatus);
|
|
239
|
+
return {
|
|
240
|
+
lifecycle: lifecycleCaption(status),
|
|
241
|
+
moderation,
|
|
242
|
+
liveUnderReview: moderation?.liveDuringReview === true,
|
|
243
|
+
allowedTransitions: LISTING_TRANSITIONS[status],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The dashboard tabs, and the statuses each one folds together.
|
|
249
|
+
*
|
|
250
|
+
* The grouping is the SERVER's (`views.my_counters`), copied here so a tab's
|
|
251
|
+
* caption and its count cannot describe different sets: `active` includes
|
|
252
|
+
* PENDING beside PUBLISHED, and `drafts` includes REJECTED beside DRAFT —
|
|
253
|
+
* both because a listing in those states is something the owner is still
|
|
254
|
+
* working on. A client that grouped them its own way would show "3 active"
|
|
255
|
+
* over two rows.
|
|
256
|
+
*/
|
|
257
|
+
export const MY_LISTINGS_TABS = ["active", "drafts", "archived"] as const;
|
|
258
|
+
|
|
259
|
+
export type MyListingsTab = (typeof MY_LISTINGS_TABS)[number];
|
|
260
|
+
|
|
261
|
+
export const MY_LISTINGS_TAB_STATUSES: Readonly<
|
|
262
|
+
Record<MyListingsTab, readonly ListingLifecycleStatus[]>
|
|
263
|
+
> = {
|
|
264
|
+
active: ["published", "pending"],
|
|
265
|
+
drafts: ["draft", "rejected"],
|
|
266
|
+
archived: ["archived", "paused", "expired", "sold"],
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Which tab a listing belongs to, or `undefined` for BLOCKED — the one
|
|
270
|
+
* status `my/counters` counts in no tab at all. A dashboard that silently
|
|
271
|
+
* dropped it would hide exactly the listing whose owner most needs to know. */
|
|
272
|
+
export function tabOf(
|
|
273
|
+
status: ListingLifecycleStatus
|
|
274
|
+
): MyListingsTab | undefined {
|
|
275
|
+
for (const tab of MY_LISTINGS_TABS) {
|
|
276
|
+
if (MY_LISTINGS_TAB_STATUSES[tab].includes(status)) return tab;
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lifecycle whitelist, mirrored from `stapel_listings.models
|
|
3
|
+
* .LISTING_TRANSITIONS`.
|
|
4
|
+
*
|
|
5
|
+
* ── A mirror, not a verdict ────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The server decides. `transition_to` raises `TransitionError` and the view
|
|
8
|
+
* answers **409 `error.409.invalid_listing_transition`** with
|
|
9
|
+
* `params.from_status`, and that refusal is rendered as the named sentence it
|
|
10
|
+
* is. What the mirror buys is the difference between a button that is there
|
|
11
|
+
* and does nothing and a button that is switched off WITH its reason: "you
|
|
12
|
+
* cannot mark a draft sold — publish it first" beats a 409 toast after the
|
|
13
|
+
* click, and beats a hidden control (which teaches nothing) by the whole
|
|
14
|
+
* private-space canon §6.3.
|
|
15
|
+
*
|
|
16
|
+
* The mirror is deliberately CONSERVATIVE in one direction only: it may never
|
|
17
|
+
* block something the server would allow. That is why it is a copy of the
|
|
18
|
+
* table rather than a hand-written summary of it, and why `test/status.test
|
|
19
|
+
* .ts` walks every source state.
|
|
20
|
+
*
|
|
21
|
+
* Two entries look odd and are correct:
|
|
22
|
+
* - `published → blocked` exists, but the owner API has no route to it: only
|
|
23
|
+
* `apply_moderation("rejected")` walks it. It is in the table because the
|
|
24
|
+
* table is the model's, and leaving it out would make the mirror a
|
|
25
|
+
* paraphrase.
|
|
26
|
+
* - `blocked → published` is reinstatement after a successful appeal, also
|
|
27
|
+
* moderation-driven. `archive` and `complete` are the only two transitions
|
|
28
|
+
* this pair's API can actually request.
|
|
29
|
+
*/
|
|
30
|
+
import type { ListingLifecycleStatus } from "../api/types.js";
|
|
31
|
+
|
|
32
|
+
export const LISTING_TRANSITIONS: Readonly<
|
|
33
|
+
Record<ListingLifecycleStatus, readonly ListingLifecycleStatus[]>
|
|
34
|
+
> = {
|
|
35
|
+
draft: ["pending", "archived"],
|
|
36
|
+
pending: ["published", "rejected", "draft", "archived"],
|
|
37
|
+
published: ["paused", "expired", "sold", "blocked", "archived"],
|
|
38
|
+
blocked: ["published", "draft", "archived"],
|
|
39
|
+
paused: ["published", "archived", "expired"],
|
|
40
|
+
expired: ["pending", "published", "archived"],
|
|
41
|
+
sold: ["archived", "published"],
|
|
42
|
+
rejected: ["draft", "archived"],
|
|
43
|
+
archived: ["draft"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Would the server accept this move? A same-status move is a no-op the
|
|
47
|
+
* server returns early on, so it counts as allowed. */
|
|
48
|
+
export function canTransition(
|
|
49
|
+
from: ListingLifecycleStatus,
|
|
50
|
+
to: ListingLifecycleStatus
|
|
51
|
+
): boolean {
|
|
52
|
+
return from === to || LISTING_TRANSITIONS[from].includes(to);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deleting is not a transition and has its own rule
|
|
57
|
+
* (`views.destroy`): a PUBLISHED or PENDING listing is refused with
|
|
58
|
+
* `error.409.listing_cannot_delete_active` — archive it first.
|
|
59
|
+
*/
|
|
60
|
+
export function canDelete(status: ListingLifecycleStatus): boolean {
|
|
61
|
+
return status !== "published" && status !== "pending";
|
|
62
|
+
}
|