@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,476 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
actionAvailable,
|
|
5
|
+
actionBlocked,
|
|
6
|
+
firstBlock,
|
|
7
|
+
loadFailed,
|
|
8
|
+
loadLoading,
|
|
9
|
+
loadReady,
|
|
10
|
+
} from "@stapel/core";
|
|
11
|
+
import type { ActionAvailability, FlowError, LoadState } from "@stapel/core";
|
|
12
|
+
import type { FeatureDef, FeaturesDto } from "@stapel/attributes-react";
|
|
13
|
+
import { toFeaturesDto, unsupportedTypes } from "@stapel/attributes-react";
|
|
14
|
+
import type {
|
|
15
|
+
ListingDetail as ListingDetailData,
|
|
16
|
+
ListingDraft,
|
|
17
|
+
PublishResponse,
|
|
18
|
+
} from "../api/types.js";
|
|
19
|
+
import { useListingsRuntime } from "../model/context.js";
|
|
20
|
+
import { useListing } from "../model/queries.js";
|
|
21
|
+
import {
|
|
22
|
+
useCreateDraft,
|
|
23
|
+
usePublishListing,
|
|
24
|
+
useSaveDraft,
|
|
25
|
+
} from "../model/mutations.js";
|
|
26
|
+
import {
|
|
27
|
+
createDraftBody,
|
|
28
|
+
draftPatchFromValues,
|
|
29
|
+
draftValuesFromDetail,
|
|
30
|
+
droppedFeatureSlugs,
|
|
31
|
+
emptyDraftValues,
|
|
32
|
+
retainKnownFeatureValues,
|
|
33
|
+
} from "../model/draft.js";
|
|
34
|
+
import type { ListingDraftValues, ListingLocation } from "../model/draft.js";
|
|
35
|
+
import { asFeatureDaoList } from "../model/features.js";
|
|
36
|
+
import { featuresDtoFromDaoList } from "../model/features.js";
|
|
37
|
+
import { mirrorDraft, publishRefusal } from "../model/validation.js";
|
|
38
|
+
import type { PublishRefusal } from "../model/validation.js";
|
|
39
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
40
|
+
import { useMandateGate } from "./useMandateGate.js";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The submission flow — the pair's long pole, and the place four contracts
|
|
44
|
+
* meet: the draft twin (listings), the value editors (attributes), the upload
|
|
45
|
+
* queue (cdn) and the category schema (categories).
|
|
46
|
+
*
|
|
47
|
+
* ── The steps, and why they are not a `@flow_step` funnel ──────────────────
|
|
48
|
+
*
|
|
49
|
+
* pick a category → create the draft row → save into it → publish
|
|
50
|
+
*
|
|
51
|
+
* The server does not sequence these (see `flows/registry.ts`). What DOES
|
|
52
|
+
* sequence them is here, and its stages are named so analytics can follow the
|
|
53
|
+
* funnel without the backend having declared one.
|
|
54
|
+
*
|
|
55
|
+
* ── The three seams, and why none of them is an import ─────────────────────
|
|
56
|
+
*
|
|
57
|
+
* `@stapel/listings-react`, `@stapel/cdn-react` and `@stapel/categories-react`
|
|
58
|
+
* are all L2 pairs, and L2 pairs never import each other (the monorepo README
|
|
59
|
+
* states the direction). So:
|
|
60
|
+
*
|
|
61
|
+
* - **the category schema** arrives as `features` — a plain
|
|
62
|
+
* `readonly FeatureDef[]`. The container gets it from
|
|
63
|
+
* `@stapel/categories-react`'s `useCategoryFeatures`;
|
|
64
|
+
* - **the gallery** arrives as {@link ListingImagesBag} — two members,
|
|
65
|
+
* satisfied STRUCTURALLY by `useUploadQueue()`'s bag from
|
|
66
|
+
* `@stapel/cdn-react`. `bag.refs` IS the value of `images_draft` (same
|
|
67
|
+
* order, first tile is the cover) and `bag.settled` is the submit gate
|
|
68
|
+
* that stops a publish while photos are in flight. That pair wrote its bag
|
|
69
|
+
* to this contract on purpose (its §13.6 note 9);
|
|
70
|
+
* - **the value editors** come from `@stapel/attributes-react`, which is L0
|
|
71
|
+
* and therefore a legitimate dependency. Only `editorTypes` crosses back:
|
|
72
|
+
* the headless half must judge renderability without importing a skin, so
|
|
73
|
+
* the skin's `BUILTIN_VALUE_EDITOR_TYPES` is passed IN.
|
|
74
|
+
*
|
|
75
|
+
* ── Reopening a draft is NOT possible on stapel-listings 0.6.0 ─────────────
|
|
76
|
+
*
|
|
77
|
+
* No read returns the `*_draft` twin: `GET /{pk}/` serializes the PUBLISHED
|
|
78
|
+
* fields (`ListingDetailSerializer`), and `ListingDraftSerializer` appears
|
|
79
|
+
* only as the RESPONSE of create / save-draft. So editing a live listing
|
|
80
|
+
* works completely — the composer seeds from the published half, which is
|
|
81
|
+
* exactly what the person sees on the page — while a draft abandoned and
|
|
82
|
+
* reopened later comes back empty. The composer says so
|
|
83
|
+
* (`draftNotReadable`) instead of showing a blank form as if nothing had been
|
|
84
|
+
* typed. Upstream ask, recorded in MODULE.md: put the draft twin on the
|
|
85
|
+
* retrieve, or add `GET /{pk}/draft/`.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/** The two members of `@stapel/cdn-react`'s upload bag this composer needs.
|
|
89
|
+
* Declared structurally so the dependency stays a wiring decision. */
|
|
90
|
+
export interface ListingImagesBag {
|
|
91
|
+
/** Settled CDN references in display order — the value of `images_draft`. */
|
|
92
|
+
readonly refs: readonly string[];
|
|
93
|
+
/** Available when every pick has a reference; blocked WITH the reason
|
|
94
|
+
* (still uploading / one failed) otherwise. */
|
|
95
|
+
readonly settled: ActionAvailability;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Where the submission has got to. Named so a funnel can be measured. */
|
|
99
|
+
export type ComposeStage =
|
|
100
|
+
| "choosing_category"
|
|
101
|
+
| "editing"
|
|
102
|
+
| "saving"
|
|
103
|
+
| "publishing"
|
|
104
|
+
| "published";
|
|
105
|
+
|
|
106
|
+
/** What a successful publish turned out to be — the 0.5.0 distinction, taken
|
|
107
|
+
* from the server's own answer rather than guessed from what we sent. */
|
|
108
|
+
export type PublishOutcome = "submitted_for_review" | "live_edit_under_review";
|
|
109
|
+
|
|
110
|
+
export interface UseListingComposerOptions {
|
|
111
|
+
/** An existing listing to edit. Absent: a new one, whose row is created on
|
|
112
|
+
* the first save. */
|
|
113
|
+
readonly listingId?: number;
|
|
114
|
+
/** The chosen category's features. Empty while none is chosen. */
|
|
115
|
+
readonly features: readonly FeatureDef[];
|
|
116
|
+
/** The schema read is in flight. Blocks the submit with its own reason —
|
|
117
|
+
* publishing against a half-known schema is how a mandatory attribute gets
|
|
118
|
+
* skipped. */
|
|
119
|
+
readonly featuresLoading?: boolean;
|
|
120
|
+
/** The schema read failed. Same block, different sentence. */
|
|
121
|
+
readonly featuresError?: unknown;
|
|
122
|
+
/** Value types the rendering half can draw — pass
|
|
123
|
+
* `BUILTIN_VALUE_EDITOR_TYPES` from `@stapel/attributes-react/default`. */
|
|
124
|
+
readonly editorTypes?: readonly string[];
|
|
125
|
+
/** The upload queue. Absent: no gallery, and `images` is set through
|
|
126
|
+
* `setValue` by whatever the host uses instead. */
|
|
127
|
+
readonly images?: ListingImagesBag;
|
|
128
|
+
/** Seed for a brand-new draft (a category preselected from the URL, say). */
|
|
129
|
+
readonly initialValues?: Partial<ListingDraftValues>;
|
|
130
|
+
onDraftCreated?: (draft: ListingDraft) => void;
|
|
131
|
+
onPublished?: (response: PublishResponse, outcome: PublishOutcome) => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ListingComposerBag {
|
|
135
|
+
readonly stage: ComposeStage;
|
|
136
|
+
readonly listingId: number | undefined;
|
|
137
|
+
readonly values: ListingDraftValues;
|
|
138
|
+
/** The listing being edited, when there is one. `loading` until it lands —
|
|
139
|
+
a composer must not let someone publish over a listing it has not read. */
|
|
140
|
+
readonly listingState: LoadState<ListingDetailData> | undefined;
|
|
141
|
+
/** Editing something already PUBLISHED: the submit becomes "send changes",
|
|
142
|
+
* and the listing stays visible throughout. */
|
|
143
|
+
readonly isLiveEdit: boolean;
|
|
144
|
+
/** The reopened draft came back empty because no read returns the draft
|
|
145
|
+
* twin — see this module's header. */
|
|
146
|
+
readonly draftNotReadable: boolean;
|
|
147
|
+
|
|
148
|
+
setValue<K extends keyof ListingDraftValues>(
|
|
149
|
+
key: K,
|
|
150
|
+
value: ListingDraftValues[K]
|
|
151
|
+
): void;
|
|
152
|
+
setLocation(location: ListingLocation): void;
|
|
153
|
+
setFeature(slug: string, value: unknown): void;
|
|
154
|
+
/** Changing category keeps the answers the new schema also asks for. */
|
|
155
|
+
setCategory(categoryId: string): void;
|
|
156
|
+
/** Slugs cleared by the last category change — named, not silently lost. */
|
|
157
|
+
readonly droppedOnCategoryChange: readonly string[];
|
|
158
|
+
|
|
159
|
+
/** The client mirror, always current. */
|
|
160
|
+
readonly mirror: Readonly<Record<string, FlowError>>;
|
|
161
|
+
/** What a control should actually show: the mirror once the person has
|
|
162
|
+
* tried to submit, overlaid by the server's verdict when there is one. */
|
|
163
|
+
readonly fieldErrors: Readonly<Record<string, FlowError>>;
|
|
164
|
+
/** The server's last publish refusal, unrouted, for a summary line. */
|
|
165
|
+
readonly refusal: PublishRefusal | undefined;
|
|
166
|
+
/** Value types this build cannot draw — the fact, from attributes-react. */
|
|
167
|
+
readonly unsupported: readonly string[];
|
|
168
|
+
|
|
169
|
+
readonly saveGate: ActionAvailability;
|
|
170
|
+
readonly publishGate: ActionAvailability;
|
|
171
|
+
save(): void;
|
|
172
|
+
publish(): void;
|
|
173
|
+
readonly saving: boolean;
|
|
174
|
+
readonly publishing: boolean;
|
|
175
|
+
readonly saved: boolean;
|
|
176
|
+
readonly saveError: unknown;
|
|
177
|
+
readonly outcome: PublishOutcome | undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function useListingComposer(
|
|
181
|
+
options: UseListingComposerOptions
|
|
182
|
+
): ListingComposerBag {
|
|
183
|
+
const runtime = useListingsRuntime();
|
|
184
|
+
const mandate = useMandateGate();
|
|
185
|
+
|
|
186
|
+
const [listingId, setListingId] = useState<number | undefined>(
|
|
187
|
+
options.listingId
|
|
188
|
+
);
|
|
189
|
+
const [values, setValues] = useState<ListingDraftValues>(() => ({
|
|
190
|
+
...emptyDraftValues({ currency: runtime.currency }),
|
|
191
|
+
...options.initialValues,
|
|
192
|
+
}));
|
|
193
|
+
const [showErrors, setShowErrors] = useState(false);
|
|
194
|
+
const [dropped, setDropped] = useState<readonly string[]>([]);
|
|
195
|
+
const [refusal, setRefusal] = useState<PublishRefusal | undefined>(undefined);
|
|
196
|
+
const [outcome, setOutcome] = useState<PublishOutcome | undefined>(undefined);
|
|
197
|
+
const [saved, setSaved] = useState(false);
|
|
198
|
+
|
|
199
|
+
const existing = useListing(options.listingId);
|
|
200
|
+
const createDraft = useCreateDraft();
|
|
201
|
+
const saveDraft = useSaveDraft();
|
|
202
|
+
const publishListing = usePublishListing();
|
|
203
|
+
|
|
204
|
+
// Seed once from the listing being edited. `seeded` is a ref rather than a
|
|
205
|
+
// dependency guard because re-seeding on a refetch would throw away
|
|
206
|
+
// everything typed since — a background invalidation must never rewrite a
|
|
207
|
+
// form somebody is in the middle of.
|
|
208
|
+
const seeded = useRef(false);
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (seeded.current) return;
|
|
211
|
+
const detail = existing.data;
|
|
212
|
+
if (detail === undefined) return;
|
|
213
|
+
seeded.current = true;
|
|
214
|
+
setListingId(detail.id);
|
|
215
|
+
setValues(
|
|
216
|
+
draftValuesFromDetail(
|
|
217
|
+
detail,
|
|
218
|
+
featuresDtoFromDaoList(asFeatureDaoList(detail.features)) as FeaturesDto,
|
|
219
|
+
{ currency: runtime.currency }
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
}, [existing.data, runtime.currency]);
|
|
223
|
+
|
|
224
|
+
// Pruning runs on the SCHEMA arriving, one render after the category
|
|
225
|
+
// changed: `setCategory` cannot prune against features it has not been
|
|
226
|
+
// handed. Gated on the schema being settled, because pruning against a
|
|
227
|
+
// half-loaded (or previous) category's features would delete answers that
|
|
228
|
+
// are perfectly valid for the one now chosen.
|
|
229
|
+
const schemaSettled =
|
|
230
|
+
options.featuresLoading !== true && options.featuresError === undefined;
|
|
231
|
+
const { features } = options;
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!schemaSettled || features.length === 0) return;
|
|
234
|
+
setValues((current) => {
|
|
235
|
+
const gone = droppedFeatureSlugs(current.features, features);
|
|
236
|
+
if (gone.length === 0) return current;
|
|
237
|
+
setDropped(gone);
|
|
238
|
+
return {
|
|
239
|
+
...current,
|
|
240
|
+
features: retainKnownFeatureValues(current.features, features),
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
}, [schemaSettled, features]);
|
|
244
|
+
|
|
245
|
+
// The gallery is the upload bag's, whenever there is one: two sources of
|
|
246
|
+
// truth for the same list is how a publish sends photos the person removed.
|
|
247
|
+
const images = options.images?.refs ?? values.images;
|
|
248
|
+
const effectiveValues: ListingDraftValues = useMemo(
|
|
249
|
+
() => ({ ...values, images }),
|
|
250
|
+
[values, images]
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const featuresDto = useMemo(
|
|
254
|
+
() => toFeaturesDto(options.features, effectiveValues.features),
|
|
255
|
+
[options.features, effectiveValues.features]
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const mirror = useMemo(
|
|
259
|
+
() =>
|
|
260
|
+
mirrorDraft(
|
|
261
|
+
effectiveValues,
|
|
262
|
+
options.features,
|
|
263
|
+
featuresDto,
|
|
264
|
+
runtime.limits
|
|
265
|
+
),
|
|
266
|
+
[effectiveValues, options.features, featuresDto, runtime.limits]
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const unsupported = useMemo(
|
|
270
|
+
() => unsupportedTypes(options.features, options.editorTypes ?? []),
|
|
271
|
+
[options.features, options.editorTypes]
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const isLiveEdit = existing.data?.status === "published";
|
|
275
|
+
const draftNotReadable =
|
|
276
|
+
existing.data !== undefined &&
|
|
277
|
+
existing.data.status !== "published" &&
|
|
278
|
+
(existing.data.title ?? "").length === 0 &&
|
|
279
|
+
(existing.data.description ?? "").length === 0;
|
|
280
|
+
|
|
281
|
+
const fieldErrors: Readonly<Record<string, FlowError>> = useMemo(() => {
|
|
282
|
+
const shown = showErrors ? mirror : {};
|
|
283
|
+
return refusal?.kind === "invalid_draft"
|
|
284
|
+
? { ...shown, ...refusal.fieldErrors }
|
|
285
|
+
: shown;
|
|
286
|
+
}, [showErrors, mirror, refusal]);
|
|
287
|
+
|
|
288
|
+
const listingState: LoadState<ListingDetailData> | undefined =
|
|
289
|
+
options.listingId === undefined
|
|
290
|
+
? undefined
|
|
291
|
+
: existing.status === "error"
|
|
292
|
+
? loadFailed(existing.error)
|
|
293
|
+
: existing.data !== undefined
|
|
294
|
+
? loadReady(existing.data)
|
|
295
|
+
: loadLoading();
|
|
296
|
+
|
|
297
|
+
const busy = createDraft.isPending || saveDraft.isPending;
|
|
298
|
+
|
|
299
|
+
const schemaGate: ActionAvailability =
|
|
300
|
+
options.featuresError !== undefined
|
|
301
|
+
? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedDetailsUnavailable)
|
|
302
|
+
: options.featuresLoading === true
|
|
303
|
+
? actionBlocked(LISTINGS_I18N_KEYS.composeDetailsLoading)
|
|
304
|
+
: actionAvailable();
|
|
305
|
+
|
|
306
|
+
const categoryGate: ActionAvailability =
|
|
307
|
+
effectiveValues.categoryId.length > 0
|
|
308
|
+
? actionAvailable()
|
|
309
|
+
: actionBlocked(LISTINGS_I18N_KEYS.composeBlockedNoCategory);
|
|
310
|
+
|
|
311
|
+
const busyGate: ActionAvailability = busy
|
|
312
|
+
? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedBusy)
|
|
313
|
+
: actionAvailable();
|
|
314
|
+
|
|
315
|
+
const saveGate = firstBlock(mandate, categoryGate, busyGate);
|
|
316
|
+
|
|
317
|
+
// The publish gate is the save gate PLUS everything that must be true for a
|
|
318
|
+
// submission to be complete. Order is the order a person would be told:
|
|
319
|
+
// may you act at all → is the form even answerable → are the photos in →
|
|
320
|
+
// is the form itself clean.
|
|
321
|
+
const publishGate = firstBlock(
|
|
322
|
+
mandate,
|
|
323
|
+
categoryGate,
|
|
324
|
+
schemaGate,
|
|
325
|
+
unsupported.length > 0
|
|
326
|
+
? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedUnsupportedType, {
|
|
327
|
+
types: unsupported.join(", "),
|
|
328
|
+
})
|
|
329
|
+
: actionAvailable(),
|
|
330
|
+
options.images?.settled ?? actionAvailable(),
|
|
331
|
+
busyGate,
|
|
332
|
+
publishListing.isPending
|
|
333
|
+
? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedBusy)
|
|
334
|
+
: actionAvailable(),
|
|
335
|
+
Object.keys(mirror).length > 0
|
|
336
|
+
? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedMirror)
|
|
337
|
+
: actionAvailable()
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
/** Create the row on demand: the id is what every other write needs. */
|
|
341
|
+
const ensureListingId = useCallback(async (): Promise<number | undefined> => {
|
|
342
|
+
if (listingId !== undefined && listingId > 0) return listingId;
|
|
343
|
+
if (effectiveValues.categoryId.length === 0) return undefined;
|
|
344
|
+
const draft = await createDraft.mutateAsync(
|
|
345
|
+
createDraftBody(effectiveValues.categoryId)
|
|
346
|
+
);
|
|
347
|
+
setListingId(draft.id);
|
|
348
|
+
options.onDraftCreated?.(draft);
|
|
349
|
+
return draft.id;
|
|
350
|
+
}, [listingId, effectiveValues.categoryId, createDraft, options]);
|
|
351
|
+
|
|
352
|
+
const persist = useCallback(async (): Promise<number | undefined> => {
|
|
353
|
+
const id = await ensureListingId();
|
|
354
|
+
if (id === undefined) return undefined;
|
|
355
|
+
// The id comes from `ensureListingId`, not from this closure's render:
|
|
356
|
+
// creating the row and saving into it happen in ONE gesture, and the
|
|
357
|
+
// state update that records the new id has not committed yet.
|
|
358
|
+
await saveDraft.mutateAsync({
|
|
359
|
+
id,
|
|
360
|
+
body: draftPatchFromValues(effectiveValues, options.features),
|
|
361
|
+
});
|
|
362
|
+
setSaved(true);
|
|
363
|
+
return id;
|
|
364
|
+
}, [ensureListingId, saveDraft, effectiveValues, options.features]);
|
|
365
|
+
|
|
366
|
+
const save = useCallback((): void => {
|
|
367
|
+
setShowErrors(true);
|
|
368
|
+
if (!saveGate.available) return;
|
|
369
|
+
setRefusal(undefined);
|
|
370
|
+
void persist().catch(() => {
|
|
371
|
+
// The mutation's own `error` carries it; swallowing the rejection here
|
|
372
|
+
// only stops an unhandled-rejection warning for a failure the bag
|
|
373
|
+
// already reports.
|
|
374
|
+
});
|
|
375
|
+
}, [saveGate.available, persist]);
|
|
376
|
+
|
|
377
|
+
const publish = useCallback((): void => {
|
|
378
|
+
setShowErrors(true);
|
|
379
|
+
if (!publishGate.available) return;
|
|
380
|
+
setRefusal(undefined);
|
|
381
|
+
void (async () => {
|
|
382
|
+
try {
|
|
383
|
+
// Always save first: publish reads the STORED draft, so publishing
|
|
384
|
+
// without saving would promote whatever was there before the last
|
|
385
|
+
// keystroke — and the person would be told their listing is fine
|
|
386
|
+
// while a field they just fixed is still wrong on the server.
|
|
387
|
+
const id = await persist();
|
|
388
|
+
if (id === undefined) return;
|
|
389
|
+
const response = await publishListing.mutateAsync(id);
|
|
390
|
+
const result: PublishOutcome =
|
|
391
|
+
response.status === "published"
|
|
392
|
+
? "live_edit_under_review"
|
|
393
|
+
: "submitted_for_review";
|
|
394
|
+
setOutcome(result);
|
|
395
|
+
options.onPublished?.(response, result);
|
|
396
|
+
} catch (thrown) {
|
|
397
|
+
setRefusal(publishRefusal(thrown));
|
|
398
|
+
}
|
|
399
|
+
})();
|
|
400
|
+
}, [publishGate.available, persist, publishListing, options]);
|
|
401
|
+
|
|
402
|
+
const stage: ComposeStage =
|
|
403
|
+
outcome !== undefined
|
|
404
|
+
? "published"
|
|
405
|
+
: publishListing.isPending
|
|
406
|
+
? "publishing"
|
|
407
|
+
: busy
|
|
408
|
+
? "saving"
|
|
409
|
+
: effectiveValues.categoryId.length === 0
|
|
410
|
+
? "choosing_category"
|
|
411
|
+
: "editing";
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
stage,
|
|
415
|
+
listingId,
|
|
416
|
+
values: effectiveValues,
|
|
417
|
+
listingState,
|
|
418
|
+
isLiveEdit,
|
|
419
|
+
draftNotReadable,
|
|
420
|
+
|
|
421
|
+
setValue: (key, value) => {
|
|
422
|
+
setSaved(false);
|
|
423
|
+
setValues((current) => ({ ...current, [key]: value }));
|
|
424
|
+
},
|
|
425
|
+
setLocation: (location) => {
|
|
426
|
+
setSaved(false);
|
|
427
|
+
setValues((current) => ({ ...current, location }));
|
|
428
|
+
},
|
|
429
|
+
setFeature: (slug, value) => {
|
|
430
|
+
setSaved(false);
|
|
431
|
+
setValues((current) => ({
|
|
432
|
+
...current,
|
|
433
|
+
features: { ...current.features, [slug]: value },
|
|
434
|
+
}));
|
|
435
|
+
},
|
|
436
|
+
setCategory: (categoryId) => {
|
|
437
|
+
setSaved(false);
|
|
438
|
+
setValues((current) => {
|
|
439
|
+
if (current.categoryId === categoryId) return current;
|
|
440
|
+
// The features of the NEW category are not known yet (the container's
|
|
441
|
+
// schema read has not run), so nothing is pruned here — pruning
|
|
442
|
+
// happens in the effect below, once they arrive. Recording the
|
|
443
|
+
// intent, not guessing the outcome.
|
|
444
|
+
return { ...current, categoryId };
|
|
445
|
+
});
|
|
446
|
+
setDropped([]);
|
|
447
|
+
setRefusal(undefined);
|
|
448
|
+
},
|
|
449
|
+
droppedOnCategoryChange: dropped,
|
|
450
|
+
|
|
451
|
+
mirror,
|
|
452
|
+
fieldErrors,
|
|
453
|
+
refusal,
|
|
454
|
+
unsupported,
|
|
455
|
+
|
|
456
|
+
saveGate,
|
|
457
|
+
publishGate,
|
|
458
|
+
save,
|
|
459
|
+
publish,
|
|
460
|
+
saving: busy,
|
|
461
|
+
publishing: publishListing.isPending,
|
|
462
|
+
saved,
|
|
463
|
+
saveError: saveDraft.error ?? createDraft.error,
|
|
464
|
+
outcome,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Renderless: the bag, handed to a render prop. */
|
|
469
|
+
export function ListingComposer(
|
|
470
|
+
props: UseListingComposerOptions & {
|
|
471
|
+
children: (bag: ListingComposerBag) => ReactNode;
|
|
472
|
+
}
|
|
473
|
+
): ReactElement {
|
|
474
|
+
const bag = useListingComposer(props);
|
|
475
|
+
return <>{props.children(bag)}</>;
|
|
476
|
+
}
|