@stapel/listings-react 0.4.0 → 0.8.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 +180 -0
- package/dist/api/generated/schema.d.ts +94 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FavoritesPane.d.ts +40 -3
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +30 -18
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +81 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +97 -27
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +163 -23
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +85 -43
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +41 -4
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +38 -25
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +13 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +47 -9
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -0
- package/dist/default/ListingPrice.d.ts.map +1 -0
- package/dist/default/ListingPrice.js +60 -0
- package/dist/default/ListingPrice.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +7 -28
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +136 -67
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/SignInLink.js +8 -7
- package/dist/default/SignInLink.js.map +1 -1
- package/dist/default/StatusTags.d.ts +16 -5
- package/dist/default/StatusTags.d.ts.map +1 -1
- package/dist/default/StatusTags.js +19 -13
- package/dist/default/StatusTags.js.map +1 -1
- package/dist/default/index.d.ts +15 -9
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -6
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Favorites.d.ts +6 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +23 -7
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +12 -0
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +3 -0
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +23 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +33 -7
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +7 -5
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/headless/useMandateGate.d.ts +33 -2
- package/dist/headless/useMandateGate.d.ts.map +1 -1
- package/dist/headless/useMandateGate.js +42 -4
- package/dist/headless/useMandateGate.js.map +1 -1
- package/dist/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +23 -5
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +30 -3
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +47 -7
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +16 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +35 -5
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/draft.d.ts +9 -6
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +3 -1
- package/dist/model/draft.js.map +1 -1
- package/dist/model/features.d.ts +21 -7
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +26 -0
- package/dist/model/features.js.map +1 -1
- package/llms.txt +9 -8
- package/manifest.json +82 -31
- package/nav-manifest.json +1 -1
- package/package.json +19 -14
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +94 -4
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +283 -116
- package/src/default/ListingComposerPage.tsx +403 -187
- package/src/default/ListingDetailPane.tsx +200 -98
- package/src/default/ListingPhoto.tsx +89 -16
- package/src/default/ListingPrice.tsx +77 -0
- package/src/default/MyListingsPane.tsx +286 -228
- package/src/default/SignInLink.tsx +4 -4
- package/src/default/StatusTags.tsx +36 -18
- package/src/default/index.ts +18 -7
- package/src/headless/Favorites.tsx +36 -7
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/ListingDetail.tsx +12 -5
- package/src/headless/useMandateGate.ts +49 -5
- package/src/i18n/es.ts +29 -5
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +7 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +54 -7
- package/src/i18n/ru.ts +41 -5
- package/src/index.ts +5 -1
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -25
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -40
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -67
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<ListingDetailPane>` — the listing page
|
|
2
|
+
* `<ListingDetailPane>` — the listing page, and the page a marketplace makes
|
|
3
|
+
* its money on.
|
|
3
4
|
*
|
|
4
5
|
* Four distinct absences, four distinct sentences, and the whole point of the
|
|
5
6
|
* component is that none of them collapses into another:
|
|
@@ -14,31 +15,82 @@
|
|
|
14
15
|
* detail endpoint has no `published()` filter, so a draft answers 200 to
|
|
15
16
|
* anyone holding the id. The pane says which side of that it is on rather
|
|
16
17
|
* than dressing a draft up as a shop page.
|
|
18
|
+
*
|
|
19
|
+
* ── The page has ONE primary action, and it depends on who is reading ──────
|
|
20
|
+
*
|
|
21
|
+
* For two releases the only control here was "Save to favourites" — the money
|
|
22
|
+
* screen of a marketplace with no way to reach the seller, and the owner's own
|
|
23
|
+
* copy of the page offering to favourite their own listing. So:
|
|
24
|
+
*
|
|
25
|
+
* a buyer → `contactSlot` (the container's `@stapel/chat-react`
|
|
26
|
+
* "message the seller" button), with favouriting beside it as
|
|
27
|
+
* the secondary it always was;
|
|
28
|
+
* the owner → Edit and Take down, and no contact button at all — you do not
|
|
29
|
+
* message yourself.
|
|
30
|
+
*
|
|
31
|
+
* `contactSlot` is a SLOT because conversations belong to another L2 pair and
|
|
32
|
+
* L2 pairs do not import each other. Unfilled it renders `<SlotPlaceholder>`,
|
|
33
|
+
* so an app wired without a chat is a named gap in a dev build rather than a
|
|
34
|
+
* page whose only verb is "save".
|
|
17
35
|
*/
|
|
18
36
|
import type { ReactElement, ReactNode } from "react";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
37
|
+
import { Button, Descriptions, Divider, Flex, Typography } from "antd";
|
|
38
|
+
import {
|
|
39
|
+
ErrorAlert,
|
|
40
|
+
EmptyState,
|
|
41
|
+
GatedButton,
|
|
42
|
+
GatedControl,
|
|
43
|
+
SkinTheme,
|
|
44
|
+
} from "@stapel/tokens-antd/skin";
|
|
45
|
+
import {
|
|
46
|
+
SlotPlaceholder,
|
|
47
|
+
matchLoad,
|
|
48
|
+
useI18n,
|
|
49
|
+
useT,
|
|
50
|
+
} from "@stapel/core";
|
|
51
|
+
import { spacing } from "@stapel/tokens";
|
|
21
52
|
import { FeatureValueList } from "@stapel/attributes-react/default";
|
|
22
53
|
import { formatFeatureValue } from "@stapel/attributes-react";
|
|
23
54
|
import { useListingDetail } from "../headless/ListingDetail.js";
|
|
55
|
+
import { useListingActions } from "../headless/ListingActions.js";
|
|
24
56
|
import { asFeatureDaoList, featuresDtoFromDaoList } from "../model/features.js";
|
|
25
57
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
26
|
-
import { ErrorAlert } from "./ErrorAlert.js";
|
|
27
58
|
import { HeartIcon } from "./icons.js";
|
|
28
59
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
60
|
+
import { ListingPrice } from "./ListingPrice.js";
|
|
29
61
|
import { ListingStatusBlock } from "./StatusTags.js";
|
|
30
|
-
import { ListingsSkinTheme } from "./theme.js";
|
|
31
62
|
import type { ThemeModeProp } from "./types.js";
|
|
32
63
|
|
|
64
|
+
/** The reading measure of the page body. A detail page is prose plus a spec
|
|
65
|
+
* table; past this it stops being one column and starts being a stripe across
|
|
66
|
+
* a 2560px pane. */
|
|
67
|
+
export const DETAIL_MEASURE = "60rem";
|
|
68
|
+
|
|
69
|
+
/** The narrowest a gallery tile may get before the grid drops a column. A
|
|
70
|
+
* measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
|
|
71
|
+
* which is §83's geometry rule — one photo per row on a phone, three on a
|
|
72
|
+
* desktop pane, and no `width: 320` that is near-full-bleed on one and a
|
|
73
|
+
* postage stamp on the other. */
|
|
74
|
+
export const DETAIL_PHOTO_MIN = "14rem";
|
|
75
|
+
|
|
33
76
|
export interface ListingDetailPaneProps extends ThemeModeProp {
|
|
34
77
|
readonly id: number;
|
|
35
78
|
/** The reader's own uuid, when the host knows it. Enables the owner view —
|
|
36
79
|
* the only place the moderation axis is shown, because it is the only
|
|
37
80
|
* person it concerns. */
|
|
38
81
|
readonly viewerId?: string;
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
82
|
+
/**
|
|
83
|
+
* THE primary action for a buyer: "message the seller", filled by the
|
|
84
|
+
* container from `@stapel/chat-react`. Rendered first, before favouriting,
|
|
85
|
+
* and never shown to the owner.
|
|
86
|
+
*/
|
|
87
|
+
readonly contactSlot?: ReactNode;
|
|
88
|
+
/** Open the composer on this listing — the owner's primary. Absent is a real
|
|
89
|
+
* answer: the button then states that this app has no editing screen. */
|
|
90
|
+
readonly onEdit?: (id: number) => void;
|
|
91
|
+
/** Extra chrome beside the primary (the seller's profile link, a share
|
|
92
|
+
* button). Cross-pair navigation is the container's job (spec §6.2 item 5),
|
|
93
|
+
* so this pair takes nodes rather than routes. */
|
|
42
94
|
readonly actions?: ReactNode;
|
|
43
95
|
readonly footer?: ReactNode;
|
|
44
96
|
}
|
|
@@ -46,53 +98,57 @@ export interface ListingDetailPaneProps extends ThemeModeProp {
|
|
|
46
98
|
export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
47
99
|
const t = useT();
|
|
48
100
|
const { locale } = useI18n();
|
|
49
|
-
const describe = useDescribeFlowError();
|
|
50
101
|
const bag = useListingDetail(
|
|
51
102
|
props.id,
|
|
52
103
|
props.viewerId !== undefined ? { viewerId: props.viewerId } : {}
|
|
53
104
|
);
|
|
105
|
+
const owner = bag.viewerIsOwner === true;
|
|
106
|
+
const actions = useListingActions(props.id, bag.status?.lifecycle.status);
|
|
107
|
+
const editGate = actions.editGate(props.onEdit !== undefined);
|
|
108
|
+
|
|
109
|
+
const favoriteLabel = t(
|
|
110
|
+
bag.isFavorited === true
|
|
111
|
+
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
112
|
+
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
113
|
+
);
|
|
54
114
|
|
|
55
115
|
return (
|
|
56
|
-
<
|
|
57
|
-
|
|
116
|
+
<SkinTheme
|
|
117
|
+
surface="base"
|
|
118
|
+
style={{ maxWidth: DETAIL_MEASURE, padding: spacing[4] }}
|
|
119
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
120
|
+
>
|
|
121
|
+
<Flex vertical gap={spacing[4]} data-testid="listings-detail">
|
|
58
122
|
{bag.removed ? (
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
showIcon
|
|
62
|
-
data-testid="listings-detail-removed"
|
|
123
|
+
<ErrorAlert
|
|
124
|
+
testId="listings-detail-removed"
|
|
63
125
|
message={t(LISTINGS_I18N_KEYS.detailRemoved)}
|
|
64
126
|
/>
|
|
65
127
|
) : null}
|
|
66
128
|
|
|
67
129
|
{matchLoad(bag.state, {
|
|
68
130
|
loading: () => (
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
131
|
+
<div
|
|
132
|
+
role="status"
|
|
133
|
+
aria-busy="true"
|
|
134
|
+
aria-label={t(LISTINGS_I18N_KEYS.detailLoading)}
|
|
135
|
+
data-testid="listings-detail-loading"
|
|
136
|
+
data-stapel-load-state="loading"
|
|
137
|
+
/>
|
|
72
138
|
),
|
|
73
|
-
failed: () =>
|
|
74
|
-
bag.removed ? null : (
|
|
139
|
+
failed: (error) =>
|
|
140
|
+
bag.removed ? null : bag.notFound ? (
|
|
141
|
+
<EmptyState
|
|
142
|
+
testId="listings-detail-error"
|
|
143
|
+
title={t(LISTINGS_I18N_KEYS.detailNotFound)}
|
|
144
|
+
/>
|
|
145
|
+
) : (
|
|
75
146
|
<ErrorAlert
|
|
76
147
|
testId="listings-detail-error"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}
|
|
148
|
+
thrown={error}
|
|
149
|
+
message={t(LISTINGS_I18N_KEYS.detailLoadFailed)}
|
|
150
|
+
onRetry={bag.refetch}
|
|
151
|
+
retryLabel={t(LISTINGS_I18N_KEYS.detailRetry)}
|
|
96
152
|
/>
|
|
97
153
|
),
|
|
98
154
|
ready: (listing) => (
|
|
@@ -101,34 +157,39 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
101
157
|
else's: a buyer has no use for "changes under review", and
|
|
102
158
|
showing a stranger that a listing was refused would leak a
|
|
103
159
|
verdict about someone else's content. */}
|
|
104
|
-
{
|
|
105
|
-
<Flex vertical gap={
|
|
160
|
+
{owner && bag.status !== undefined ? (
|
|
161
|
+
<Flex vertical gap={spacing[2]} data-testid="listings-detail-owner-view">
|
|
106
162
|
<ListingStatusBlock status={bag.status} />
|
|
107
163
|
{!bag.publiclyVisible ? (
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
message={t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
|
|
112
|
-
/>
|
|
164
|
+
<Typography.Text type="secondary">
|
|
165
|
+
{t(LISTINGS_I18N_KEYS.detailOwnerOnlyView)}
|
|
166
|
+
</Typography.Text>
|
|
113
167
|
) : null}
|
|
114
168
|
</Flex>
|
|
115
169
|
) : null}
|
|
116
170
|
|
|
117
|
-
{
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
showIcon
|
|
121
|
-
data-testid="listings-detail-not-published"
|
|
171
|
+
{!owner && !bag.publiclyVisible ? (
|
|
172
|
+
<ErrorAlert
|
|
173
|
+
testId="listings-detail-not-published"
|
|
122
174
|
message={t(LISTINGS_I18N_KEYS.detailNotPublished)}
|
|
175
|
+
variant="inline"
|
|
123
176
|
/>
|
|
124
177
|
) : null}
|
|
125
178
|
|
|
126
|
-
|
|
179
|
+
{/* Element-width tiles: the grid decides how many fit, the
|
|
180
|
+
photos fill them. */}
|
|
181
|
+
<div
|
|
182
|
+
data-testid="listings-detail-gallery"
|
|
183
|
+
style={{
|
|
184
|
+
display: "grid",
|
|
185
|
+
gridTemplateColumns: `repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr))`,
|
|
186
|
+
gap: spacing[3],
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
127
189
|
{bag.images.length === 0 ? (
|
|
128
190
|
<ListingPhoto
|
|
129
191
|
imageRef={undefined}
|
|
130
192
|
alt={listing.title ?? String(listing.id)}
|
|
131
|
-
style={{ width: 320, aspectRatio: "4 / 3" }}
|
|
132
193
|
/>
|
|
133
194
|
) : (
|
|
134
195
|
bag.images.map((ref, index) => (
|
|
@@ -139,11 +200,10 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
139
200
|
index: index + 1,
|
|
140
201
|
total: bag.images.length,
|
|
141
202
|
})}
|
|
142
|
-
style={{ width: 320, aspectRatio: "4 / 3" }}
|
|
143
203
|
/>
|
|
144
204
|
))
|
|
145
205
|
)}
|
|
146
|
-
</
|
|
206
|
+
</div>
|
|
147
207
|
|
|
148
208
|
<Typography.Title level={3} data-testid="listings-detail-title">
|
|
149
209
|
{listing.title ?? ""}
|
|
@@ -163,48 +223,90 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
163
223
|
) : null}
|
|
164
224
|
|
|
165
225
|
<Typography.Title level={4} data-testid="listings-detail-price">
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
226
|
+
<ListingPrice
|
|
227
|
+
amount={listing.price}
|
|
228
|
+
{...(listing.currency !== undefined
|
|
229
|
+
? { currency: listing.currency }
|
|
230
|
+
: {})}
|
|
231
|
+
/>
|
|
169
232
|
</Typography.Title>
|
|
170
233
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
234
|
+
{/* The buy box. One primary, and which one depends on who is
|
|
235
|
+
reading this page. */}
|
|
236
|
+
<Flex
|
|
237
|
+
wrap
|
|
238
|
+
gap={spacing[3]}
|
|
239
|
+
align="flex-start"
|
|
240
|
+
data-testid="listings-detail-actions"
|
|
241
|
+
>
|
|
242
|
+
{owner ? (
|
|
243
|
+
<>
|
|
244
|
+
<GatedButton
|
|
245
|
+
gate={editGate}
|
|
246
|
+
type="primary"
|
|
247
|
+
testId="listings-detail-edit"
|
|
248
|
+
data-analytics="none"
|
|
249
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
250
|
+
onClick={() => {
|
|
251
|
+
props.onEdit?.(props.id);
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{t(LISTINGS_I18N_KEYS.detailEdit)}
|
|
255
|
+
</GatedButton>
|
|
256
|
+
<GatedButton
|
|
257
|
+
gate={actions.archive}
|
|
258
|
+
danger
|
|
259
|
+
testId="listings-detail-take-down"
|
|
260
|
+
data-analytics="none"
|
|
261
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
262
|
+
onClick={actions.doArchive}
|
|
263
|
+
>
|
|
264
|
+
{t(LISTINGS_I18N_KEYS.detailTakeDown)}
|
|
265
|
+
</GatedButton>
|
|
266
|
+
</>
|
|
267
|
+
) : (
|
|
268
|
+
<div data-testid="listings-detail-contact">
|
|
269
|
+
{props.contactSlot ?? <SlotPlaceholder name="contactSlot" />}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{/* Favouriting your own listing is not a thing anyone does;
|
|
274
|
+
for everyone else it is the secondary it always was. */}
|
|
275
|
+
{owner ? null : (
|
|
276
|
+
<GatedControl
|
|
277
|
+
gate={bag.favoriteGate}
|
|
278
|
+
testId="listings-detail-favorite-gate"
|
|
279
|
+
>
|
|
280
|
+
{(bind) => (
|
|
281
|
+
<Button
|
|
282
|
+
disabled={bind.disabled}
|
|
283
|
+
data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
|
|
284
|
+
{...(bind["aria-describedby"] !== undefined
|
|
285
|
+
? { "aria-describedby": bind["aria-describedby"] }
|
|
286
|
+
: {})}
|
|
287
|
+
aria-label={favoriteLabel}
|
|
288
|
+
aria-pressed={bag.isFavorited === true}
|
|
289
|
+
icon={<HeartIcon filled={bag.isFavorited === true} />}
|
|
290
|
+
data-testid="listings-detail-favorite"
|
|
291
|
+
data-analytics="none"
|
|
292
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
293
|
+
onClick={bag.toggleFavorite}
|
|
294
|
+
>
|
|
295
|
+
{favoriteLabel}
|
|
296
|
+
</Button>
|
|
297
|
+
)}
|
|
298
|
+
</GatedControl>
|
|
299
|
+
)}
|
|
300
|
+
|
|
192
301
|
{props.actions}
|
|
193
|
-
</
|
|
302
|
+
</Flex>
|
|
194
303
|
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
data-testid="listings-detail-favorite-blocked"
|
|
202
|
-
>
|
|
203
|
-
{t(
|
|
204
|
-
bag.favoriteGate.block.code,
|
|
205
|
-
bag.favoriteGate.block.params
|
|
206
|
-
)}
|
|
207
|
-
</Typography.Text>
|
|
304
|
+
{actions.error !== undefined && actions.error !== null ? (
|
|
305
|
+
<ErrorAlert
|
|
306
|
+
testId="listings-detail-action-error"
|
|
307
|
+
thrown={actions.error}
|
|
308
|
+
variant="inline"
|
|
309
|
+
/>
|
|
208
310
|
) : null}
|
|
209
311
|
|
|
210
312
|
<Divider />
|
|
@@ -235,14 +337,14 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
235
337
|
{/* Counted, not rounded to zero: a stored attribute this build
|
|
236
338
|
cannot key is a gap in what the buyer is being told. */}
|
|
237
339
|
{bag.unreadableFeatures > 0 ? (
|
|
238
|
-
<
|
|
340
|
+
<Typography.Text
|
|
239
341
|
type="warning"
|
|
240
|
-
showIcon
|
|
241
342
|
data-testid="listings-detail-unreadable"
|
|
242
|
-
|
|
343
|
+
>
|
|
344
|
+
{t(LISTINGS_I18N_KEYS.detailUnreadableFeatures, {
|
|
243
345
|
count: bag.unreadableFeatures,
|
|
244
346
|
})}
|
|
245
|
-
|
|
347
|
+
</Typography.Text>
|
|
246
348
|
) : null}
|
|
247
349
|
|
|
248
350
|
<Descriptions size="small" column={1}>
|
|
@@ -271,6 +373,6 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
271
373
|
),
|
|
272
374
|
})}
|
|
273
375
|
</Flex>
|
|
274
|
-
</
|
|
376
|
+
</SkinTheme>
|
|
275
377
|
);
|
|
276
378
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A stored CDN reference, drawn — or
|
|
2
|
+
* A stored CDN reference, drawn — or a designed placeholder saying why not.
|
|
3
3
|
*
|
|
4
4
|
* `Listing.images` carries opaque `<type>/<hash>` strings and no contract in
|
|
5
5
|
* this fleet resolves a stranger's reference (`model/runtime.ts` argues it at
|
|
@@ -8,18 +8,33 @@
|
|
|
8
8
|
* says so instead of emitting a broken `<img>`. An empty grey box that never
|
|
9
9
|
* loads teaches a person nothing; a sentence gets the wiring fixed.
|
|
10
10
|
*
|
|
11
|
+
* The placeholder is TOKENISED and theme-aware. It was antd's `<Empty>`, whose
|
|
12
|
+
* illustration is a flat `#d9d9d9` slab: it glared white on every dark page in
|
|
13
|
+
* the visual pass and read as a broken image rather than as a designed "no
|
|
14
|
+
* photo yet". It is now the aspect-ratio box the photo would have occupied,
|
|
15
|
+
* painted from `colorFillQuaternary` with a camera glyph in
|
|
16
|
+
* `colorTextQuaternary`, so it belongs to whichever side the theme is on and
|
|
17
|
+
* never shifts the layout when a real photo lands.
|
|
18
|
+
*
|
|
11
19
|
* When the resolver DOES answer, `@stapel/image`'s `<Image>` takes over: it
|
|
12
20
|
* measures the slot, picks the variant tier that fits, blur-ups from
|
|
13
21
|
* `preview_b64` and never downgrades on a re-measure. None of that logic
|
|
14
22
|
* belongs here — it is why `@stapel/image` exists.
|
|
15
23
|
*/
|
|
16
24
|
import type { CSSProperties, ReactElement } from "react";
|
|
17
|
-
import {
|
|
25
|
+
import { useMemo } from "react";
|
|
26
|
+
import { Typography, theme as antdTheme } from "antd";
|
|
18
27
|
import { Image } from "@stapel/image";
|
|
19
28
|
import { useT } from "@stapel/core";
|
|
29
|
+
import { radii, spacing } from "@stapel/tokens";
|
|
20
30
|
import { useListingsRuntime } from "../model/context.js";
|
|
21
31
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
22
32
|
|
|
33
|
+
/** The default shape of a listing photo box everywhere in this skin. Declared
|
|
34
|
+
* once so a card, a detail hero and a dashboard thumbnail cannot drift into
|
|
35
|
+
* three different crops. */
|
|
36
|
+
export const LISTING_PHOTO_ASPECT = "4 / 3";
|
|
37
|
+
|
|
23
38
|
export interface ListingPhotoProps {
|
|
24
39
|
/** The stored reference, or `undefined` for a listing with no photos. */
|
|
25
40
|
readonly imageRef: string | undefined;
|
|
@@ -28,26 +43,78 @@ export interface ListingPhotoProps {
|
|
|
28
43
|
readonly style?: CSSProperties;
|
|
29
44
|
}
|
|
30
45
|
|
|
46
|
+
/** A camera outline in `currentColor` — the house convention (`icons.tsx`):
|
|
47
|
+
* no icon dependency, and it inherits the theme rather than carrying a
|
|
48
|
+
* colour. `aria-hidden` because the sentence beside it is the label. */
|
|
49
|
+
function CameraGlyph(): ReactElement {
|
|
50
|
+
return (
|
|
51
|
+
<svg
|
|
52
|
+
width="24"
|
|
53
|
+
height="24"
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth="1.5"
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
strokeLinejoin="round"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
>
|
|
62
|
+
<path d="M3 8.5A2.5 2.5 0 0 1 5.5 6h1.7l1.2-2h6.2l1.2 2h1.7A2.5 2.5 0 0 1 20 8.5v8A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5z" />
|
|
63
|
+
<circle cx="12" cy="12.5" r="3.2" />
|
|
64
|
+
</svg>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
31
68
|
export function ListingPhoto(props: ListingPhotoProps): ReactElement {
|
|
32
69
|
const t = useT();
|
|
70
|
+
const { token } = antdTheme.useToken();
|
|
33
71
|
const runtime = useListingsRuntime();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
72
|
+
// Memoised on the REFERENCE, not called in render. A host resolver is a
|
|
73
|
+
// plain function returning a fresh object (`resolveImage: (ref) => ({ … })`
|
|
74
|
+
// is the documented shape), so calling it inline handed `<Image>` a new
|
|
75
|
+
// `meta` identity on every render of this card — which is a load `<Image>`
|
|
76
|
+
// then has to decide is or is not the same one. It defends itself now, and
|
|
77
|
+
// a caller still should not manufacture the churn.
|
|
78
|
+
const resolve = runtime.resolveImage;
|
|
79
|
+
const imageRef = props.imageRef;
|
|
80
|
+
const meta = useMemo(
|
|
81
|
+
() => (imageRef === undefined || resolve === undefined ? undefined : resolve(imageRef)),
|
|
82
|
+
[imageRef, resolve]
|
|
83
|
+
);
|
|
38
84
|
|
|
39
85
|
if (meta === undefined) {
|
|
86
|
+
const caption = t(
|
|
87
|
+
props.imageRef === undefined
|
|
88
|
+
? LISTINGS_I18N_KEYS.cardNoPhoto
|
|
89
|
+
: LISTINGS_I18N_KEYS.cardPhotoUnavailable
|
|
90
|
+
);
|
|
40
91
|
return (
|
|
41
|
-
<
|
|
42
|
-
|
|
92
|
+
<div
|
|
93
|
+
role="img"
|
|
94
|
+
aria-label={caption}
|
|
43
95
|
data-testid="listings-photo-absent"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
96
|
+
style={{
|
|
97
|
+
display: "flex",
|
|
98
|
+
flexDirection: "column",
|
|
99
|
+
alignItems: "center",
|
|
100
|
+
justifyContent: "center",
|
|
101
|
+
gap: spacing[1],
|
|
102
|
+
width: "100%",
|
|
103
|
+
aspectRatio: LISTING_PHOTO_ASPECT,
|
|
104
|
+
background: token.colorFillQuaternary,
|
|
105
|
+
color: token.colorTextQuaternary,
|
|
106
|
+
borderRadius: radii.md,
|
|
107
|
+
...props.style,
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<CameraGlyph />
|
|
111
|
+
<Typography.Text
|
|
112
|
+
type="secondary"
|
|
113
|
+
style={{ fontSize: token.fontSizeSM, textAlign: "center" }}
|
|
114
|
+
>
|
|
115
|
+
{caption}
|
|
116
|
+
</Typography.Text>
|
|
117
|
+
</div>
|
|
51
118
|
);
|
|
52
119
|
}
|
|
53
120
|
|
|
@@ -56,7 +123,13 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
|
|
|
56
123
|
meta={meta}
|
|
57
124
|
alt={props.alt}
|
|
58
125
|
data-testid="listings-photo"
|
|
59
|
-
|
|
126
|
+
style={{
|
|
127
|
+
width: "100%",
|
|
128
|
+
aspectRatio: LISTING_PHOTO_ASPECT,
|
|
129
|
+
objectFit: "cover",
|
|
130
|
+
borderRadius: radii.md,
|
|
131
|
+
...props.style,
|
|
132
|
+
}}
|
|
60
133
|
/>
|
|
61
134
|
);
|
|
62
135
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ListingPrice>` — the asking price, formatted as money.
|
|
3
|
+
*
|
|
4
|
+
* ── The defect this closes ─────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Every price on this pair's screens was `` `${price} ${currency}` `` —
|
|
7
|
+
* `4500.00 RUB`. An ISO code where a glyph belongs, no thousands grouping, a
|
|
8
|
+
* forced `.00` on a whole number, and the same string in `ru` and `es` as in
|
|
9
|
+
* `en`. `@stapel/currencies-react` exists precisely so no pair spells that
|
|
10
|
+
* template literal again: `formatMoney` is `Intl.NumberFormat` over a decimal
|
|
11
|
+
* STRING (never a `number` — `4500.10` is not a float), per locale and per the
|
|
12
|
+
* currency's own minor units.
|
|
13
|
+
*
|
|
14
|
+
* ── Two branches, because the catalogue is optional ────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* A deployment that mounted `<CurrenciesProvider>` has the catalogue, so the
|
|
17
|
+
* price gets the currency's real glyph even where the locale carries none
|
|
18
|
+
* (`en` has no `₽`) and the formatting is the one the rest of that host's
|
|
19
|
+
* money uses. A host that did not mount it still gets a properly formatted,
|
|
20
|
+
* properly localized number through the pure `formatMoney` — the pair does not
|
|
21
|
+
* require a second module to render a price, and it does not silently fall
|
|
22
|
+
* back to the template literal either.
|
|
23
|
+
*
|
|
24
|
+
* The branch is decided by a context READ, and each arm is its own component,
|
|
25
|
+
* so no hook is called conditionally. `@stapel/currencies-react` is an
|
|
26
|
+
* OPTIONAL peer for exactly this reason: absent, only the plain arm ever
|
|
27
|
+
* mounts.
|
|
28
|
+
*/
|
|
29
|
+
import { useContext } from "react";
|
|
30
|
+
import type { ReactElement } from "react";
|
|
31
|
+
import { useI18n, useT } from "@stapel/core";
|
|
32
|
+
import {
|
|
33
|
+
CurrenciesRuntimeContext,
|
|
34
|
+
formatMoney,
|
|
35
|
+
useMoney,
|
|
36
|
+
} from "@stapel/currencies-react";
|
|
37
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
38
|
+
|
|
39
|
+
export interface ListingPriceProps {
|
|
40
|
+
/** The amount as the wire spells it — a decimal string. */
|
|
41
|
+
readonly amount: string | undefined;
|
|
42
|
+
/** The code the amount is quoted in (`Listing.currency`). */
|
|
43
|
+
readonly currency?: string | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is there a price at all? A listing may carry none, and "no price" is a
|
|
47
|
+
* sentence, not a zero. */
|
|
48
|
+
function hasAmount(props: ListingPriceProps): boolean {
|
|
49
|
+
return props.amount !== undefined && props.amount.length > 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function PlainPrice(props: ListingPriceProps): ReactElement {
|
|
53
|
+
const { locale } = useI18n();
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
{formatMoney(props.amount ?? "", (props.currency ?? "").toUpperCase(), {
|
|
57
|
+
locale,
|
|
58
|
+
})}
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function CataloguePrice(props: ListingPriceProps): ReactElement {
|
|
64
|
+
const money = useMoney();
|
|
65
|
+
return <>{money.format(props.amount ?? "", (props.currency ?? "").toUpperCase())}</>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The price as text — the caller owns the typography, so one component serves
|
|
70
|
+
* the card's strong line and the detail page's display heading.
|
|
71
|
+
*/
|
|
72
|
+
export function ListingPrice(props: ListingPriceProps): ReactElement {
|
|
73
|
+
const t = useT();
|
|
74
|
+
const wired = useContext(CurrenciesRuntimeContext) !== null;
|
|
75
|
+
if (!hasAmount(props)) return <>{t(LISTINGS_I18N_KEYS.cardPriceAbsent)}</>;
|
|
76
|
+
return wired ? <CataloguePrice {...props} /> : <PlainPrice {...props} />;
|
|
77
|
+
}
|