@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,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ListingDetailPane>` — the listing page.
|
|
3
|
+
*
|
|
4
|
+
* Four distinct absences, four distinct sentences, and the whole point of the
|
|
5
|
+
* component is that none of them collapses into another:
|
|
6
|
+
*
|
|
7
|
+
* loading — we are asking
|
|
8
|
+
* failed — we could not ask (retry, never "nothing here")
|
|
9
|
+
* not found — no listing ever had this id
|
|
10
|
+
* removed — one did, and it is gone (the AllowAny status probe
|
|
11
|
+
* is the only read that can still say so)
|
|
12
|
+
*
|
|
13
|
+
* On top of that, a listing that IS returned may still not be on sale: the
|
|
14
|
+
* detail endpoint has no `published()` filter, so a draft answers 200 to
|
|
15
|
+
* anyone holding the id. The pane says which side of that it is on rather
|
|
16
|
+
* than dressing a draft up as a shop page.
|
|
17
|
+
*/
|
|
18
|
+
import type { ReactElement, ReactNode } from "react";
|
|
19
|
+
import { Alert, Button, Descriptions, Divider, Flex, Space, Spin, Typography } from "antd";
|
|
20
|
+
import { matchLoad, useDescribeFlowError, useT, useI18n } from "@stapel/core";
|
|
21
|
+
import { FeatureValueList } from "@stapel/attributes-react/default";
|
|
22
|
+
import { formatFeatureValue } from "@stapel/attributes-react";
|
|
23
|
+
import { useListingDetail } from "../headless/ListingDetail.js";
|
|
24
|
+
import { asFeatureDaoList, featuresDtoFromDaoList } from "../model/features.js";
|
|
25
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
26
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
27
|
+
import { HeartIcon } from "./icons.js";
|
|
28
|
+
import { ListingPhoto } from "./ListingPhoto.js";
|
|
29
|
+
import { ListingStatusBlock } from "./StatusTags.js";
|
|
30
|
+
import { ListingsSkinTheme } from "./theme.js";
|
|
31
|
+
import type { ThemeModeProp } from "./types.js";
|
|
32
|
+
|
|
33
|
+
export interface ListingDetailPaneProps extends ThemeModeProp {
|
|
34
|
+
readonly id: number;
|
|
35
|
+
/** The reader's own uuid, when the host knows it. Enables the owner view —
|
|
36
|
+
* the only place the moderation axis is shown, because it is the only
|
|
37
|
+
* person it concerns. */
|
|
38
|
+
readonly viewerId?: string;
|
|
39
|
+
/** Slots the container fills: "message the seller" (chat-react), the
|
|
40
|
+
* seller's profile link, reviews. Cross-pair navigation is the container's
|
|
41
|
+
* job (spec §6.2 item 5), so this pair takes nodes rather than routes. */
|
|
42
|
+
readonly actions?: ReactNode;
|
|
43
|
+
readonly footer?: ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
47
|
+
const t = useT();
|
|
48
|
+
const { locale } = useI18n();
|
|
49
|
+
const describe = useDescribeFlowError();
|
|
50
|
+
const bag = useListingDetail(
|
|
51
|
+
props.id,
|
|
52
|
+
props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
57
|
+
<Flex vertical gap={16} data-testid="listings-detail">
|
|
58
|
+
{bag.removed ? (
|
|
59
|
+
<Alert
|
|
60
|
+
type="warning"
|
|
61
|
+
showIcon
|
|
62
|
+
data-testid="listings-detail-removed"
|
|
63
|
+
message={t(LISTINGS_I18N_KEYS.detailRemoved)}
|
|
64
|
+
/>
|
|
65
|
+
) : null}
|
|
66
|
+
|
|
67
|
+
{matchLoad(bag.state, {
|
|
68
|
+
loading: () => (
|
|
69
|
+
<Flex justify="center" data-testid="listings-detail-loading">
|
|
70
|
+
<Spin aria-label={t(LISTINGS_I18N_KEYS.detailLoading)} />
|
|
71
|
+
</Flex>
|
|
72
|
+
),
|
|
73
|
+
failed: () =>
|
|
74
|
+
bag.removed ? null : (
|
|
75
|
+
<ErrorAlert
|
|
76
|
+
testId="listings-detail-error"
|
|
77
|
+
error={describe({
|
|
78
|
+
code: bag.notFound
|
|
79
|
+
? LISTINGS_I18N_KEYS.detailNotFound
|
|
80
|
+
: LISTINGS_I18N_KEYS.detailLoadFailed,
|
|
81
|
+
params: {},
|
|
82
|
+
status: 0,
|
|
83
|
+
message: undefined,
|
|
84
|
+
language: undefined,
|
|
85
|
+
})}
|
|
86
|
+
action={
|
|
87
|
+
<Button
|
|
88
|
+
size="small"
|
|
89
|
+
data-analytics="none"
|
|
90
|
+
data-analytics-reason="retrying a read the person already asked for; not a business action"
|
|
91
|
+
onClick={bag.refetch}
|
|
92
|
+
>
|
|
93
|
+
{t(LISTINGS_I18N_KEYS.detailRetry)}
|
|
94
|
+
</Button>
|
|
95
|
+
}
|
|
96
|
+
/>
|
|
97
|
+
),
|
|
98
|
+
ready: (listing) => (
|
|
99
|
+
<>
|
|
100
|
+
{/* The moderation axis is the OWNER's business and nobody
|
|
101
|
+
else's: a buyer has no use for "changes under review", and
|
|
102
|
+
showing a stranger that a listing was refused would leak a
|
|
103
|
+
verdict about someone else's content. */}
|
|
104
|
+
{bag.viewerIsOwner === true && bag.status !== undefined ? (
|
|
105
|
+
<Flex vertical gap={8} data-testid="listings-detail-owner-view">
|
|
106
|
+
<ListingStatusBlock status={bag.status} />
|
|
107
|
+
{!bag.publiclyVisible ? (
|
|
108
|
+
<Alert
|
|
109
|
+
type="info"
|
|
110
|
+
showIcon
|
|
111
|
+
message={t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
|
|
112
|
+
/>
|
|
113
|
+
) : null}
|
|
114
|
+
</Flex>
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
117
|
+
{bag.viewerIsOwner !== true && !bag.publiclyVisible ? (
|
|
118
|
+
<Alert
|
|
119
|
+
type="warning"
|
|
120
|
+
showIcon
|
|
121
|
+
data-testid="listings-detail-not-published"
|
|
122
|
+
message={t(LISTINGS_I18N_KEYS.detailNotPublished)}
|
|
123
|
+
/>
|
|
124
|
+
) : null}
|
|
125
|
+
|
|
126
|
+
<Flex gap={16} wrap>
|
|
127
|
+
{bag.images.length === 0 ? (
|
|
128
|
+
<ListingPhoto
|
|
129
|
+
imageRef={undefined}
|
|
130
|
+
alt={listing.title ?? String(listing.id)}
|
|
131
|
+
style={{ width: 320, aspectRatio: "4 / 3" }}
|
|
132
|
+
/>
|
|
133
|
+
) : (
|
|
134
|
+
bag.images.map((ref, index) => (
|
|
135
|
+
<ListingPhoto
|
|
136
|
+
key={ref}
|
|
137
|
+
imageRef={ref}
|
|
138
|
+
alt={t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
139
|
+
index: index + 1,
|
|
140
|
+
total: bag.images.length,
|
|
141
|
+
})}
|
|
142
|
+
style={{ width: 320, aspectRatio: "4 / 3" }}
|
|
143
|
+
/>
|
|
144
|
+
))
|
|
145
|
+
)}
|
|
146
|
+
</Flex>
|
|
147
|
+
|
|
148
|
+
<Typography.Title level={3} data-testid="listings-detail-title">
|
|
149
|
+
{listing.title ?? ""}
|
|
150
|
+
</Typography.Title>
|
|
151
|
+
|
|
152
|
+
{/* The `show_at_title` projection, formatted from the stored
|
|
153
|
+
DAOs — no category read needed (see model/features.ts). */}
|
|
154
|
+
{bag.titleFeatures.length > 0 ? (
|
|
155
|
+
<Typography.Text type="secondary" data-testid="listings-detail-title-features">
|
|
156
|
+
{bag.titleFeatures
|
|
157
|
+
.map((view) =>
|
|
158
|
+
formatFeatureValue(view.feature, view.value, { t, locale })
|
|
159
|
+
)
|
|
160
|
+
.filter((text): text is string => text !== undefined)
|
|
161
|
+
.join(" · ")}
|
|
162
|
+
</Typography.Text>
|
|
163
|
+
) : null}
|
|
164
|
+
|
|
165
|
+
<Typography.Title level={4} data-testid="listings-detail-price">
|
|
166
|
+
{listing.price !== undefined && listing.price.length > 0
|
|
167
|
+
? `${listing.price} ${listing.currency ?? ""}`.trim()
|
|
168
|
+
: t(LISTINGS_I18N_KEYS.cardPriceAbsent)}
|
|
169
|
+
</Typography.Title>
|
|
170
|
+
|
|
171
|
+
<Space wrap>
|
|
172
|
+
<Button
|
|
173
|
+
disabled={!bag.favoriteGate.available}
|
|
174
|
+
aria-label={t(
|
|
175
|
+
bag.isFavorited === true
|
|
176
|
+
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
177
|
+
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
178
|
+
)}
|
|
179
|
+
aria-pressed={bag.isFavorited === true}
|
|
180
|
+
icon={<HeartIcon filled={bag.isFavorited === true} />}
|
|
181
|
+
data-testid="listings-detail-favorite"
|
|
182
|
+
data-analytics="none"
|
|
183
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
184
|
+
onClick={bag.toggleFavorite}
|
|
185
|
+
>
|
|
186
|
+
{t(
|
|
187
|
+
bag.isFavorited === true
|
|
188
|
+
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
189
|
+
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
190
|
+
)}
|
|
191
|
+
</Button>
|
|
192
|
+
{props.actions}
|
|
193
|
+
</Space>
|
|
194
|
+
|
|
195
|
+
{/* A switched-off control that cannot say why is the one shape
|
|
196
|
+
ActionAvailability makes unwritable — so the reason is
|
|
197
|
+
rendered, not implied by the disabled attribute. */}
|
|
198
|
+
{!bag.favoriteGate.available ? (
|
|
199
|
+
<Typography.Text
|
|
200
|
+
type="secondary"
|
|
201
|
+
data-testid="listings-detail-favorite-blocked"
|
|
202
|
+
>
|
|
203
|
+
{t(
|
|
204
|
+
bag.favoriteGate.block.code,
|
|
205
|
+
bag.favoriteGate.block.params
|
|
206
|
+
)}
|
|
207
|
+
</Typography.Text>
|
|
208
|
+
) : null}
|
|
209
|
+
|
|
210
|
+
<Divider />
|
|
211
|
+
|
|
212
|
+
<Typography.Title level={5}>
|
|
213
|
+
{t(LISTINGS_I18N_KEYS.detailDescription)}
|
|
214
|
+
</Typography.Title>
|
|
215
|
+
<Typography.Paragraph data-testid="listings-detail-description">
|
|
216
|
+
{listing.description ?? ""}
|
|
217
|
+
</Typography.Paragraph>
|
|
218
|
+
|
|
219
|
+
<Typography.Title level={5}>
|
|
220
|
+
{t(LISTINGS_I18N_KEYS.detailSpecs)}
|
|
221
|
+
</Typography.Title>
|
|
222
|
+
{bag.features.length === 0 ? (
|
|
223
|
+
<Typography.Text type="secondary" data-testid="listings-detail-no-specs">
|
|
224
|
+
{t(LISTINGS_I18N_KEYS.detailNoSpecs)}
|
|
225
|
+
</Typography.Text>
|
|
226
|
+
) : (
|
|
227
|
+
<FeatureValueList
|
|
228
|
+
features={bag.features.map((view) => view.feature)}
|
|
229
|
+
values={featuresDtoFromDaoList(
|
|
230
|
+
asFeatureDaoList(listing.features)
|
|
231
|
+
)}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Counted, not rounded to zero: a stored attribute this build
|
|
236
|
+
cannot key is a gap in what the buyer is being told. */}
|
|
237
|
+
{bag.unreadableFeatures > 0 ? (
|
|
238
|
+
<Alert
|
|
239
|
+
type="warning"
|
|
240
|
+
showIcon
|
|
241
|
+
data-testid="listings-detail-unreadable"
|
|
242
|
+
message={t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
|
|
243
|
+
count: bag.unreadableFeatures,
|
|
244
|
+
})}
|
|
245
|
+
/>
|
|
246
|
+
) : null}
|
|
247
|
+
|
|
248
|
+
<Descriptions size="small" column={1}>
|
|
249
|
+
{listing.stock_quantity != null ? (
|
|
250
|
+
<Descriptions.Item label={t(LISTINGS_I18N_KEYS.detailStock)}>
|
|
251
|
+
{listing.stock_quantity}
|
|
252
|
+
</Descriptions.Item>
|
|
253
|
+
) : null}
|
|
254
|
+
{listing.location_label !== undefined &&
|
|
255
|
+
listing.location_label.length > 0 ? (
|
|
256
|
+
<Descriptions.Item
|
|
257
|
+
label={t(LISTINGS_I18N_KEYS.composeLocationLabel)}
|
|
258
|
+
>
|
|
259
|
+
{listing.location_label}
|
|
260
|
+
</Descriptions.Item>
|
|
261
|
+
) : null}
|
|
262
|
+
</Descriptions>
|
|
263
|
+
|
|
264
|
+
{props.footer}
|
|
265
|
+
</>
|
|
266
|
+
),
|
|
267
|
+
})}
|
|
268
|
+
</Flex>
|
|
269
|
+
</ListingsSkinTheme>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stored CDN reference, drawn — or the reason it cannot be.
|
|
3
|
+
*
|
|
4
|
+
* `Listing.images` carries opaque `<type>/<hash>` strings and no contract in
|
|
5
|
+
* this fleet resolves a stranger's reference (`model/runtime.ts` argues it at
|
|
6
|
+
* length). So this component asks the runtime's `resolveImage`, and when
|
|
7
|
+
* there is no resolver — or the resolver has nothing for this reference — it
|
|
8
|
+
* says so instead of emitting a broken `<img>`. An empty grey box that never
|
|
9
|
+
* loads teaches a person nothing; a sentence gets the wiring fixed.
|
|
10
|
+
*
|
|
11
|
+
* When the resolver DOES answer, `@stapel/image`'s `<Image>` takes over: it
|
|
12
|
+
* measures the slot, picks the variant tier that fits, blur-ups from
|
|
13
|
+
* `preview_b64` and never downgrades on a re-measure. None of that logic
|
|
14
|
+
* belongs here — it is why `@stapel/image` exists.
|
|
15
|
+
*/
|
|
16
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
17
|
+
import { Empty } from "antd";
|
|
18
|
+
import { Image } from "@stapel/image";
|
|
19
|
+
import { useT } from "@stapel/core";
|
|
20
|
+
import { useListingsRuntime } from "../model/context.js";
|
|
21
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
22
|
+
|
|
23
|
+
export interface ListingPhotoProps {
|
|
24
|
+
/** The stored reference, or `undefined` for a listing with no photos. */
|
|
25
|
+
readonly imageRef: string | undefined;
|
|
26
|
+
/** Alt text — required, and the caller has the context to write it. */
|
|
27
|
+
readonly alt: string;
|
|
28
|
+
readonly style?: CSSProperties;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ListingPhoto(props: ListingPhotoProps): ReactElement {
|
|
32
|
+
const t = useT();
|
|
33
|
+
const runtime = useListingsRuntime();
|
|
34
|
+
const meta =
|
|
35
|
+
props.imageRef === undefined || runtime.resolveImage === undefined
|
|
36
|
+
? undefined
|
|
37
|
+
: runtime.resolveImage(props.imageRef);
|
|
38
|
+
|
|
39
|
+
if (meta === undefined) {
|
|
40
|
+
return (
|
|
41
|
+
<Empty
|
|
42
|
+
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
43
|
+
data-testid="listings-photo-absent"
|
|
44
|
+
description={t(
|
|
45
|
+
props.imageRef === undefined
|
|
46
|
+
? LISTINGS_I18N_KEYS.cardNoPhoto
|
|
47
|
+
: LISTINGS_I18N_KEYS.cardPhotoUnavailable
|
|
48
|
+
)}
|
|
49
|
+
{...(props.style ? { style: props.style } : {})}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Image
|
|
56
|
+
meta={meta}
|
|
57
|
+
alt={props.alt}
|
|
58
|
+
data-testid="listings-photo"
|
|
59
|
+
{...(props.style ? { style: props.style } : {})}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<MyListingsPane>` — the seller's dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Three counts that are real, and rows that may not be: stapel-listings 0.6.1
|
|
5
|
+
* has no owner-scoped list endpoint (`headless/MyListings.tsx` argues it in
|
|
6
|
+
* full), so the rows come from an injected source and the pane NAMES the gap
|
|
7
|
+
* when there is none. The counters are shown either way, because they are the
|
|
8
|
+
* one thing the contract can actually answer.
|
|
9
|
+
*
|
|
10
|
+
* Each row carries both axes. "Published" and "changes under review" appear
|
|
11
|
+
* beside each other, which is the entire reason `model/status.ts` exists: a
|
|
12
|
+
* dashboard that derived one from the other would tell a seller their live
|
|
13
|
+
* listing is offline, or never tell them their edit is being screened.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactElement } from "react";
|
|
16
|
+
import {
|
|
17
|
+
Alert,
|
|
18
|
+
Badge,
|
|
19
|
+
Button,
|
|
20
|
+
Empty,
|
|
21
|
+
Flex,
|
|
22
|
+
List,
|
|
23
|
+
Space,
|
|
24
|
+
Spin,
|
|
25
|
+
Tabs,
|
|
26
|
+
Tooltip,
|
|
27
|
+
Typography,
|
|
28
|
+
} from "antd";
|
|
29
|
+
import { matchList, matchLoad, useDescribeFlowError, useT } from "@stapel/core";
|
|
30
|
+
import type { ActionAvailability } from "@stapel/core";
|
|
31
|
+
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
32
|
+
import { useMyListings } from "../headless/MyListings.js";
|
|
33
|
+
import type { MyListingsSource } from "../model/mineSource.js";
|
|
34
|
+
import { useListingActions } from "../headless/ListingActions.js";
|
|
35
|
+
import { listingStatusView } from "../model/status.js";
|
|
36
|
+
import type { MyListingsTab } from "../model/status.js";
|
|
37
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
38
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
39
|
+
import { ListingStatusBlock } from "./StatusTags.js";
|
|
40
|
+
import { ListingsSkinTheme } from "./theme.js";
|
|
41
|
+
import type { ThemeModeProp } from "./types.js";
|
|
42
|
+
|
|
43
|
+
const TAB_LABEL: Readonly<Record<MyListingsTab, string>> = {
|
|
44
|
+
active: LISTINGS_I18N_KEYS.mineTabActive,
|
|
45
|
+
drafts: LISTINGS_I18N_KEYS.mineTabDrafts,
|
|
46
|
+
archived: LISTINGS_I18N_KEYS.mineTabArchived,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A control plus the sentence that explains it when it is off. Written once
|
|
50
|
+
* here because this pane has six of them and a grey button with no reason is
|
|
51
|
+
* the one shape `ActionAvailability` exists to make impossible. */
|
|
52
|
+
function GatedButton(props: {
|
|
53
|
+
gate: ActionAvailability;
|
|
54
|
+
label: string;
|
|
55
|
+
testId: string;
|
|
56
|
+
onClick: () => void;
|
|
57
|
+
danger?: boolean;
|
|
58
|
+
/** The call sites mark themselves `data-analytics="none"` with a
|
|
59
|
+
* `passthrough` reason (the convention `stapel/clickable-needs-event`
|
|
60
|
+
* documents for a forwarding component); the real click point is the antd
|
|
61
|
+
* button below, which declares its own outcome. */
|
|
62
|
+
"data-analytics"?: string;
|
|
63
|
+
"data-analytics-reason"?: string;
|
|
64
|
+
}): ReactElement {
|
|
65
|
+
const t = useT();
|
|
66
|
+
return (
|
|
67
|
+
<Tooltip
|
|
68
|
+
title={
|
|
69
|
+
props.gate.available
|
|
70
|
+
? props.label
|
|
71
|
+
: t(props.gate.block.code, props.gate.block.params)
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
<span>
|
|
75
|
+
<Button
|
|
76
|
+
size="small"
|
|
77
|
+
danger={props.danger === true}
|
|
78
|
+
disabled={!props.gate.available}
|
|
79
|
+
data-testid={props.testId}
|
|
80
|
+
data-analytics="none"
|
|
81
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
82
|
+
onClick={props.onClick}
|
|
83
|
+
>
|
|
84
|
+
{props.label}
|
|
85
|
+
</Button>
|
|
86
|
+
</span>
|
|
87
|
+
</Tooltip>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function MyListingRow(props: {
|
|
92
|
+
listing: ListingCardData;
|
|
93
|
+
onEdit?: (id: number) => void;
|
|
94
|
+
}): ReactElement {
|
|
95
|
+
const t = useT();
|
|
96
|
+
const describe = useDescribeFlowError();
|
|
97
|
+
const actions = useListingActions(props.listing.id, props.listing.status);
|
|
98
|
+
// The card list carries `status` but NOT `moderation_status`
|
|
99
|
+
// (`ListingCardSerializer` omits it), so a row shows the moderation axis
|
|
100
|
+
// only through the one thing the projection does say. Pending is the
|
|
101
|
+
// honest default for the notice table's second argument here: it is the
|
|
102
|
+
// value that produces a note only when the lifecycle also implies one.
|
|
103
|
+
const status =
|
|
104
|
+
props.listing.status === undefined
|
|
105
|
+
? undefined
|
|
106
|
+
: listingStatusView(props.listing.status, "approved");
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<List.Item
|
|
110
|
+
data-testid="listings-mine-row"
|
|
111
|
+
data-listing-id={props.listing.id}
|
|
112
|
+
actions={[
|
|
113
|
+
<GatedButton
|
|
114
|
+
key="edit"
|
|
115
|
+
data-analytics="none"
|
|
116
|
+
data-analytics-reason="passthrough — GatedButton owns the real click point"
|
|
117
|
+
gate={{ available: true }}
|
|
118
|
+
label={t(LISTINGS_I18N_KEYS.mineEdit)}
|
|
119
|
+
testId="listings-mine-edit"
|
|
120
|
+
onClick={() => {
|
|
121
|
+
props.onEdit?.(props.listing.id);
|
|
122
|
+
}}
|
|
123
|
+
/>,
|
|
124
|
+
<GatedButton
|
|
125
|
+
key="complete"
|
|
126
|
+
data-analytics="none"
|
|
127
|
+
data-analytics-reason="passthrough — GatedButton owns the real click point"
|
|
128
|
+
gate={actions.complete}
|
|
129
|
+
label={t(LISTINGS_I18N_KEYS.mineComplete)}
|
|
130
|
+
testId="listings-mine-complete"
|
|
131
|
+
onClick={actions.doComplete}
|
|
132
|
+
/>,
|
|
133
|
+
<GatedButton
|
|
134
|
+
key="archive"
|
|
135
|
+
data-analytics="none"
|
|
136
|
+
data-analytics-reason="passthrough — GatedButton owns the real click point"
|
|
137
|
+
gate={actions.archive}
|
|
138
|
+
label={t(LISTINGS_I18N_KEYS.mineArchive)}
|
|
139
|
+
testId="listings-mine-archive"
|
|
140
|
+
onClick={actions.doArchive}
|
|
141
|
+
/>,
|
|
142
|
+
<GatedButton
|
|
143
|
+
key="delete"
|
|
144
|
+
data-analytics="none"
|
|
145
|
+
data-analytics-reason="passthrough — GatedButton owns the real click point"
|
|
146
|
+
danger
|
|
147
|
+
gate={actions.remove}
|
|
148
|
+
label={t(LISTINGS_I18N_KEYS.mineDelete)}
|
|
149
|
+
testId="listings-mine-delete"
|
|
150
|
+
onClick={actions.doRemove}
|
|
151
|
+
/>,
|
|
152
|
+
]}
|
|
153
|
+
>
|
|
154
|
+
<List.Item.Meta
|
|
155
|
+
title={props.listing.title ?? String(props.listing.id)}
|
|
156
|
+
description={
|
|
157
|
+
<Flex vertical gap={4}>
|
|
158
|
+
{status !== undefined ? <ListingStatusBlock status={status} /> : null}
|
|
159
|
+
{actions.error !== undefined && actions.error !== null ? (
|
|
160
|
+
<ErrorAlert
|
|
161
|
+
testId="listings-mine-action-error"
|
|
162
|
+
error={describe({
|
|
163
|
+
code: LISTINGS_I18N_KEYS.unknownError,
|
|
164
|
+
params: {},
|
|
165
|
+
status: 0,
|
|
166
|
+
message: undefined,
|
|
167
|
+
language: undefined,
|
|
168
|
+
})}
|
|
169
|
+
/>
|
|
170
|
+
) : null}
|
|
171
|
+
</Flex>
|
|
172
|
+
}
|
|
173
|
+
/>
|
|
174
|
+
</List.Item>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface MyListingsPaneProps extends ThemeModeProp {
|
|
179
|
+
/** How the host gets the caller's own rows. Absent: the pane says the
|
|
180
|
+
* contract cannot answer, and still shows the counts. */
|
|
181
|
+
readonly source?: MyListingsSource;
|
|
182
|
+
readonly onEdit?: (id: number) => void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
186
|
+
const t = useT();
|
|
187
|
+
const describe = useDescribeFlowError();
|
|
188
|
+
const bag = useMyListings(
|
|
189
|
+
props.source !== undefined ? { source: props.source } : {}
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<ListingsSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
194
|
+
<Flex vertical gap={16} data-testid="listings-mine">
|
|
195
|
+
<Typography.Title level={3}>
|
|
196
|
+
{t(LISTINGS_I18N_KEYS.mineTitle)}
|
|
197
|
+
</Typography.Title>
|
|
198
|
+
|
|
199
|
+
{!bag.gate.available ? (
|
|
200
|
+
<Alert
|
|
201
|
+
type="info"
|
|
202
|
+
showIcon
|
|
203
|
+
data-testid="listings-mine-blocked"
|
|
204
|
+
message={t(bag.gate.block.code, bag.gate.block.params)}
|
|
205
|
+
/>
|
|
206
|
+
) : null}
|
|
207
|
+
|
|
208
|
+
<Tabs
|
|
209
|
+
activeKey={bag.tab}
|
|
210
|
+
data-testid="listings-mine-tabs"
|
|
211
|
+
onChange={(key) => {
|
|
212
|
+
bag.setTab(key as MyListingsTab);
|
|
213
|
+
}}
|
|
214
|
+
items={bag.tabs.map((tab) => ({
|
|
215
|
+
key: tab,
|
|
216
|
+
label: (
|
|
217
|
+
<Space size={6}>
|
|
218
|
+
{t(TAB_LABEL[tab])}
|
|
219
|
+
{matchLoad(bag.counters, {
|
|
220
|
+
loading: () => null,
|
|
221
|
+
// A count we could not fetch is not zero. The badge is
|
|
222
|
+
// simply absent and the failure is stated once, below.
|
|
223
|
+
failed: () => null,
|
|
224
|
+
ready: (counters) => (
|
|
225
|
+
<Badge
|
|
226
|
+
count={counters[tab]}
|
|
227
|
+
showZero
|
|
228
|
+
data-testid={`listings-mine-count-${tab}`}
|
|
229
|
+
/>
|
|
230
|
+
),
|
|
231
|
+
})}
|
|
232
|
+
</Space>
|
|
233
|
+
),
|
|
234
|
+
}))}
|
|
235
|
+
/>
|
|
236
|
+
|
|
237
|
+
{matchLoad(bag.counters, {
|
|
238
|
+
loading: () => null,
|
|
239
|
+
failed: () => (
|
|
240
|
+
<Typography.Text type="secondary" data-testid="listings-mine-counters-failed">
|
|
241
|
+
{t(LISTINGS_I18N_KEYS.mineCountersFailed)}
|
|
242
|
+
</Typography.Text>
|
|
243
|
+
),
|
|
244
|
+
ready: () => null,
|
|
245
|
+
})}
|
|
246
|
+
|
|
247
|
+
{matchList(bag.rows, {
|
|
248
|
+
loading: () => (
|
|
249
|
+
<Flex justify="center" data-testid="listings-mine-loading">
|
|
250
|
+
<Spin aria-label={t(LISTINGS_I18N_KEYS.mineLoading)} />
|
|
251
|
+
</Flex>
|
|
252
|
+
),
|
|
253
|
+
failed: () => (
|
|
254
|
+
<ErrorAlert
|
|
255
|
+
testId="listings-mine-error"
|
|
256
|
+
error={describe({
|
|
257
|
+
code: LISTINGS_I18N_KEYS.mineLoadFailed,
|
|
258
|
+
params: {},
|
|
259
|
+
status: 0,
|
|
260
|
+
message: undefined,
|
|
261
|
+
language: undefined,
|
|
262
|
+
})}
|
|
263
|
+
action={
|
|
264
|
+
<Button
|
|
265
|
+
size="small"
|
|
266
|
+
data-analytics="none"
|
|
267
|
+
data-analytics-reason="retrying a read the person already asked for; not a business action"
|
|
268
|
+
onClick={bag.refetch}
|
|
269
|
+
>
|
|
270
|
+
{t(LISTINGS_I18N_KEYS.mineRetry)}
|
|
271
|
+
</Button>
|
|
272
|
+
}
|
|
273
|
+
/>
|
|
274
|
+
),
|
|
275
|
+
empty: () => (
|
|
276
|
+
<Empty
|
|
277
|
+
data-testid="listings-mine-empty"
|
|
278
|
+
description={t(LISTINGS_I18N_KEYS.mineEmpty)}
|
|
279
|
+
/>
|
|
280
|
+
),
|
|
281
|
+
ready: (rows) => (
|
|
282
|
+
<List
|
|
283
|
+
dataSource={[...rows]}
|
|
284
|
+
rowKey={(row) => row.id}
|
|
285
|
+
renderItem={(row) => (
|
|
286
|
+
<MyListingRow
|
|
287
|
+
listing={row}
|
|
288
|
+
{...(props.onEdit !== undefined ? { onEdit: props.onEdit } : {})}
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
/>
|
|
292
|
+
),
|
|
293
|
+
})}
|
|
294
|
+
|
|
295
|
+
{/* The named gap. `matchList`'s `failed` arm above already renders
|
|
296
|
+
"we could not load"; this states WHY once, in the pane, because
|
|
297
|
+
the reason is a property of this deployment and not of this
|
|
298
|
+
request. */}
|
|
299
|
+
{props.source === undefined ? (
|
|
300
|
+
<Alert
|
|
301
|
+
type="warning"
|
|
302
|
+
showIcon
|
|
303
|
+
data-testid="listings-mine-source-missing"
|
|
304
|
+
message={t(LISTINGS_I18N_KEYS.mineSourceMissing)}
|
|
305
|
+
/>
|
|
306
|
+
) : null}
|
|
307
|
+
|
|
308
|
+
<Space>
|
|
309
|
+
<Button
|
|
310
|
+
size="small"
|
|
311
|
+
disabled={!bag.prevPage.available}
|
|
312
|
+
data-testid="listings-mine-prev"
|
|
313
|
+
data-analytics="none"
|
|
314
|
+
data-analytics-reason="paging a list the person is already reading; not a business action"
|
|
315
|
+
onClick={bag.goPrev}
|
|
316
|
+
>
|
|
317
|
+
{t(LISTINGS_I18N_KEYS.pagePrev)}
|
|
318
|
+
</Button>
|
|
319
|
+
<Button
|
|
320
|
+
size="small"
|
|
321
|
+
disabled={!bag.nextPage.available}
|
|
322
|
+
data-testid="listings-mine-next"
|
|
323
|
+
data-analytics="none"
|
|
324
|
+
data-analytics-reason="paging a list the person is already reading; not a business action"
|
|
325
|
+
onClick={bag.goNext}
|
|
326
|
+
>
|
|
327
|
+
{t(LISTINGS_I18N_KEYS.pageNext)}
|
|
328
|
+
</Button>
|
|
329
|
+
</Space>
|
|
330
|
+
</Flex>
|
|
331
|
+
</ListingsSkinTheme>
|
|
332
|
+
);
|
|
333
|
+
}
|