@stapel/listings-react 0.4.0 → 0.7.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 +102 -0
- package/dist/api/generated/schema.d.ts +2 -2
- 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 +25 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +19 -12
- 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 +4 -0
- 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/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +21 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +19 -2
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +35 -5
- 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 +33 -4
- package/dist/i18n/ru.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 +7 -6
- package/manifest.json +53 -11
- package/nav-manifest.json +1 -1
- package/package.json +17 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +2 -2
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +88 -46
- 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 +10 -0
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/i18n/es.ts +26 -4
- package/src/i18n/keys.ts +42 -5
- package/src/i18n/ru.ts +38 -4
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/listings-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Headless React pair for stapel-listings: a typed client over the draft twin, the composer that turns a category schema into a submittable listing, and the owner's dashboard. Two independent axes are rendered as two — lifecycle `status` decides visibility and `moderation_status` decides nothing about it, so an edit to a live listing stays live and says 'under review' instead of vanishing. Feature values are drawn and mirrored by @stapel/attributes-react; photos arrive as an injected upload bag whose `refs` ARE `images_draft` and whose `settled` gates the submit; a publish refusal is routed onto the control that caused it by slug. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,14 +53,14 @@
|
|
|
53
53
|
"limit": "11 KB"
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
|
-
"name": "default — the antd skin (card, detail, composer, dashboard) must stay out of the main bundle",
|
|
56
|
+
"name": "default — the antd skin (card, detail, composer, dashboard, the four named slots and the delete confirmation) must stay out of the main bundle",
|
|
57
57
|
"path": "dist/default/index.js",
|
|
58
|
-
"limit": "
|
|
58
|
+
"limit": "16 KB"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry",
|
|
62
62
|
"path": "dist/i18n/ru.js",
|
|
63
|
-
"limit": "
|
|
63
|
+
"limit": "5 KB"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"name": "i18n/es — the Spanish bundle is opt-in, not carried by the main entry",
|
|
@@ -70,14 +70,18 @@
|
|
|
70
70
|
],
|
|
71
71
|
"peerDependencies": {
|
|
72
72
|
"@stapel/attributes-react": ">=0.1.0",
|
|
73
|
-
"@stapel/core": ">=0.
|
|
73
|
+
"@stapel/core": ">=0.18.1 <1.0.0",
|
|
74
|
+
"@stapel/currencies-react": ">=0.2.0",
|
|
74
75
|
"@stapel/image": ">=0.2.0",
|
|
75
|
-
"@stapel/tokens-antd": ">=0.
|
|
76
|
+
"@stapel/tokens-antd": ">=0.7.0",
|
|
76
77
|
"@tanstack/react-query": "^5.0.0",
|
|
77
78
|
"antd": ">=5.20.0 <7",
|
|
78
79
|
"react": ">=19"
|
|
79
80
|
},
|
|
80
81
|
"peerDependenciesMeta": {
|
|
82
|
+
"@stapel/currencies-react": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
81
85
|
"@stapel/tokens-antd": {
|
|
82
86
|
"optional": true
|
|
83
87
|
},
|
|
@@ -98,12 +102,13 @@
|
|
|
98
102
|
"size-limit": "^11.2.0",
|
|
99
103
|
"typescript": "^5.8.3",
|
|
100
104
|
"vitest": "^3.2.4",
|
|
101
|
-
"@stapel/attributes-react": "^0.
|
|
102
|
-
"@stapel/core": "^0.
|
|
103
|
-
"@stapel/
|
|
104
|
-
"@stapel/
|
|
105
|
-
"@stapel/
|
|
106
|
-
"@stapel/tokens
|
|
105
|
+
"@stapel/attributes-react": "^0.3.1",
|
|
106
|
+
"@stapel/core": "^0.18.1",
|
|
107
|
+
"@stapel/currencies-react": "^0.2.0",
|
|
108
|
+
"@stapel/image": "^0.4.1",
|
|
109
|
+
"@stapel/showcase": "^0.3.0",
|
|
110
|
+
"@stapel/tokens": "^0.5.1",
|
|
111
|
+
"@stapel/tokens-antd": "^0.7.0"
|
|
107
112
|
},
|
|
108
113
|
"engines": {
|
|
109
114
|
"node": ">=22"
|
|
@@ -664,7 +664,7 @@ export interface components {
|
|
|
664
664
|
images_draft?: string[];
|
|
665
665
|
location_id_draft?: string;
|
|
666
666
|
location_label_draft?: string;
|
|
667
|
-
geohash_draft
|
|
667
|
+
readonly geohash_draft: string;
|
|
668
668
|
/** Format: decimal */
|
|
669
669
|
lat_draft?: string | null;
|
|
670
670
|
/** Format: decimal */
|
|
@@ -805,7 +805,7 @@ export interface components {
|
|
|
805
805
|
images_draft?: string[];
|
|
806
806
|
location_id_draft?: string;
|
|
807
807
|
location_label_draft?: string;
|
|
808
|
-
geohash_draft?: string;
|
|
808
|
+
readonly geohash_draft?: string;
|
|
809
809
|
/** Format: decimal */
|
|
810
810
|
lat_draft?: string | null;
|
|
811
811
|
/** Format: decimal */
|
|
@@ -6,19 +6,53 @@
|
|
|
6
6
|
* "You have not saved anything yet" and "we could not load your favourites"
|
|
7
7
|
* are the pair a `data ?? []` would have merged, and merging them is exactly
|
|
8
8
|
* the substitution that cost the fleet an incident (spec §7.4).
|
|
9
|
+
*
|
|
10
|
+
* ── A visitor is told ONE thing ────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* `useFavorites` disables the query without a mandate, so a visitor's `rows`
|
|
13
|
+
* sit in `loading` forever — and the pane rendered the blocked notice AND the
|
|
14
|
+
* spinner underneath it, saying "you cannot see this" and "we are fetching it"
|
|
15
|
+
* at the same time, with no sign-in link anywhere on the screen. The gate is
|
|
16
|
+
* now the WHOLE body: one designed state, with the door the container supplies
|
|
17
|
+
* (`signIn`) inside it, and nothing else drawn.
|
|
18
|
+
*
|
|
19
|
+
* The pager obeys the same rule. `Previous`/`Next` were rendered disabled in
|
|
20
|
+
* every state including empty, failed and blocked — two controls that meant
|
|
21
|
+
* nothing, three times over. They render when there is a page to go to.
|
|
22
|
+
*
|
|
23
|
+
* And each one renders only when ITS page exists. The keyset gates name the
|
|
24
|
+
* missing page with the button's own label key, so a blocked `Previous` drew
|
|
25
|
+
* the word twice — once as a dead button, once as its own "reason". A pager
|
|
26
|
+
* does not explain itself: the page indicator says where you are, and a
|
|
27
|
+
* direction with nowhere to go is simply not drawn.
|
|
9
28
|
*/
|
|
10
|
-
import type { ReactElement } from "react";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
29
|
+
import type { ReactElement, ReactNode } from "react";
|
|
30
|
+
import { Button, Flex, Typography } from "antd";
|
|
31
|
+
import {
|
|
32
|
+
EmptyState,
|
|
33
|
+
ErrorAlert,
|
|
34
|
+
SkinTheme,
|
|
35
|
+
} from "@stapel/tokens-antd/skin";
|
|
36
|
+
import { matchList, useT } from "@stapel/core";
|
|
37
|
+
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
38
|
+
import { spacing } from "@stapel/tokens";
|
|
14
39
|
import { useFavorites } from "../headless/Favorites.js";
|
|
15
40
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
16
|
-
import { ErrorAlert } from "./ErrorAlert.js";
|
|
17
41
|
import { ListingCard } from "./ListingCard.js";
|
|
18
42
|
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
19
|
-
import {
|
|
43
|
+
import { SignInLink } from "./SignInLink.js";
|
|
20
44
|
import type { ThemeModeProp } from "./types.js";
|
|
21
45
|
|
|
46
|
+
/** The narrowest a card may get before the grid drops a column — the same
|
|
47
|
+
* element-relative rule the detail gallery uses. It was `width: 240`, which
|
|
48
|
+
* left two cards floating in a 2560px pane and overflowed a 390px one. */
|
|
49
|
+
export const FAVORITES_CARD_MIN = "15rem";
|
|
50
|
+
|
|
51
|
+
/** How wide the saved grid may get before it stops being a grid and becomes
|
|
52
|
+
* three cards adrift in a 1280px window. A measure, not a breakpoint: the
|
|
53
|
+
* cards keep filling it, and past it the page centres. */
|
|
54
|
+
export const FAVORITES_MEASURE = "72rem";
|
|
55
|
+
|
|
22
56
|
/** How a card in this grid opens — the same one-contract union `<ListingCard>`
|
|
23
57
|
* takes, one level up, so a pane cannot re-introduce the double navigation the
|
|
24
58
|
* card no longer allows. */
|
|
@@ -41,7 +75,21 @@ export type FavoritesPaneOpenProps =
|
|
|
41
75
|
readonly linkComponent?: undefined;
|
|
42
76
|
};
|
|
43
77
|
|
|
44
|
-
|
|
78
|
+
/** The way OUT of an empty favourites list. */
|
|
79
|
+
export interface FavoritesBrowseCtaProp {
|
|
80
|
+
/**
|
|
81
|
+
* What a person with nothing saved can do next — a "Browse listings"
|
|
82
|
+
* button, supplied by the container because only it knows where the
|
|
83
|
+
* catalogue lives. An empty state without one is a dead end, and this
|
|
84
|
+
* screen was one of four sharing a single stock illustration and no exit.
|
|
85
|
+
*/
|
|
86
|
+
readonly browseCta?: ReactNode;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type FavoritesPaneProps = ThemeModeProp &
|
|
90
|
+
SignInCtaProp &
|
|
91
|
+
FavoritesBrowseCtaProp &
|
|
92
|
+
FavoritesPaneOpenProps;
|
|
45
93
|
|
|
46
94
|
/** The card's own open props for one row. One arm, never two. */
|
|
47
95
|
function cardOpenProps(
|
|
@@ -60,93 +108,119 @@ function cardOpenProps(
|
|
|
60
108
|
|
|
61
109
|
export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
|
|
62
110
|
const t = useT();
|
|
63
|
-
const describe = useDescribeFlowError();
|
|
64
111
|
const bag = useFavorites();
|
|
112
|
+
const paged = bag.prevPage.available || bag.nextPage.available;
|
|
65
113
|
|
|
66
114
|
return (
|
|
67
|
-
<
|
|
68
|
-
|
|
115
|
+
<SkinTheme
|
|
116
|
+
surface="base"
|
|
117
|
+
style={{ padding: spacing[4], maxWidth: FAVORITES_MEASURE }}
|
|
118
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
119
|
+
>
|
|
120
|
+
<Flex vertical gap={spacing[4]} data-testid="listings-favorites">
|
|
69
121
|
<Typography.Title level={3}>
|
|
70
122
|
{t(LISTINGS_I18N_KEYS.favoritesTitle)}
|
|
71
123
|
</Typography.Title>
|
|
72
124
|
|
|
73
125
|
{!bag.gate.available ? (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
126
|
+
// The whole body, not a banner over a spinner. A blocked screen has
|
|
127
|
+
// one state and it carries its own way out.
|
|
128
|
+
<EmptyState
|
|
129
|
+
testId="listings-favorites-blocked"
|
|
130
|
+
title={t(bag.gate.block.code, bag.gate.block.params)}
|
|
131
|
+
hint={t(LISTINGS_I18N_KEYS.favoritesSignInHint)}
|
|
132
|
+
action={
|
|
133
|
+
<SignInLink cta={props.signIn} testId="listings-favorites-sign-in" />
|
|
134
|
+
}
|
|
79
135
|
/>
|
|
80
|
-
) :
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
) : (
|
|
137
|
+
<>
|
|
138
|
+
{matchList(bag.rows, {
|
|
139
|
+
loading: () => (
|
|
140
|
+
<div
|
|
141
|
+
role="status"
|
|
142
|
+
aria-busy="true"
|
|
143
|
+
aria-label={t(LISTINGS_I18N_KEYS.favoritesLoading)}
|
|
144
|
+
data-testid="listings-favorites-loading"
|
|
145
|
+
data-stapel-load-state="loading"
|
|
146
|
+
/>
|
|
147
|
+
),
|
|
148
|
+
failed: (error) => (
|
|
149
|
+
<ErrorAlert
|
|
150
|
+
testId="listings-favorites-error"
|
|
151
|
+
thrown={error}
|
|
152
|
+
message={t(LISTINGS_I18N_KEYS.favoritesLoadFailed)}
|
|
153
|
+
onRetry={bag.refetch}
|
|
154
|
+
retryLabel={t(LISTINGS_I18N_KEYS.mineRetry)}
|
|
155
|
+
/>
|
|
156
|
+
),
|
|
157
|
+
empty: () => (
|
|
158
|
+
<EmptyState
|
|
159
|
+
testId="listings-favorites-empty"
|
|
160
|
+
title={t(LISTINGS_I18N_KEYS.favoritesEmpty)}
|
|
161
|
+
hint={t(LISTINGS_I18N_KEYS.favoritesEmptyHint)}
|
|
162
|
+
{...(props.browseCta !== undefined
|
|
163
|
+
? { action: props.browseCta }
|
|
164
|
+
: {})}
|
|
165
|
+
/>
|
|
166
|
+
),
|
|
167
|
+
ready: (rows) => (
|
|
168
|
+
<div
|
|
169
|
+
data-testid="listings-favorites-grid"
|
|
170
|
+
style={{
|
|
171
|
+
display: "grid",
|
|
172
|
+
gridTemplateColumns: `repeat(auto-fill, minmax(${FAVORITES_CARD_MIN}, 1fr))`,
|
|
173
|
+
gap: spacing[4],
|
|
174
|
+
}}
|
|
104
175
|
>
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
/>
|
|
115
|
-
),
|
|
116
|
-
ready: (rows) => (
|
|
117
|
-
<Flex wrap gap={16} data-testid="listings-favorites-grid">
|
|
118
|
-
{rows.map((row) => (
|
|
119
|
-
<div key={row.id} style={{ width: 240 }}>
|
|
120
|
-
<ListingCard listing={row} {...cardOpenProps(props, row.id)} />
|
|
176
|
+
{rows.map((row) => (
|
|
177
|
+
<ListingCard
|
|
178
|
+
key={row.id}
|
|
179
|
+
listing={row}
|
|
180
|
+
blockedReason="line"
|
|
181
|
+
{...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
|
|
182
|
+
{...cardOpenProps(props, row.id)}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
121
185
|
</div>
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
),
|
|
125
|
-
})}
|
|
186
|
+
),
|
|
187
|
+
})}
|
|
126
188
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
189
|
+
{paged ? (
|
|
190
|
+
<Flex gap={spacing[3]} wrap align="center">
|
|
191
|
+
{bag.prevPage.available ? (
|
|
192
|
+
<Button
|
|
193
|
+
size="small"
|
|
194
|
+
data-testid="listings-favorites-prev"
|
|
195
|
+
data-analytics="none"
|
|
196
|
+
data-analytics-reason="paging a list the person is already reading; not a business action"
|
|
197
|
+
onClick={bag.goPrev}
|
|
198
|
+
>
|
|
199
|
+
{t(LISTINGS_I18N_KEYS.pagePrev)}
|
|
200
|
+
</Button>
|
|
201
|
+
) : null}
|
|
202
|
+
<Typography.Text
|
|
203
|
+
type="secondary"
|
|
204
|
+
data-testid="listings-favorites-page"
|
|
205
|
+
>
|
|
206
|
+
{t(LISTINGS_I18N_KEYS.pageIndicator, { page: bag.pageNumber })}
|
|
207
|
+
</Typography.Text>
|
|
208
|
+
{bag.nextPage.available ? (
|
|
209
|
+
<Button
|
|
210
|
+
size="small"
|
|
211
|
+
data-testid="listings-favorites-next"
|
|
212
|
+
data-analytics="none"
|
|
213
|
+
data-analytics-reason="paging a list the person is already reading; not a business action"
|
|
214
|
+
onClick={bag.goNext}
|
|
215
|
+
>
|
|
216
|
+
{t(LISTINGS_I18N_KEYS.pageNext)}
|
|
217
|
+
</Button>
|
|
218
|
+
) : null}
|
|
219
|
+
</Flex>
|
|
220
|
+
) : null}
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
149
223
|
</Flex>
|
|
150
|
-
</
|
|
224
|
+
</SkinTheme>
|
|
151
225
|
);
|
|
152
226
|
}
|
|
@@ -22,19 +22,24 @@
|
|
|
22
22
|
* card. That property is the whole reason the projection exists, and it is
|
|
23
23
|
* what makes a grid of forty cards cost one query.
|
|
24
24
|
*
|
|
25
|
-
* ── The heart is never hidden
|
|
25
|
+
* ── The heart is never hidden, and never explained by hover ────────────────
|
|
26
26
|
*
|
|
27
27
|
* A visitor sees it, blocked, with the reason IN WORDS beside it and the
|
|
28
28
|
* sign-in link the container supplies (`signIn`, typically `?next=<current>`).
|
|
29
29
|
* Hiding it would teach nobody that favourites exist (private-space canon
|
|
30
|
-
* §6.3, spec §6.2 item 6)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* §6.3, spec §6.2 item 6). The reason itself goes through the shared
|
|
31
|
+
* `<GatedControl>`, which renders it as text linked by `aria-describedby` —
|
|
32
|
+
* a disabled antd button receives no pointer events and is not focusable, so
|
|
33
|
+
* the `Tooltip` this component used to offer as a "quieter" volume was a
|
|
34
|
+
* reason nobody could read on any device. That arm is gone; `blockedReason`
|
|
35
|
+
* is now "text" (reason + door) or "line" (reason alone, for a grid).
|
|
33
36
|
*/
|
|
34
37
|
import type { ReactElement, ReactNode } from "react";
|
|
35
|
-
import { Button, Card, Flex,
|
|
38
|
+
import { Button, Card, Flex, Typography } from "antd";
|
|
39
|
+
import { GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
|
|
36
40
|
import { useActionGate, useT } from "@stapel/core";
|
|
37
41
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
42
|
+
import { spacing } from "@stapel/tokens";
|
|
38
43
|
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
39
44
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
40
45
|
import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
@@ -44,7 +49,7 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
44
49
|
import { HeartIcon } from "./icons.js";
|
|
45
50
|
import { SignInLink } from "./SignInLink.js";
|
|
46
51
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
47
|
-
import {
|
|
52
|
+
import { ListingPrice } from "./ListingPrice.js";
|
|
48
53
|
import type { ThemeModeProp } from "./types.js";
|
|
49
54
|
|
|
50
55
|
/**
|
|
@@ -87,8 +92,27 @@ export type ListingCardOpenProps =
|
|
|
87
92
|
readonly linkComponent?: undefined;
|
|
88
93
|
};
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* How loudly a blocked favourite states its reason on THIS surface.
|
|
97
|
+
*
|
|
98
|
+
* `"text"` (the default) is the sentence under the controls, with the sign-in
|
|
99
|
+
* door beside it — the right volume for a card that stands alone.
|
|
100
|
+
*
|
|
101
|
+
* `"line"` collapses it to the reason alone, no door: a grid of forty cards
|
|
102
|
+
* repeating "sign in to do this — sign in" forty times is not forty pieces of
|
|
103
|
+
* help, it is the loudest thing on the page, and every one of those doors
|
|
104
|
+
* leads where the header's own sign-in button already leads.
|
|
105
|
+
*
|
|
106
|
+
* There is no third setting. The reason is on screen in both, because the only
|
|
107
|
+
* way to make it quieter than a line of text is to hide it behind hover, and
|
|
108
|
+
* hover does not exist on the device most of these cards are read on.
|
|
109
|
+
*/
|
|
110
|
+
export type ListingCardBlockedReason = "text" | "line";
|
|
111
|
+
|
|
90
112
|
export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
|
|
91
113
|
readonly listing: ListingCardData;
|
|
114
|
+
/** See {@link ListingCardBlockedReason}. Default `"text"`. */
|
|
115
|
+
readonly blockedReason?: ListingCardBlockedReason;
|
|
92
116
|
/** Extra chrome the container adds (a `promoted` tag from search, say —
|
|
93
117
|
* DSA Art. 26 marking belongs to the pair that receives it). */
|
|
94
118
|
readonly badge?: ReactNode;
|
|
@@ -105,6 +129,11 @@ export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
|
|
|
105
129
|
* Exactly one of the three renders, so exactly one navigation happens per
|
|
106
130
|
* click. That is the whole fix — the branch below has no arm in which both a
|
|
107
131
|
* handler and an `href` reach the DOM.
|
|
132
|
+
*
|
|
133
|
+
* It is the card's PRIMARY action and is drawn as one: a full-width button,
|
|
134
|
+
* which on a phone is 44px tall because `SkinTheme` sets antd's
|
|
135
|
+
* `controlHeight` there. It was a bare text link beside a 40px icon button,
|
|
136
|
+
* with no hierarchy between them and neither one a real touch target.
|
|
108
137
|
*/
|
|
109
138
|
function OpenControl(
|
|
110
139
|
props: ListingCardOpenProps & { readonly listingId: number }
|
|
@@ -131,8 +160,8 @@ function OpenControl(
|
|
|
131
160
|
</Link>
|
|
132
161
|
) : (
|
|
133
162
|
<Button
|
|
134
|
-
|
|
135
|
-
|
|
163
|
+
type="primary"
|
|
164
|
+
block
|
|
136
165
|
href={props.href}
|
|
137
166
|
data-testid="listings-card-open"
|
|
138
167
|
data-analytics="none"
|
|
@@ -147,8 +176,8 @@ function OpenControl(
|
|
|
147
176
|
const onOpen = props.onOpen;
|
|
148
177
|
return (
|
|
149
178
|
<Button
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
type="primary"
|
|
180
|
+
block
|
|
152
181
|
data-testid="listings-card-open"
|
|
153
182
|
data-analytics="none"
|
|
154
183
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
@@ -175,12 +204,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
175
204
|
const titleDaos = asFeatureDaoList(listing.features_title);
|
|
176
205
|
|
|
177
206
|
const status = listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
178
|
-
const price =
|
|
179
|
-
listing.price !== undefined && listing.price.length > 0
|
|
180
|
-
? `${listing.price} ${listing.currency ?? ""}`.trim()
|
|
181
|
-
: t(LISTINGS_I18N_KEYS.cardPriceAbsent);
|
|
182
207
|
|
|
183
208
|
const favoriteGate = useActionGate(favorite.gate);
|
|
209
|
+
const blockedReason = props.blockedReason ?? "text";
|
|
184
210
|
|
|
185
211
|
const favoriteLabel = t(
|
|
186
212
|
favorite.favorited
|
|
@@ -189,7 +215,10 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
189
215
|
);
|
|
190
216
|
|
|
191
217
|
return (
|
|
192
|
-
<
|
|
218
|
+
<SkinTheme
|
|
219
|
+
surface="bare"
|
|
220
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
221
|
+
>
|
|
193
222
|
<Card
|
|
194
223
|
size="small"
|
|
195
224
|
data-testid="listings-card"
|
|
@@ -197,19 +226,22 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
197
226
|
{...(status !== undefined
|
|
198
227
|
? { "data-listing-status": status.status }
|
|
199
228
|
: {})}
|
|
229
|
+
styles={{ body: { minWidth: 0 } }}
|
|
200
230
|
cover={
|
|
201
231
|
<ListingPhoto
|
|
202
232
|
imageRef={listing.images?.[0]}
|
|
203
233
|
alt={listing.title ?? String(listing.id)}
|
|
204
|
-
style={{ aspectRatio: "4 / 3" }}
|
|
205
234
|
/>
|
|
206
235
|
}
|
|
207
236
|
>
|
|
208
|
-
<Flex vertical gap={
|
|
237
|
+
<Flex vertical gap={spacing[1]} style={{ minWidth: 0 }}>
|
|
209
238
|
{props.badge}
|
|
210
239
|
|
|
211
240
|
<Typography.Text strong data-testid="listings-card-price">
|
|
212
|
-
|
|
241
|
+
<ListingPrice
|
|
242
|
+
amount={listing.price}
|
|
243
|
+
{...(listing.currency !== undefined ? { currency: listing.currency } : {})}
|
|
244
|
+
/>
|
|
213
245
|
</Typography.Text>
|
|
214
246
|
|
|
215
247
|
<Typography.Text ellipsis data-testid="listings-card-title">
|
|
@@ -242,24 +274,32 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
242
274
|
</Typography.Text>
|
|
243
275
|
) : null}
|
|
244
276
|
|
|
245
|
-
|
|
277
|
+
{/* The action row is wrapped in ONE `GatedControl`, so the reason a
|
|
278
|
+
blocked heart carries lands full width UNDER both controls
|
|
279
|
+
rather than in the two-centimetre column beside the icon. */}
|
|
280
|
+
{props.showFavorite === false ? (
|
|
246
281
|
<OpenControl {...props} listingId={listing.id} />
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
282
|
+
) : (
|
|
283
|
+
<GatedControl
|
|
284
|
+
gate={favorite.gate}
|
|
285
|
+
testId="listings-card-actions"
|
|
286
|
+
style={{ width: "100%" }}
|
|
287
|
+
>
|
|
288
|
+
{(bind) => (
|
|
289
|
+
<Flex
|
|
290
|
+
gap={spacing[2]}
|
|
291
|
+
align="center"
|
|
292
|
+
style={{ width: "100%", minWidth: 0 }}
|
|
293
|
+
>
|
|
294
|
+
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
|
|
295
|
+
<OpenControl {...props} listingId={listing.id} />
|
|
296
|
+
</div>
|
|
260
297
|
<Button
|
|
261
|
-
|
|
262
|
-
disabled=
|
|
298
|
+
disabled={bind.disabled}
|
|
299
|
+
data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
|
|
300
|
+
{...(bind["aria-describedby"] !== undefined
|
|
301
|
+
? { "aria-describedby": bind["aria-describedby"] }
|
|
302
|
+
: {})}
|
|
263
303
|
aria-label={favoriteLabel}
|
|
264
304
|
aria-pressed={favorite.favorited}
|
|
265
305
|
data-testid="listings-card-favorite"
|
|
@@ -269,27 +309,29 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
269
309
|
onClick={favorite.toggle}
|
|
270
310
|
icon={<HeartIcon filled={favorite.favorited} />}
|
|
271
311
|
/>
|
|
272
|
-
</
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
312
|
+
</Flex>
|
|
313
|
+
)}
|
|
314
|
+
</GatedControl>
|
|
315
|
+
)}
|
|
276
316
|
|
|
277
|
-
{/* The
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
317
|
+
{/* The door. `GatedControl` above already prints the reason and
|
|
318
|
+
wires `aria-describedby` to it; what it cannot know is WHERE a
|
|
319
|
+
visitor signs in, which is the container's business and arrives
|
|
320
|
+
as `signIn`. On a grid `blockedReason="line"` drops the door and
|
|
321
|
+
keeps the sentence — twenty-four doors to one place is not
|
|
322
|
+
twenty-four pieces of help. */}
|
|
323
|
+
{props.showFavorite === false ||
|
|
324
|
+
favoriteGate.reason === undefined ||
|
|
325
|
+
blockedReason === "line" ? null : (
|
|
283
326
|
<Typography.Text
|
|
284
327
|
type="secondary"
|
|
285
328
|
data-testid="listings-card-favorite-blocked"
|
|
286
329
|
>
|
|
287
|
-
{favoriteGate.reason}
|
|
288
330
|
<SignInLink cta={props.signIn} testId="listings-card-sign-in" />
|
|
289
331
|
</Typography.Text>
|
|
290
332
|
)}
|
|
291
333
|
</Flex>
|
|
292
334
|
</Card>
|
|
293
|
-
</
|
|
335
|
+
</SkinTheme>
|
|
294
336
|
);
|
|
295
337
|
}
|