@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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ListingCard>` — the one component of this pair that another pair renders.
|
|
3
|
+
*
|
|
4
|
+
* `@stapel/search-react` takes a `renderCard` slot and the container fills it
|
|
5
|
+
* with this (spec §3.7 / §6.2 item 1). The two pairs never import each other;
|
|
6
|
+
* the CONTAINER is the seam, which is why this component takes a plain card
|
|
7
|
+
* row and a plain `href`/`onOpen` rather than reaching for a router.
|
|
8
|
+
*
|
|
9
|
+
* ── What it renders without asking the server anything else ────────────────
|
|
10
|
+
*
|
|
11
|
+
* Badges. `features_badges` is a stored DAO projection, and a DAO carries the
|
|
12
|
+
* display config beside the value, so `formatFeatureValue` can render
|
|
13
|
+
* "1200 W" from the row alone — no category fetch, no second request per
|
|
14
|
+
* card. That property is the whole reason the projection exists, and it is
|
|
15
|
+
* what makes a grid of forty cards cost one query.
|
|
16
|
+
*
|
|
17
|
+
* ── The heart is never hidden ──────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* A visitor sees it, blocked, with the reason and the sign-in CTA the
|
|
20
|
+
* container attaches (`?next=`). Hiding it would teach nobody that
|
|
21
|
+
* favourites exist (private-space canon §6.3, spec §6.2 item 6).
|
|
22
|
+
*/
|
|
23
|
+
import type { ReactElement, ReactNode } from "react";
|
|
24
|
+
import { Button, Card, Flex, Tooltip, Typography } from "antd";
|
|
25
|
+
import { useT } from "@stapel/core";
|
|
26
|
+
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
27
|
+
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
28
|
+
import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
29
|
+
import { lifecycleCaption } from "../model/status.js";
|
|
30
|
+
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
31
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
32
|
+
import { HeartIcon } from "./icons.js";
|
|
33
|
+
import { ListingPhoto } from "./ListingPhoto.js";
|
|
34
|
+
import { ListingsSkinTheme } from "./theme.js";
|
|
35
|
+
import type { ThemeModeProp } from "./types.js";
|
|
36
|
+
|
|
37
|
+
export interface ListingCardProps extends ThemeModeProp {
|
|
38
|
+
readonly listing: ListingCardData;
|
|
39
|
+
/** Where the card leads. The container owns routing, so this is a plain
|
|
40
|
+
* href a `<Link>` or an `<a>` can carry — the pair never calls
|
|
41
|
+
* `window.location`. */
|
|
42
|
+
readonly href?: string;
|
|
43
|
+
/** Called instead of following `href`, for a host with its own navigation. */
|
|
44
|
+
readonly onOpen?: (id: number) => void;
|
|
45
|
+
/** Extra chrome the container adds (a `promoted` tag from search, say —
|
|
46
|
+
* DSA Art. 26 marking belongs to the pair that receives it). */
|
|
47
|
+
readonly badge?: ReactNode;
|
|
48
|
+
/** Hide the favourite control entirely — for a context where it makes no
|
|
49
|
+
* sense (the owner's own dashboard). NOT a way to hide it from visitors. */
|
|
50
|
+
readonly showFavorite?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ListingCard(props: ListingCardProps): ReactElement {
|
|
54
|
+
const t = useT();
|
|
55
|
+
const { listing } = props;
|
|
56
|
+
const favorite = useFavoriteToggle(listing.id, listing.is_favorited);
|
|
57
|
+
|
|
58
|
+
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
59
|
+
const badgeFeatures = featuresFromDaoList(badgeDaos);
|
|
60
|
+
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
61
|
+
const titleDaos = asFeatureDaoList(listing.features_title);
|
|
62
|
+
|
|
63
|
+
const status = listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
64
|
+
const price =
|
|
65
|
+
listing.price !== undefined && listing.price.length > 0
|
|
66
|
+
? `${listing.price} ${listing.currency ?? ""}`.trim()
|
|
67
|
+
: t(LISTINGS_I18N_KEYS.cardPriceAbsent);
|
|
68
|
+
|
|
69
|
+
const favoriteLabel = t(
|
|
70
|
+
favorite.favorited
|
|
71
|
+
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
72
|
+
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
77
|
+
<Card
|
|
78
|
+
size="small"
|
|
79
|
+
data-testid="listings-card"
|
|
80
|
+
data-listing-id={listing.id}
|
|
81
|
+
{...(status !== undefined
|
|
82
|
+
? { "data-listing-status": status.status }
|
|
83
|
+
: {})}
|
|
84
|
+
cover={
|
|
85
|
+
<ListingPhoto
|
|
86
|
+
imageRef={listing.images?.[0]}
|
|
87
|
+
alt={listing.title ?? String(listing.id)}
|
|
88
|
+
style={{ aspectRatio: "4 / 3" }}
|
|
89
|
+
/>
|
|
90
|
+
}
|
|
91
|
+
>
|
|
92
|
+
<Flex vertical gap={4}>
|
|
93
|
+
{props.badge}
|
|
94
|
+
|
|
95
|
+
<Typography.Text strong data-testid="listings-card-price">
|
|
96
|
+
{price}
|
|
97
|
+
</Typography.Text>
|
|
98
|
+
|
|
99
|
+
<Typography.Text ellipsis data-testid="listings-card-title">
|
|
100
|
+
{listing.title ?? ""}
|
|
101
|
+
</Typography.Text>
|
|
102
|
+
|
|
103
|
+
{/* The title features are a stored projection too — the seller's
|
|
104
|
+
"1.5 TB, black" line, already ordered by the server. */}
|
|
105
|
+
{titleDaos.length > 0 ? (
|
|
106
|
+
<Typography.Text type="secondary" ellipsis>
|
|
107
|
+
<FeatureBadges
|
|
108
|
+
features={featuresFromDaoList(titleDaos)
|
|
109
|
+
.map((view) => view.feature)}
|
|
110
|
+
values={featuresDtoFromDaoList(titleDaos)}
|
|
111
|
+
/>
|
|
112
|
+
</Typography.Text>
|
|
113
|
+
) : null}
|
|
114
|
+
|
|
115
|
+
{badgeFeatures.length > 0 ? (
|
|
116
|
+
<FeatureBadges
|
|
117
|
+
features={badgeFeatures.map((view) => view.feature)}
|
|
118
|
+
values={badgeValues}
|
|
119
|
+
/>
|
|
120
|
+
) : null}
|
|
121
|
+
|
|
122
|
+
{listing.location_label !== undefined &&
|
|
123
|
+
listing.location_label.length > 0 ? (
|
|
124
|
+
<Typography.Text type="secondary" data-testid="listings-card-location">
|
|
125
|
+
{listing.location_label}
|
|
126
|
+
</Typography.Text>
|
|
127
|
+
) : null}
|
|
128
|
+
|
|
129
|
+
<Flex gap={8} align="center">
|
|
130
|
+
{props.href !== undefined || props.onOpen !== undefined ? (
|
|
131
|
+
<Button
|
|
132
|
+
size="small"
|
|
133
|
+
type="link"
|
|
134
|
+
{...(props.href !== undefined ? { href: props.href } : {})}
|
|
135
|
+
data-testid="listings-card-open"
|
|
136
|
+
data-analytics="none"
|
|
137
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
138
|
+
onClick={() => {
|
|
139
|
+
props.onOpen?.(listing.id);
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{t(LISTINGS_I18N_KEYS.cardOpen)}
|
|
143
|
+
</Button>
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
{props.showFavorite === false ? null : (
|
|
147
|
+
<Tooltip
|
|
148
|
+
title={
|
|
149
|
+
favorite.gate.available
|
|
150
|
+
? favoriteLabel
|
|
151
|
+
: t(favorite.gate.block.code, favorite.gate.block.params)
|
|
152
|
+
}
|
|
153
|
+
>
|
|
154
|
+
{/* A disabled antd Button swallows pointer events, so the
|
|
155
|
+
tooltip needs the wrapper to hear them — which is also
|
|
156
|
+
what makes the REASON reachable by keyboard. */}
|
|
157
|
+
<span data-testid="listings-card-favorite-wrap">
|
|
158
|
+
<Button
|
|
159
|
+
size="small"
|
|
160
|
+
disabled={!favorite.gate.available}
|
|
161
|
+
aria-label={favoriteLabel}
|
|
162
|
+
aria-pressed={favorite.favorited}
|
|
163
|
+
data-testid="listings-card-favorite"
|
|
164
|
+
data-favorited={String(favorite.favorited)}
|
|
165
|
+
data-analytics="none"
|
|
166
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
167
|
+
onClick={favorite.toggle}
|
|
168
|
+
icon={<HeartIcon filled={favorite.favorited} />}
|
|
169
|
+
/>
|
|
170
|
+
</span>
|
|
171
|
+
</Tooltip>
|
|
172
|
+
)}
|
|
173
|
+
</Flex>
|
|
174
|
+
</Flex>
|
|
175
|
+
</Card>
|
|
176
|
+
</ListingsSkinTheme>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ListingComposerPage>` — the submission screen.
|
|
3
|
+
*
|
|
4
|
+
* Four contracts meet on this page and each arrives through its own seam, so
|
|
5
|
+
* the component can be read top to bottom without knowing any of the other
|
|
6
|
+
* pairs:
|
|
7
|
+
*
|
|
8
|
+
* category `categorySlot` — the container's `<CategoryPickerField>`
|
|
9
|
+
* details `features` — the schema, drawn by `<FeatureFields>` (L0)
|
|
10
|
+
* photos `gallerySlot` — the container's `<MediaGalleryField>`, whose
|
|
11
|
+
* bag is handed to the composer as `images`
|
|
12
|
+
* the draft this pair
|
|
13
|
+
*
|
|
14
|
+
* ── Every blocked control says which of six reasons it is ──────────────────
|
|
15
|
+
*
|
|
16
|
+
* The publish button is the most-gated control in the fleet, and that is the
|
|
17
|
+
* point: "sign in", "choose a category", "we could not load what this
|
|
18
|
+
* category asks for", "this build cannot draw one of these details", "wait
|
|
19
|
+
* for the photos", "fix the highlighted fields" are six different problems
|
|
20
|
+
* with six different next actions. `firstBlock` orders them the way a person
|
|
21
|
+
* would be told, and the reason is rendered beside the button — never left
|
|
22
|
+
* as a grey rectangle.
|
|
23
|
+
*/
|
|
24
|
+
import type { ReactElement, ReactNode } from "react";
|
|
25
|
+
import {
|
|
26
|
+
Alert,
|
|
27
|
+
Button,
|
|
28
|
+
Checkbox,
|
|
29
|
+
Divider,
|
|
30
|
+
Flex,
|
|
31
|
+
Form,
|
|
32
|
+
Input,
|
|
33
|
+
InputNumber,
|
|
34
|
+
Space,
|
|
35
|
+
Typography,
|
|
36
|
+
} from "antd";
|
|
37
|
+
import { useDescribeFlowError, useT } from "@stapel/core";
|
|
38
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
39
|
+
import {
|
|
40
|
+
BUILTIN_VALUE_EDITOR_TYPES,
|
|
41
|
+
FeatureFields,
|
|
42
|
+
} from "@stapel/attributes-react/default";
|
|
43
|
+
import { useListingComposer } from "../headless/ListingComposer.js";
|
|
44
|
+
import type { ListingImagesBag } from "../headless/ListingComposer.js";
|
|
45
|
+
import {
|
|
46
|
+
CATEGORY_FIELD,
|
|
47
|
+
DESCRIPTION_FIELD,
|
|
48
|
+
IMAGES_FIELD,
|
|
49
|
+
PRICE_FIELD,
|
|
50
|
+
TITLE_FIELD,
|
|
51
|
+
failedResults,
|
|
52
|
+
} from "../model/validation.js";
|
|
53
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
54
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
55
|
+
import { ListingsSkinTheme } from "./theme.js";
|
|
56
|
+
import type { ThemeModeProp } from "./types.js";
|
|
57
|
+
|
|
58
|
+
export interface ListingComposerPageProps extends ThemeModeProp {
|
|
59
|
+
/** Editing an existing listing; absent for a new one. */
|
|
60
|
+
readonly listingId?: number;
|
|
61
|
+
/** The chosen category's features — from `@stapel/categories-react` via the
|
|
62
|
+
* container (L2 pairs do not import each other). */
|
|
63
|
+
readonly features: readonly FeatureDef[];
|
|
64
|
+
readonly featuresLoading?: boolean;
|
|
65
|
+
readonly featuresError?: unknown;
|
|
66
|
+
/** The category chooser. A slot for the same reason. */
|
|
67
|
+
readonly categorySlot?: ReactNode;
|
|
68
|
+
/** The photo grid. Its bag is what `images` carries. */
|
|
69
|
+
readonly gallerySlot?: ReactNode;
|
|
70
|
+
/** `useUploadQueue()`'s bag from `@stapel/cdn-react` — `refs` becomes
|
|
71
|
+
* `images_draft`, `settled` gates the submit. */
|
|
72
|
+
readonly images?: ListingImagesBag;
|
|
73
|
+
readonly onPublished?: (listingId: number) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ListingComposerPage(
|
|
77
|
+
props: ListingComposerPageProps
|
|
78
|
+
): ReactElement {
|
|
79
|
+
const t = useT();
|
|
80
|
+
const describe = useDescribeFlowError();
|
|
81
|
+
const bag = useListingComposer({
|
|
82
|
+
...(props.listingId !== undefined ? { listingId: props.listingId } : {}),
|
|
83
|
+
features: props.features,
|
|
84
|
+
...(props.featuresLoading !== undefined
|
|
85
|
+
? { featuresLoading: props.featuresLoading }
|
|
86
|
+
: {}),
|
|
87
|
+
...(props.featuresError !== undefined
|
|
88
|
+
? { featuresError: props.featuresError }
|
|
89
|
+
: {}),
|
|
90
|
+
editorTypes: BUILTIN_VALUE_EDITOR_TYPES,
|
|
91
|
+
...(props.images !== undefined ? { images: props.images } : {}),
|
|
92
|
+
...(props.onPublished !== undefined
|
|
93
|
+
? {
|
|
94
|
+
// `listing_id` comes back in the response, so the callback never
|
|
95
|
+
// has to reach for a piece of state that may be one render behind.
|
|
96
|
+
onPublished: (response) => {
|
|
97
|
+
props.onPublished?.(response.listing_id);
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
: {}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Spread onto a `Form.Item`: the two props exist only when there IS a
|
|
104
|
+
* refusal, which is what `exactOptionalPropertyTypes` asks for and what
|
|
105
|
+
* keeps a clean field from rendering an empty help line. */
|
|
106
|
+
const errorOf = (
|
|
107
|
+
field: string
|
|
108
|
+
): { help: ReactNode; validateStatus: "error" } | Record<string, never> => {
|
|
109
|
+
const error = bag.fieldErrors[field];
|
|
110
|
+
if (error === undefined) return {};
|
|
111
|
+
return { help: describe(error).message, validateStatus: "error" };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const publishLabel = t(
|
|
115
|
+
bag.isLiveEdit
|
|
116
|
+
? LISTINGS_I18N_KEYS.composeRepublish
|
|
117
|
+
: LISTINGS_I18N_KEYS.composePublish
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
122
|
+
<Flex vertical gap={16} data-testid="listings-composer" data-stage={bag.stage}>
|
|
123
|
+
<Typography.Title level={3}>
|
|
124
|
+
{t(
|
|
125
|
+
props.listingId === undefined
|
|
126
|
+
? LISTINGS_I18N_KEYS.composeNewTitle
|
|
127
|
+
: LISTINGS_I18N_KEYS.composeEditTitle
|
|
128
|
+
)}
|
|
129
|
+
</Typography.Title>
|
|
130
|
+
|
|
131
|
+
{/* stapel-listings 0.6.1 exposes no read of the `*_draft` twin, so a
|
|
132
|
+
draft reopened in a new session comes back empty. Saying so beats
|
|
133
|
+
a blank form that looks like lost work. */}
|
|
134
|
+
{bag.draftNotReadable ? (
|
|
135
|
+
<Alert
|
|
136
|
+
type="warning"
|
|
137
|
+
showIcon
|
|
138
|
+
data-testid="listings-composer-draft-unreadable"
|
|
139
|
+
message={t(LISTINGS_I18N_KEYS.composeBlockedNoDraft)}
|
|
140
|
+
/>
|
|
141
|
+
) : null}
|
|
142
|
+
|
|
143
|
+
{bag.outcome !== undefined ? (
|
|
144
|
+
<Alert
|
|
145
|
+
type="success"
|
|
146
|
+
showIcon
|
|
147
|
+
data-testid="listings-composer-published"
|
|
148
|
+
data-outcome={bag.outcome}
|
|
149
|
+
message={t(
|
|
150
|
+
bag.outcome === "live_edit_under_review"
|
|
151
|
+
? LISTINGS_I18N_KEYS.composePublishedLive
|
|
152
|
+
: LISTINGS_I18N_KEYS.composePublishedFirst
|
|
153
|
+
)}
|
|
154
|
+
/>
|
|
155
|
+
) : null}
|
|
156
|
+
|
|
157
|
+
<Form layout="vertical" data-testid="listings-composer-form">
|
|
158
|
+
<Form.Item
|
|
159
|
+
label={t(LISTINGS_I18N_KEYS.composeCategory)}
|
|
160
|
+
extra={t(LISTINGS_I18N_KEYS.composeCategoryHelp)}
|
|
161
|
+
{...errorOf(CATEGORY_FIELD)}
|
|
162
|
+
>
|
|
163
|
+
{props.categorySlot ?? (
|
|
164
|
+
<Input
|
|
165
|
+
value={bag.values.categoryId}
|
|
166
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeCategory)}
|
|
167
|
+
data-testid="listings-composer-category"
|
|
168
|
+
onChange={(event) => {
|
|
169
|
+
bag.setCategory(event.target.value);
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</Form.Item>
|
|
174
|
+
|
|
175
|
+
{bag.droppedOnCategoryChange.length > 0 ? (
|
|
176
|
+
<Alert
|
|
177
|
+
type="info"
|
|
178
|
+
showIcon
|
|
179
|
+
data-testid="listings-composer-dropped"
|
|
180
|
+
message={t(LISTINGS_I18N_KEYS.composeCategoryChangedDropped, {
|
|
181
|
+
count: bag.droppedOnCategoryChange.length,
|
|
182
|
+
})}
|
|
183
|
+
/>
|
|
184
|
+
) : null}
|
|
185
|
+
|
|
186
|
+
<Form.Item
|
|
187
|
+
label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
|
|
188
|
+
{...errorOf(TITLE_FIELD)}
|
|
189
|
+
>
|
|
190
|
+
<Input
|
|
191
|
+
value={bag.values.title}
|
|
192
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
|
|
193
|
+
data-testid="listings-composer-title"
|
|
194
|
+
onChange={(event) => {
|
|
195
|
+
bag.setValue("title", event.target.value);
|
|
196
|
+
}}
|
|
197
|
+
onBlur={bag.save}
|
|
198
|
+
/>
|
|
199
|
+
</Form.Item>
|
|
200
|
+
|
|
201
|
+
<Form.Item
|
|
202
|
+
label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
|
|
203
|
+
{...errorOf(DESCRIPTION_FIELD)}
|
|
204
|
+
>
|
|
205
|
+
<Input.TextArea
|
|
206
|
+
rows={5}
|
|
207
|
+
value={bag.values.description}
|
|
208
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeDescriptionLabel)}
|
|
209
|
+
data-testid="listings-composer-description"
|
|
210
|
+
onChange={(event) => {
|
|
211
|
+
bag.setValue("description", event.target.value);
|
|
212
|
+
}}
|
|
213
|
+
onBlur={bag.save}
|
|
214
|
+
/>
|
|
215
|
+
</Form.Item>
|
|
216
|
+
|
|
217
|
+
<Space align="start" wrap>
|
|
218
|
+
<Form.Item
|
|
219
|
+
label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
|
|
220
|
+
{...errorOf(PRICE_FIELD)}
|
|
221
|
+
>
|
|
222
|
+
<Input
|
|
223
|
+
inputMode="decimal"
|
|
224
|
+
value={bag.values.price}
|
|
225
|
+
aria-label={t(LISTINGS_I18N_KEYS.composePriceLabel)}
|
|
226
|
+
data-testid="listings-composer-price"
|
|
227
|
+
onChange={(event) => {
|
|
228
|
+
bag.setValue("price", event.target.value);
|
|
229
|
+
}}
|
|
230
|
+
onBlur={bag.save}
|
|
231
|
+
/>
|
|
232
|
+
</Form.Item>
|
|
233
|
+
|
|
234
|
+
<Form.Item label={t(LISTINGS_I18N_KEYS.composeCurrencyLabel)}>
|
|
235
|
+
<Input
|
|
236
|
+
value={bag.values.currency}
|
|
237
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeCurrencyLabel)}
|
|
238
|
+
data-testid="listings-composer-currency"
|
|
239
|
+
onChange={(event) => {
|
|
240
|
+
bag.setValue("currency", event.target.value);
|
|
241
|
+
}}
|
|
242
|
+
onBlur={bag.save}
|
|
243
|
+
/>
|
|
244
|
+
</Form.Item>
|
|
245
|
+
</Space>
|
|
246
|
+
|
|
247
|
+
<Form.Item label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}>
|
|
248
|
+
<Input
|
|
249
|
+
value={bag.values.location.locationLabel}
|
|
250
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
|
|
251
|
+
data-testid="listings-composer-location"
|
|
252
|
+
onChange={(event) => {
|
|
253
|
+
bag.setLocation({
|
|
254
|
+
...bag.values.location,
|
|
255
|
+
locationLabel: event.target.value,
|
|
256
|
+
});
|
|
257
|
+
}}
|
|
258
|
+
onBlur={bag.save}
|
|
259
|
+
/>
|
|
260
|
+
</Form.Item>
|
|
261
|
+
|
|
262
|
+
{/* Latitude and longitude are ONE value: half a coordinate points
|
|
263
|
+
nowhere, and the mirror refuses it under `location`. `geohash`
|
|
264
|
+
is not typed here at all — it comes from whatever resolved the
|
|
265
|
+
place, because a geohash computed at a precision of our own
|
|
266
|
+
choosing would bucket the pin somewhere the indexer does not
|
|
267
|
+
expect (model/draft.ts). */}
|
|
268
|
+
<Space align="start" wrap>
|
|
269
|
+
<Form.Item
|
|
270
|
+
label={t(LISTINGS_I18N_KEYS.composeLatLabel)}
|
|
271
|
+
{...(bag.fieldErrors["location"] ? { validateStatus: "error" as const } : {})}
|
|
272
|
+
>
|
|
273
|
+
<Input
|
|
274
|
+
inputMode="decimal"
|
|
275
|
+
value={bag.values.location.lat ?? ""}
|
|
276
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeLatLabel)}
|
|
277
|
+
data-testid="listings-composer-lat"
|
|
278
|
+
onChange={(event) => {
|
|
279
|
+
bag.setLocation({
|
|
280
|
+
...bag.values.location,
|
|
281
|
+
lat: event.target.value.length > 0 ? event.target.value : null,
|
|
282
|
+
});
|
|
283
|
+
}}
|
|
284
|
+
onBlur={bag.save}
|
|
285
|
+
/>
|
|
286
|
+
</Form.Item>
|
|
287
|
+
<Form.Item
|
|
288
|
+
label={t(LISTINGS_I18N_KEYS.composeLonLabel)}
|
|
289
|
+
{...errorOf("location")}
|
|
290
|
+
>
|
|
291
|
+
<Input
|
|
292
|
+
inputMode="decimal"
|
|
293
|
+
value={bag.values.location.lon ?? ""}
|
|
294
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeLonLabel)}
|
|
295
|
+
data-testid="listings-composer-lon"
|
|
296
|
+
onChange={(event) => {
|
|
297
|
+
bag.setLocation({
|
|
298
|
+
...bag.values.location,
|
|
299
|
+
lon: event.target.value.length > 0 ? event.target.value : null,
|
|
300
|
+
});
|
|
301
|
+
}}
|
|
302
|
+
onBlur={bag.save}
|
|
303
|
+
/>
|
|
304
|
+
</Form.Item>
|
|
305
|
+
</Space>
|
|
306
|
+
|
|
307
|
+
<Divider />
|
|
308
|
+
|
|
309
|
+
<Typography.Title level={5}>
|
|
310
|
+
{t(LISTINGS_I18N_KEYS.composePhotos)}
|
|
311
|
+
</Typography.Title>
|
|
312
|
+
{props.gallerySlot}
|
|
313
|
+
{bag.fieldErrors[IMAGES_FIELD] ? (
|
|
314
|
+
<Typography.Text type="danger" data-testid="listings-composer-images-error">
|
|
315
|
+
{describe(bag.fieldErrors[IMAGES_FIELD]).message}
|
|
316
|
+
</Typography.Text>
|
|
317
|
+
) : null}
|
|
318
|
+
|
|
319
|
+
<Divider />
|
|
320
|
+
|
|
321
|
+
<Typography.Title level={5}>
|
|
322
|
+
{t(LISTINGS_I18N_KEYS.composeDetails)}
|
|
323
|
+
</Typography.Title>
|
|
324
|
+
{props.featuresError !== undefined ? (
|
|
325
|
+
<Alert
|
|
326
|
+
type="error"
|
|
327
|
+
showIcon
|
|
328
|
+
data-testid="listings-composer-features-failed"
|
|
329
|
+
message={t(LISTINGS_I18N_KEYS.composeDetailsFailed)}
|
|
330
|
+
/>
|
|
331
|
+
) : props.featuresLoading === true ? (
|
|
332
|
+
<Typography.Text type="secondary" data-testid="listings-composer-features-loading">
|
|
333
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsLoading)}
|
|
334
|
+
</Typography.Text>
|
|
335
|
+
) : props.features.length === 0 ? (
|
|
336
|
+
<Typography.Text type="secondary" data-testid="listings-composer-features-empty">
|
|
337
|
+
{t(LISTINGS_I18N_KEYS.composeDetailsEmpty)}
|
|
338
|
+
</Typography.Text>
|
|
339
|
+
) : (
|
|
340
|
+
<FeatureFields
|
|
341
|
+
features={props.features}
|
|
342
|
+
values={bag.values.features}
|
|
343
|
+
errors={bag.fieldErrors}
|
|
344
|
+
disabled={bag.publishing}
|
|
345
|
+
onChange={bag.setFeature}
|
|
346
|
+
/>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
<Divider />
|
|
350
|
+
|
|
351
|
+
<Form.Item>
|
|
352
|
+
<Checkbox
|
|
353
|
+
checked={bag.values.countable}
|
|
354
|
+
data-testid="listings-composer-countable"
|
|
355
|
+
onChange={(event) => {
|
|
356
|
+
bag.setValue("countable", event.target.checked);
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
{t(LISTINGS_I18N_KEYS.composeCountable)}
|
|
360
|
+
</Checkbox>
|
|
361
|
+
</Form.Item>
|
|
362
|
+
|
|
363
|
+
{bag.values.countable ? (
|
|
364
|
+
<Form.Item label={t(LISTINGS_I18N_KEYS.composeStock)}>
|
|
365
|
+
<InputNumber
|
|
366
|
+
min={0}
|
|
367
|
+
value={bag.values.stockQuantity}
|
|
368
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeStock)}
|
|
369
|
+
data-testid="listings-composer-stock"
|
|
370
|
+
onChange={(value) => {
|
|
371
|
+
bag.setValue("stockQuantity", value ?? null);
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
</Form.Item>
|
|
375
|
+
) : null}
|
|
376
|
+
|
|
377
|
+
<Form.Item>
|
|
378
|
+
<Checkbox
|
|
379
|
+
checked={bag.values.autoRepublish}
|
|
380
|
+
data-testid="listings-composer-auto-republish"
|
|
381
|
+
onChange={(event) => {
|
|
382
|
+
bag.setValue("autoRepublish", event.target.checked);
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
{t(LISTINGS_I18N_KEYS.composeAutoRepublish)}
|
|
386
|
+
</Checkbox>
|
|
387
|
+
</Form.Item>
|
|
388
|
+
</Form>
|
|
389
|
+
|
|
390
|
+
{bag.refusal?.kind === "invalid_draft" ? (
|
|
391
|
+
<Alert
|
|
392
|
+
type="error"
|
|
393
|
+
showIcon
|
|
394
|
+
data-testid="listings-composer-invalid"
|
|
395
|
+
message={t(LISTINGS_I18N_KEYS.composeInvalidSummary, {
|
|
396
|
+
count: failedResults(bag.refusal.batch).length,
|
|
397
|
+
})}
|
|
398
|
+
/>
|
|
399
|
+
) : null}
|
|
400
|
+
|
|
401
|
+
{bag.refusal?.kind === "error" ? (
|
|
402
|
+
<ErrorAlert
|
|
403
|
+
testId="listings-composer-error"
|
|
404
|
+
error={describe({
|
|
405
|
+
code: bag.refusal.error.code,
|
|
406
|
+
params: bag.refusal.error.params,
|
|
407
|
+
status: bag.refusal.error.status,
|
|
408
|
+
message: bag.refusal.error.message,
|
|
409
|
+
language: bag.refusal.error.language,
|
|
410
|
+
})}
|
|
411
|
+
/>
|
|
412
|
+
) : null}
|
|
413
|
+
|
|
414
|
+
{bag.saveError !== undefined && bag.saveError !== null ? (
|
|
415
|
+
<ErrorAlert
|
|
416
|
+
testId="listings-composer-save-error"
|
|
417
|
+
error={describe({
|
|
418
|
+
code: LISTINGS_I18N_KEYS.unknownError,
|
|
419
|
+
params: {},
|
|
420
|
+
status: 0,
|
|
421
|
+
message: undefined,
|
|
422
|
+
language: undefined,
|
|
423
|
+
})}
|
|
424
|
+
/>
|
|
425
|
+
) : null}
|
|
426
|
+
|
|
427
|
+
<Space>
|
|
428
|
+
<Button
|
|
429
|
+
disabled={!bag.saveGate.available}
|
|
430
|
+
loading={bag.saving}
|
|
431
|
+
data-testid="listings-composer-save"
|
|
432
|
+
data-analytics="none"
|
|
433
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
434
|
+
onClick={bag.save}
|
|
435
|
+
>
|
|
436
|
+
{t(bag.saving ? LISTINGS_I18N_KEYS.composeSaving : LISTINGS_I18N_KEYS.composeSave)}
|
|
437
|
+
</Button>
|
|
438
|
+
|
|
439
|
+
<Button
|
|
440
|
+
type="primary"
|
|
441
|
+
disabled={!bag.publishGate.available}
|
|
442
|
+
loading={bag.publishing}
|
|
443
|
+
data-testid="listings-composer-publish"
|
|
444
|
+
data-analytics="none"
|
|
445
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
446
|
+
onClick={bag.publish}
|
|
447
|
+
>
|
|
448
|
+
{bag.publishing ? t(LISTINGS_I18N_KEYS.composePublishing) : publishLabel}
|
|
449
|
+
</Button>
|
|
450
|
+
|
|
451
|
+
{bag.saved ? (
|
|
452
|
+
<Typography.Text type="success" data-testid="listings-composer-saved">
|
|
453
|
+
{t(LISTINGS_I18N_KEYS.composeSaved)}
|
|
454
|
+
</Typography.Text>
|
|
455
|
+
) : null}
|
|
456
|
+
</Space>
|
|
457
|
+
|
|
458
|
+
{!bag.publishGate.available ? (
|
|
459
|
+
<Typography.Text type="secondary" data-testid="listings-composer-publish-blocked">
|
|
460
|
+
{t(bag.publishGate.block.code, bag.publishGate.block.params)}
|
|
461
|
+
</Typography.Text>
|
|
462
|
+
) : null}
|
|
463
|
+
</Flex>
|
|
464
|
+
</ListingsSkinTheme>
|
|
465
|
+
);
|
|
466
|
+
}
|