@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,231 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
actionAvailable,
|
|
5
|
+
actionBlocked,
|
|
6
|
+
errorStatus,
|
|
7
|
+
firstBlock,
|
|
8
|
+
loadFailed,
|
|
9
|
+
loadLoading,
|
|
10
|
+
loadReady,
|
|
11
|
+
} from "@stapel/core";
|
|
12
|
+
import type { ActionAvailability, LoadState } from "@stapel/core";
|
|
13
|
+
import type {
|
|
14
|
+
ListingDetail as ListingDetailData,
|
|
15
|
+
ListingFeatureView,
|
|
16
|
+
ListingStatusInfo,
|
|
17
|
+
} from "../api/types.js";
|
|
18
|
+
import { useListing, useListingStatus } from "../model/queries.js";
|
|
19
|
+
import { useFavoriteListing } from "../model/mutations.js";
|
|
20
|
+
import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from "../model/features.js";
|
|
21
|
+
import { listingStatusView } from "../model/status.js";
|
|
22
|
+
import type { ListingStatusView } from "../model/status.js";
|
|
23
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
24
|
+
import { useMandateGate } from "./useMandateGate.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `images` is `string[] | NULL` on the wire, and the null is the SERVER
|
|
28
|
+
* saying "this listing has no photos" — not this pair saying "we do not
|
|
29
|
+
* know". Whether the read landed at all is carried by `state`, one field
|
|
30
|
+
* over, so collapsing the null here loses nothing: a named constant makes
|
|
31
|
+
* that argument visible where a bare `?? []` would look like the flattening
|
|
32
|
+
* `no-flattened-load-state` bans.
|
|
33
|
+
*/
|
|
34
|
+
const NO_IMAGES: readonly string[] = [];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Everything a listing page renders, with the two absences it must be able to
|
|
38
|
+
* tell apart.
|
|
39
|
+
*
|
|
40
|
+
* ── Why a second read runs beside the detail ───────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* `GET /listings/{pk}/` reads `Listing.objects`, which excludes soft-deleted
|
|
43
|
+
* rows: a removed listing answers 404, and a 404 is also what a made-up id
|
|
44
|
+
* answers. `GET /listings/{pk}/status/` reads `Listing.all_objects` and is
|
|
45
|
+
* `AllowAny`, so it still answers for the removed one. Running both is what
|
|
46
|
+
* turns "there is no listing at this address" and "this listing was removed"
|
|
47
|
+
* into two different sentences — and the second is the one a person following
|
|
48
|
+
* a stale link actually needs.
|
|
49
|
+
*
|
|
50
|
+
* The probe is not a fallback fired after a failure: it runs in parallel, so
|
|
51
|
+
* the page resolves in one round trip rather than two sequential ones.
|
|
52
|
+
*
|
|
53
|
+
* ── Visibility is reported, not assumed ────────────────────────────────────
|
|
54
|
+
*
|
|
55
|
+
* The detail endpoint has NO `published()` filter on its queryset, so a draft
|
|
56
|
+
* and a taken-down listing both answer 200 to anyone holding the id. The bag
|
|
57
|
+
* therefore carries `publiclyVisible` (computed from `status`, the one field
|
|
58
|
+
* that decides it) and `viewerIsOwner`, and a skin says which of the two
|
|
59
|
+
* situations it is in. Rendering a draft as a live shop page would be the
|
|
60
|
+
* pair repeating the server's omission instead of covering it.
|
|
61
|
+
*/
|
|
62
|
+
export interface ListingDetailBag {
|
|
63
|
+
readonly id: number;
|
|
64
|
+
readonly state: LoadState<ListingDetailData>;
|
|
65
|
+
/** The status probe, which answers for rows the detail cannot see. */
|
|
66
|
+
readonly statusState: LoadState<ListingStatusInfo>;
|
|
67
|
+
/** True when the detail 404s AND the probe says the row is soft-deleted —
|
|
68
|
+
* i.e. it existed and is gone, rather than never having existed. */
|
|
69
|
+
readonly removed: boolean;
|
|
70
|
+
/** The detail 404s and the probe finds nothing either. */
|
|
71
|
+
readonly notFound: boolean;
|
|
72
|
+
/** Both axes plus the sentence that comes out of the pair of them. */
|
|
73
|
+
readonly status: ListingStatusView | undefined;
|
|
74
|
+
readonly publiclyVisible: boolean;
|
|
75
|
+
/** Whether the person reading this is the owner, when both the probe and
|
|
76
|
+
* the viewer's id are known. `undefined` means "we did not ask" — a public
|
|
77
|
+
* page has no reason to. */
|
|
78
|
+
readonly viewerIsOwner: boolean | undefined;
|
|
79
|
+
/** The ordered specs table. */
|
|
80
|
+
readonly features: readonly ListingFeatureView[];
|
|
81
|
+
/** The `show_at_title` subset — what a heading appends. */
|
|
82
|
+
readonly titleFeatures: readonly ListingFeatureView[];
|
|
83
|
+
/** The `show_as_badge` subset — what a card shows. */
|
|
84
|
+
readonly badgeFeatures: readonly ListingFeatureView[];
|
|
85
|
+
/** Stored rows this build could not key. Reported, never rounded to zero. */
|
|
86
|
+
readonly unreadableFeatures: number;
|
|
87
|
+
/** Ordered CDN references. Resolving one to an image is the runtime's job
|
|
88
|
+
* (`resolveImage`), because no contract in this fleet resolves a
|
|
89
|
+
* stranger's reference — see `model/runtime.ts`. */
|
|
90
|
+
readonly images: readonly string[];
|
|
91
|
+
readonly isFavorited: boolean | undefined;
|
|
92
|
+
/** Saving a favourite needs a mandate; the block says which of the four
|
|
93
|
+
* reasons applies. */
|
|
94
|
+
readonly favoriteGate: ActionAvailability;
|
|
95
|
+
toggleFavorite(): void;
|
|
96
|
+
readonly favoriteInFlight: boolean;
|
|
97
|
+
refetch(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface UseListingDetailOptions {
|
|
101
|
+
/** The reader's own id, when the host knows it — enables `viewerIsOwner`.
|
|
102
|
+
* A uuid string, matching `ListingDetail.owner` / `ListingStatus.owner_id`. */
|
|
103
|
+
readonly viewerId?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function useListingDetail(
|
|
107
|
+
id: number,
|
|
108
|
+
options: UseListingDetailOptions = {}
|
|
109
|
+
): ListingDetailBag {
|
|
110
|
+
const detail = useListing(id);
|
|
111
|
+
const probe = useListingStatus(id);
|
|
112
|
+
const favorite = useFavoriteListing();
|
|
113
|
+
const mandate = useMandateGate();
|
|
114
|
+
|
|
115
|
+
const state: LoadState<ListingDetailData> =
|
|
116
|
+
detail.status === "error"
|
|
117
|
+
? loadFailed(detail.error)
|
|
118
|
+
: detail.data !== undefined
|
|
119
|
+
? loadReady(detail.data)
|
|
120
|
+
: loadLoading();
|
|
121
|
+
|
|
122
|
+
const statusState: LoadState<ListingStatusInfo> =
|
|
123
|
+
probe.status === "error"
|
|
124
|
+
? loadFailed(probe.error)
|
|
125
|
+
: probe.data !== undefined
|
|
126
|
+
? loadReady(probe.data)
|
|
127
|
+
: loadLoading();
|
|
128
|
+
|
|
129
|
+
const detailIs404 =
|
|
130
|
+
detail.status === "error" && errorStatus(detail.error) === 404;
|
|
131
|
+
const removed = detailIs404 && probe.data?.is_deleted === true;
|
|
132
|
+
const notFound = detailIs404 && !removed && probe.status === "error";
|
|
133
|
+
|
|
134
|
+
const status: ListingStatusView | undefined = useMemo(() => {
|
|
135
|
+
if (detail.data !== undefined) {
|
|
136
|
+
return listingStatusView(
|
|
137
|
+
detail.data.status ?? "draft",
|
|
138
|
+
detail.data.moderation_status ?? "pending"
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
// The probe still knows both axes for a row the detail cannot return —
|
|
142
|
+
// `moderation_status` arrives as a bare string there, so it is narrowed
|
|
143
|
+
// by the same table rather than trusted.
|
|
144
|
+
if (probe.data !== undefined) {
|
|
145
|
+
const moderation = probe.data.moderation_status;
|
|
146
|
+
return listingStatusView(
|
|
147
|
+
probe.data.status,
|
|
148
|
+
moderation === "approved" ||
|
|
149
|
+
moderation === "rejected" ||
|
|
150
|
+
moderation === "needs_review"
|
|
151
|
+
? moderation
|
|
152
|
+
: "pending"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}, [detail.data, probe.data]);
|
|
157
|
+
|
|
158
|
+
const owner = detail.data?.owner ?? probe.data?.owner_id;
|
|
159
|
+
const viewerIsOwner =
|
|
160
|
+
options.viewerId === undefined || owner === undefined
|
|
161
|
+
? undefined
|
|
162
|
+
: owner === options.viewerId;
|
|
163
|
+
|
|
164
|
+
const features = useMemo(
|
|
165
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features)),
|
|
166
|
+
[detail.data]
|
|
167
|
+
);
|
|
168
|
+
const titleFeatures = useMemo(
|
|
169
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title)),
|
|
170
|
+
[detail.data]
|
|
171
|
+
);
|
|
172
|
+
const badgeFeatures = useMemo(
|
|
173
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges)),
|
|
174
|
+
[detail.data]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const isFavorited = detail.data?.is_favorited ?? undefined;
|
|
178
|
+
|
|
179
|
+
const favoriteGate = firstBlock(
|
|
180
|
+
mandate,
|
|
181
|
+
favorite.isPending
|
|
182
|
+
? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
|
|
183
|
+
: actionAvailable(),
|
|
184
|
+
detail.data === undefined
|
|
185
|
+
? actionBlocked(LISTINGS_I18N_KEYS.detailLoading)
|
|
186
|
+
: actionAvailable()
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
id,
|
|
191
|
+
state,
|
|
192
|
+
statusState,
|
|
193
|
+
removed,
|
|
194
|
+
notFound,
|
|
195
|
+
status,
|
|
196
|
+
publiclyVisible: status?.lifecycle.publiclyVisible ?? false,
|
|
197
|
+
viewerIsOwner,
|
|
198
|
+
features,
|
|
199
|
+
titleFeatures,
|
|
200
|
+
badgeFeatures,
|
|
201
|
+
unreadableFeatures: unreadableFeatureCount(
|
|
202
|
+
asFeatureDaoList(detail.data?.features)
|
|
203
|
+
),
|
|
204
|
+
images: detail.data?.images ?? NO_IMAGES,
|
|
205
|
+
isFavorited,
|
|
206
|
+
favoriteGate,
|
|
207
|
+
toggleFavorite: () => {
|
|
208
|
+
if (!favoriteGate.available) return;
|
|
209
|
+
favorite.mutate({ id, favorited: isFavorited !== true });
|
|
210
|
+
},
|
|
211
|
+
favoriteInFlight: favorite.isPending,
|
|
212
|
+
refetch: () => {
|
|
213
|
+
void detail.refetch();
|
|
214
|
+
void probe.refetch();
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Renderless: the bag, handed to a render prop. Bring your own visuals, or
|
|
220
|
+
* import `<ListingDetailPane>` from `./default`. */
|
|
221
|
+
export function ListingDetail(props: {
|
|
222
|
+
id: number;
|
|
223
|
+
viewerId?: string;
|
|
224
|
+
children: (bag: ListingDetailBag) => ReactNode;
|
|
225
|
+
}): ReactElement {
|
|
226
|
+
const bag = useListingDetail(
|
|
227
|
+
props.id,
|
|
228
|
+
props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
|
|
229
|
+
);
|
|
230
|
+
return <>{props.children(bag)}</>;
|
|
231
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { ModuleProvider } from "../model/context.js";
|
|
3
|
+
import type { ListingsRuntime } from "../model/runtime.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renderless provider — puts one wired {@link ListingsRuntime} in context for
|
|
7
|
+
* every hook and headless component in the pair. Bring your own visual shell.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <ListingsProvider runtime={createListingsRuntime({ baseUrl: "/listings/api/v1/" })}>
|
|
11
|
+
* <YourShopWindow />
|
|
12
|
+
* </ListingsProvider>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export function ListingsProvider(props: {
|
|
16
|
+
runtime: ListingsRuntime;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): ReactElement {
|
|
19
|
+
return <ModuleProvider runtime={props.runtime}>{props.children}</ModuleProvider>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import type { ReactElement, ReactNode } from "react";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import {
|
|
5
|
+
actionAvailable,
|
|
6
|
+
actionBlocked,
|
|
7
|
+
loadFailed,
|
|
8
|
+
loadLoading,
|
|
9
|
+
loadReady,
|
|
10
|
+
useActiveSessionReady,
|
|
11
|
+
} from "@stapel/core";
|
|
12
|
+
import type { ActionAvailability, LoadState } from "@stapel/core";
|
|
13
|
+
import type {
|
|
14
|
+
ListingCard,
|
|
15
|
+
ListingPageParams,
|
|
16
|
+
MyCounters,
|
|
17
|
+
} from "../api/types.js";
|
|
18
|
+
import { MY_LISTINGS_SOURCE_MISSING } from "../model/mineSource.js";
|
|
19
|
+
import type { MyListingsSource } from "../model/mineSource.js";
|
|
20
|
+
import { useMyCounters } from "../model/queries.js";
|
|
21
|
+
import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
|
|
22
|
+
import { MY_LISTINGS_TABS } from "../model/status.js";
|
|
23
|
+
import type { MyListingsTab } from "../model/status.js";
|
|
24
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
25
|
+
import { useMandateGate } from "./useMandateGate.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The owner's dashboard — and the one screen in this pair whose rows the
|
|
29
|
+
* backend cannot supply.
|
|
30
|
+
*
|
|
31
|
+
* The counters are REAL and are shown. The rows come from an injected
|
|
32
|
+
* `MyListingsSource`, and when there is none `rows` lands in the `failed` arm
|
|
33
|
+
* carrying a NAMED reason rather than as an empty list. `model/mineSource.ts`
|
|
34
|
+
* holds the whole argument and the upstream asks; this file is the bag over
|
|
35
|
+
* it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface MyListingsBag {
|
|
39
|
+
readonly tab: MyListingsTab;
|
|
40
|
+
readonly tabs: readonly MyListingsTab[];
|
|
41
|
+
setTab(tab: MyListingsTab): void;
|
|
42
|
+
/** The three real counts. */
|
|
43
|
+
readonly counters: LoadState<MyCounters>;
|
|
44
|
+
/** The rows for the current tab. `failed` with a named reason when no
|
|
45
|
+
* source is wired — never an empty list. */
|
|
46
|
+
readonly rows: LoadState<readonly ListingCard[]>;
|
|
47
|
+
readonly page: ListingPageParams;
|
|
48
|
+
readonly nextPage: ActionAvailability;
|
|
49
|
+
readonly prevPage: ActionAvailability;
|
|
50
|
+
goNext(): void;
|
|
51
|
+
goPrev(): void;
|
|
52
|
+
/** Whether the person may see this screen at all. */
|
|
53
|
+
readonly gate: ActionAvailability;
|
|
54
|
+
refetch(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UseMyListingsOptions {
|
|
58
|
+
readonly source?: MyListingsSource;
|
|
59
|
+
readonly initialTab?: MyListingsTab;
|
|
60
|
+
readonly limit?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useMyListings(
|
|
64
|
+
options: UseMyListingsOptions = {}
|
|
65
|
+
): MyListingsBag {
|
|
66
|
+
const gate = useMandateGate();
|
|
67
|
+
const sessionReady = useActiveSessionReady();
|
|
68
|
+
const [tab, setTabState] = useState<MyListingsTab>(
|
|
69
|
+
options.initialTab ?? "active"
|
|
70
|
+
);
|
|
71
|
+
const [page, setPage] = useState<ListingPageParams>(
|
|
72
|
+
options.limit !== undefined ? { limit: options.limit } : {}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const counters = useMyCounters();
|
|
76
|
+
const { source } = options;
|
|
77
|
+
|
|
78
|
+
const rowsQuery = useQuery({
|
|
79
|
+
queryKey: listingsQueryKeys.mine(tab, pageKey(page)),
|
|
80
|
+
queryFn: ({ signal }) =>
|
|
81
|
+
(source as MyListingsSource)({ tab, page, signal }),
|
|
82
|
+
enabled: source !== undefined && sessionReady && gate.available,
|
|
83
|
+
retry: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const rows: LoadState<readonly ListingCard[]> = useMemo(() => {
|
|
87
|
+
if (source === undefined) return loadFailed(MY_LISTINGS_SOURCE_MISSING);
|
|
88
|
+
if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
|
|
89
|
+
if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
|
|
90
|
+
return loadLoading();
|
|
91
|
+
}, [source, rowsQuery.status, rowsQuery.error, rowsQuery.data]);
|
|
92
|
+
|
|
93
|
+
const envelope = rowsQuery.data;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
tab,
|
|
97
|
+
tabs: MY_LISTINGS_TABS,
|
|
98
|
+
setTab: (next) => {
|
|
99
|
+
// A cursor belongs to ONE ordered candidate set. Carried across a tab
|
|
100
|
+
// change it either bounces or honestly returns page four of a
|
|
101
|
+
// different list — the same property `@stapel/search-react` writes
|
|
102
|
+
// down for its own keyset state.
|
|
103
|
+
setPage(options.limit !== undefined ? { limit: options.limit } : {});
|
|
104
|
+
setTabState(next);
|
|
105
|
+
},
|
|
106
|
+
counters:
|
|
107
|
+
counters.status === "error"
|
|
108
|
+
? loadFailed(counters.error)
|
|
109
|
+
: counters.data !== undefined
|
|
110
|
+
? loadReady(counters.data)
|
|
111
|
+
: loadLoading(),
|
|
112
|
+
rows,
|
|
113
|
+
page,
|
|
114
|
+
nextPage:
|
|
115
|
+
envelope?.has_next === true && envelope.next_anchor != null
|
|
116
|
+
? actionAvailable()
|
|
117
|
+
: actionBlocked(LISTINGS_I18N_KEYS.pageNext),
|
|
118
|
+
prevPage:
|
|
119
|
+
envelope?.has_prev === true && envelope.prev_anchor != null
|
|
120
|
+
? actionAvailable()
|
|
121
|
+
: actionBlocked(LISTINGS_I18N_KEYS.pagePrev),
|
|
122
|
+
goNext: () => {
|
|
123
|
+
const anchor = envelope?.next_anchor;
|
|
124
|
+
if (anchor == null) return;
|
|
125
|
+
setPage((current) => ({ ...current, anchor, direction: "next" }));
|
|
126
|
+
},
|
|
127
|
+
goPrev: () => {
|
|
128
|
+
const anchor = envelope?.prev_anchor;
|
|
129
|
+
if (anchor == null) return;
|
|
130
|
+
setPage((current) => ({ ...current, anchor, direction: "prev" }));
|
|
131
|
+
},
|
|
132
|
+
gate,
|
|
133
|
+
refetch: () => {
|
|
134
|
+
void counters.refetch();
|
|
135
|
+
void rowsQuery.refetch();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Renderless: the bag, handed to a render prop. */
|
|
141
|
+
export function MyListings(
|
|
142
|
+
props: UseMyListingsOptions & {
|
|
143
|
+
children: (bag: MyListingsBag) => ReactNode;
|
|
144
|
+
}
|
|
145
|
+
): ReactElement {
|
|
146
|
+
const bag = useMyListings(props);
|
|
147
|
+
return <>{props.children(bag)}</>;
|
|
148
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { matchMandate, useMandate } from "@stapel/core";
|
|
2
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
3
|
+
import { actionAvailable, actionBlocked } from "@stapel/core";
|
|
4
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* "May this person act at all?" — the first gate on every write in the pair.
|
|
8
|
+
*
|
|
9
|
+
* Posting a listing, saving a draft, marking something sold and saving a
|
|
10
|
+
* favourite are all `IsAuthenticated`. A visitor meeting one of them must be
|
|
11
|
+
* told to sign in, WITH the reason and with a way to do it: a hidden button
|
|
12
|
+
* teaches nothing (private-space canon §6.3), and a 401 after the click
|
|
13
|
+
* teaches it too late.
|
|
14
|
+
*
|
|
15
|
+
* The axis is read through core's `MandateSource` seam, never derived here. A
|
|
16
|
+
* storefront's derivation is "is there a session?"; a tenant app's is
|
|
17
|
+
* `@stapel/workspaces-react`'s. This pair asks and does not care which.
|
|
18
|
+
* `matchMandate` has five required arms, so the two `unresolved` outcomes
|
|
19
|
+
* cannot fall into the refusal's branch by omission:
|
|
20
|
+
*
|
|
21
|
+
* - `asking` — we have not finished asking. The control waits; it does not
|
|
22
|
+
* say "you may not".
|
|
23
|
+
* - `unavailable` — we COULD NOT ask. Also not "you may not": the storefront
|
|
24
|
+
* spec's own negative leg (§7.4) is that `/me` answering 503
|
|
25
|
+
* must not render as a refusal.
|
|
26
|
+
*
|
|
27
|
+
* Outside a `<MandateProvider>` core answers `unresolved/unavailable` rather
|
|
28
|
+
* than throwing, so a pair rendered in a host that never wired the axis
|
|
29
|
+
* degrades to "we could not check" — visible, fixable, and not a blank page.
|
|
30
|
+
*/
|
|
31
|
+
export function useMandateGate(): ActionAvailability {
|
|
32
|
+
const mandate = useMandate();
|
|
33
|
+
return matchMandate<ActionAvailability>(mandate, {
|
|
34
|
+
member: () => actionAvailable(),
|
|
35
|
+
guest: () => actionBlocked(LISTINGS_I18N_KEYS.blockedGuest),
|
|
36
|
+
anonymous: () => actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn),
|
|
37
|
+
asking: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
|
|
38
|
+
unavailable: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pair's error map (frontend-standard §4 checklist #7, frontend-core
|
|
3
|
+
* §2.5): the generated `code → { status, params, remediation, en }` catalog
|
|
4
|
+
* plus a tiny `explain()` lookup. Backs the manifest `errors` block and gives
|
|
5
|
+
* hosts a mechanical UX branch beside `t(code, params)`. The map itself is
|
|
6
|
+
* generated from the backend registry (`pnpm gen:errors`); this file only
|
|
7
|
+
* adds the lookup helper, names the three refusals a storefront actually
|
|
8
|
+
* meets, and re-exports the public surface.
|
|
9
|
+
*/
|
|
10
|
+
import { LISTINGS_ERRORS } from "./generated/errors.gen.js";
|
|
11
|
+
import type { Remediation } from "./generated/errors.gen.js";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
LISTINGS_ERRORS,
|
|
15
|
+
LISTINGS_ERROR_CODES,
|
|
16
|
+
listingsErrorBundleEn,
|
|
17
|
+
} from "./generated/errors.gen.js";
|
|
18
|
+
export type {
|
|
19
|
+
ListingsErrorCode,
|
|
20
|
+
ListingsErrorSpec,
|
|
21
|
+
Remediation,
|
|
22
|
+
} from "./generated/errors.gen.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The lifecycle refusal, and the one code in this registry that carries the
|
|
26
|
+
* information needed to explain itself: `params.from_status` is the state the
|
|
27
|
+
* server refused to move OUT of (`views._transition`). A skin renders
|
|
28
|
+
* "a listing that is sold cannot be archived that way", not "conflict".
|
|
29
|
+
*/
|
|
30
|
+
export const LISTING_INVALID_TRANSITION = "error.409.invalid_listing_transition";
|
|
31
|
+
|
|
32
|
+
/** Deleting something that is on sale. The remedy is in the sentence:
|
|
33
|
+
* archive it first. */
|
|
34
|
+
export const LISTING_CANNOT_DELETE_ACTIVE =
|
|
35
|
+
"error.409.listing_cannot_delete_active";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The publish refusal that is NOT a per-field verdict.
|
|
39
|
+
*
|
|
40
|
+
* `publish` answers an invalid draft with a bare `ValidationBatchResult`; this
|
|
41
|
+
* code is what it answers when the PROMOTION fails afterwards — today that
|
|
42
|
+
* means `REQUIRE_IMAGE_ON_PUBLISH` with an empty gallery. Two 400s, two
|
|
43
|
+
* meanings; `model/validation.ts`'s `publishRefusal` is the one place that
|
|
44
|
+
* tells them apart.
|
|
45
|
+
*/
|
|
46
|
+
export const LISTING_PUBLISH_VALIDATION_FAILED =
|
|
47
|
+
"error.400.publish_validation_failed";
|
|
48
|
+
|
|
49
|
+
/** Somebody else's listing. Every owner operation routes through
|
|
50
|
+
* `views._get_own` — except `PUT`/`PATCH`, which is why this pair does not
|
|
51
|
+
* call them (`api/listingsApi.ts`). */
|
|
52
|
+
export const LISTING_NOT_OWNER = "error.403.listing_not_owner";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve a backend error code to its remediation hint, or `undefined` for a
|
|
56
|
+
* code this module doesn't know (e.g. a cross-cutting `stapel.http.*`
|
|
57
|
+
* fallback). Zero guessing at runtime — a static lookup over the generated map.
|
|
58
|
+
*/
|
|
59
|
+
export function explainListingsError(code: string): Remediation | undefined {
|
|
60
|
+
return (LISTINGS_ERRORS as Record<string, { remediation: Remediation }>)[code]
|
|
61
|
+
?.remediation;
|
|
62
|
+
}
|