@stapel/listings-react 0.2.0 → 0.3.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 +129 -0
- package/MODULE.md +97 -3
- package/README.md +67 -2
- package/dist/default/FavoritesPane.d.ts +19 -4
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +13 -3
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +54 -11
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +32 -5
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +59 -4
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +12 -3
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/SignInLink.d.ts +28 -0
- package/dist/default/SignInLink.d.ts.map +1 -0
- package/dist/default/SignInLink.js +17 -0
- package/dist/default/SignInLink.js.map +1 -0
- package/dist/default/index.d.ts +5 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +17 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +26 -10
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +2 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +3 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +1 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +2 -1
- package/nav-manifest.json +1 -1
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FavoritesPane.tsx +40 -11
- package/src/default/ListingCard.tsx +144 -27
- package/src/default/ListingComposerPage.tsx +79 -14
- package/src/default/SignInLink.tsx +54 -0
- package/src/default/index.ts +15 -3
- package/src/headless/ListingComposer.tsx +43 -10
- package/src/i18n/es.ts +1 -0
- package/src/i18n/keys.ts +3 -0
- package/src/i18n/ru.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/listings-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"typescript": "^5.8.3",
|
|
100
100
|
"vitest": "^3.2.4",
|
|
101
101
|
"@stapel/attributes-react": "^0.2.0",
|
|
102
|
-
"@stapel/core": "^0.
|
|
102
|
+
"@stapel/core": "^0.16.0",
|
|
103
103
|
"@stapel/image": "^0.2.0",
|
|
104
104
|
"@stapel/showcase": "^0.2.0",
|
|
105
105
|
"@stapel/tokens": "^0.5.0",
|
|
@@ -10,17 +10,52 @@
|
|
|
10
10
|
import type { ReactElement } from "react";
|
|
11
11
|
import { Alert, Button, Empty, Flex, Space, Spin, Typography } from "antd";
|
|
12
12
|
import { matchList, useDescribeFlowError, useT } from "@stapel/core";
|
|
13
|
+
import type { LinkComponent } from "@stapel/core";
|
|
13
14
|
import { useFavorites } from "../headless/Favorites.js";
|
|
14
15
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
15
16
|
import { ErrorAlert } from "./ErrorAlert.js";
|
|
16
17
|
import { ListingCard } from "./ListingCard.js";
|
|
18
|
+
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
17
19
|
import { ListingsSkinTheme } from "./theme.js";
|
|
18
20
|
import type { ThemeModeProp } from "./types.js";
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/** How a card in this grid opens — the same one-contract union `<ListingCard>`
|
|
23
|
+
* takes, one level up, so a pane cannot re-introduce the double navigation the
|
|
24
|
+
* card no longer allows. */
|
|
25
|
+
export type FavoritesPaneOpenProps =
|
|
26
|
+
| {
|
|
27
|
+
/** Where a card leads. The container owns routing. */
|
|
28
|
+
readonly hrefFor: (id: number) => string;
|
|
29
|
+
/** The host's `<Link>`, so a click stays inside the SPA. */
|
|
30
|
+
readonly linkComponent?: LinkComponent;
|
|
31
|
+
readonly onOpen?: undefined;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
readonly onOpen: (id: number) => void;
|
|
35
|
+
readonly hrefFor?: undefined;
|
|
36
|
+
readonly linkComponent?: undefined;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
readonly hrefFor?: undefined;
|
|
40
|
+
readonly onOpen?: undefined;
|
|
41
|
+
readonly linkComponent?: undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type FavoritesPaneProps = ThemeModeProp & FavoritesPaneOpenProps;
|
|
45
|
+
|
|
46
|
+
/** The card's own open props for one row. One arm, never two. */
|
|
47
|
+
function cardOpenProps(
|
|
48
|
+
props: FavoritesPaneOpenProps,
|
|
49
|
+
id: number
|
|
50
|
+
): ListingCardOpenProps {
|
|
51
|
+
if (props.hrefFor !== undefined) {
|
|
52
|
+
const href = props.hrefFor(id);
|
|
53
|
+
return props.linkComponent !== undefined
|
|
54
|
+
? { href, linkComponent: props.linkComponent }
|
|
55
|
+
: { href };
|
|
56
|
+
}
|
|
57
|
+
if (props.onOpen !== undefined) return { onOpen: props.onOpen };
|
|
58
|
+
return {};
|
|
24
59
|
}
|
|
25
60
|
|
|
26
61
|
export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
|
|
@@ -82,13 +117,7 @@ export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
|
|
|
82
117
|
<Flex wrap gap={16} data-testid="listings-favorites-grid">
|
|
83
118
|
{rows.map((row) => (
|
|
84
119
|
<div key={row.id} style={{ width: 240 }}>
|
|
85
|
-
<ListingCard
|
|
86
|
-
listing={row}
|
|
87
|
-
{...(props.hrefFor !== undefined
|
|
88
|
-
? { href: props.hrefFor(row.id) }
|
|
89
|
-
: {})}
|
|
90
|
-
{...(props.onOpen !== undefined ? { onOpen: props.onOpen } : {})}
|
|
91
|
-
/>
|
|
120
|
+
<ListingCard listing={row} {...cardOpenProps(props, row.id)} />
|
|
92
121
|
</div>
|
|
93
122
|
))}
|
|
94
123
|
</Flex>
|
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
* `@stapel/search-react` takes a `renderCard` slot and the container fills it
|
|
5
5
|
* with this (spec §3.7 / §6.2 item 1). The two pairs never import each other;
|
|
6
6
|
* the CONTAINER is the seam, which is why this component takes a plain card
|
|
7
|
-
* row and a plain `href
|
|
7
|
+
* row and a plain `href` rather than reaching for a router.
|
|
8
|
+
*
|
|
9
|
+
* ── One click, one navigation ──────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* `href` and `onOpen` used to be two optional props, and a card given both
|
|
12
|
+
* navigated TWICE: the handler ran, and the browser then followed the anchor
|
|
13
|
+
* that was still on the button. They are now three arms of a union — link,
|
|
14
|
+
* button, or neither — and `linkComponent` rides on the link arm so a
|
|
15
|
+
* container can hand in its router's `<Link>` and keep the anchor.
|
|
8
16
|
*
|
|
9
17
|
* ── What it renders without asking the server anything else ────────────────
|
|
10
18
|
*
|
|
@@ -16,13 +24,17 @@
|
|
|
16
24
|
*
|
|
17
25
|
* ── The heart is never hidden ──────────────────────────────────────────────
|
|
18
26
|
*
|
|
19
|
-
* A visitor sees it, blocked, with the reason
|
|
20
|
-
* container
|
|
21
|
-
* favourites exist (private-space canon
|
|
27
|
+
* A visitor sees it, blocked, with the reason IN WORDS beside it and the
|
|
28
|
+
* sign-in link the container supplies (`signIn`, typically `?next=<current>`).
|
|
29
|
+
* Hiding it would teach nobody that favourites exist (private-space canon
|
|
30
|
+
* §6.3, spec §6.2 item 6) — and until 0.3.0 the reason lived only in a tooltip
|
|
31
|
+
* on a disabled button, which receives no pointer events in any browser: a
|
|
32
|
+
* reason nobody could read, and no door to walk through.
|
|
22
33
|
*/
|
|
23
34
|
import type { ReactElement, ReactNode } from "react";
|
|
24
35
|
import { Button, Card, Flex, Tooltip, Typography } from "antd";
|
|
25
|
-
import { useT } from "@stapel/core";
|
|
36
|
+
import { useActionGate, useT } from "@stapel/core";
|
|
37
|
+
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
26
38
|
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
27
39
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
28
40
|
import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
@@ -30,18 +42,53 @@ import { lifecycleCaption } from "../model/status.js";
|
|
|
30
42
|
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
31
43
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
32
44
|
import { HeartIcon } from "./icons.js";
|
|
45
|
+
import { SignInLink } from "./SignInLink.js";
|
|
33
46
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
34
47
|
import { ListingsSkinTheme } from "./theme.js";
|
|
35
48
|
import type { ThemeModeProp } from "./types.js";
|
|
36
49
|
|
|
37
|
-
|
|
50
|
+
/**
|
|
51
|
+
* How the card opens — ONE of three, and the type says so.
|
|
52
|
+
*
|
|
53
|
+
* It used to be two optional props, and a caller that passed both got two
|
|
54
|
+
* navigations for one click: the handler ran and the browser then followed the
|
|
55
|
+
* anchor anyway. The storefront worked around it by passing `onOpen` only,
|
|
56
|
+
* which cost it a real anchor (no middle-click, no "open in new tab", nothing
|
|
57
|
+
* for a crawler to follow) on the most linkable element in the app.
|
|
58
|
+
*
|
|
59
|
+
* So the union has three arms and no fourth: a link, a button, or neither.
|
|
60
|
+
* `linkComponent` belongs to the link arm because it IS the link — handing a
|
|
61
|
+
* `<Link>` to a card that navigates by callback would be two answers to one
|
|
62
|
+
* question again.
|
|
63
|
+
*/
|
|
64
|
+
export type ListingCardOpenProps =
|
|
65
|
+
| {
|
|
66
|
+
/** Where the card leads. A plain path — the pair never calls
|
|
67
|
+
* `window.location` and never builds a router descriptor. */
|
|
68
|
+
readonly href: string;
|
|
69
|
+
/** The host's `<Link>`, so the click stays inside the SPA. Absent: an
|
|
70
|
+
* antd link-button carrying the `href`, which reloads the page in a
|
|
71
|
+
* router app — correct, just not fast. */
|
|
72
|
+
readonly linkComponent?: LinkComponent;
|
|
73
|
+
readonly onOpen?: undefined;
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
/** The card opens by callback: rendered as a button, with no `href` for
|
|
77
|
+
* the browser to follow after the handler has already navigated. */
|
|
78
|
+
readonly onOpen: (id: number) => void;
|
|
79
|
+
readonly href?: undefined;
|
|
80
|
+
readonly linkComponent?: undefined;
|
|
81
|
+
}
|
|
82
|
+
| {
|
|
83
|
+
/** No open control at all — a card inside a screen that is already the
|
|
84
|
+
* listing. */
|
|
85
|
+
readonly href?: undefined;
|
|
86
|
+
readonly onOpen?: undefined;
|
|
87
|
+
readonly linkComponent?: undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
|
|
38
91
|
readonly listing: ListingCardData;
|
|
39
|
-
/** Where the card leads. The container owns routing, so this is a plain
|
|
40
|
-
* href a `<Link>` or an `<a>` can carry — the pair never calls
|
|
41
|
-
* `window.location`. */
|
|
42
|
-
readonly href?: string;
|
|
43
|
-
/** Called instead of following `href`, for a host with its own navigation. */
|
|
44
|
-
readonly onOpen?: (id: number) => void;
|
|
45
92
|
/** Extra chrome the container adds (a `promoted` tag from search, say —
|
|
46
93
|
* DSA Art. 26 marking belongs to the pair that receives it). */
|
|
47
94
|
readonly badge?: ReactNode;
|
|
@@ -50,6 +97,73 @@ export interface ListingCardProps extends ThemeModeProp {
|
|
|
50
97
|
readonly showFavorite?: boolean;
|
|
51
98
|
}
|
|
52
99
|
|
|
100
|
+
export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The one control that opens the card: an anchor, a button, or nothing.
|
|
104
|
+
*
|
|
105
|
+
* Exactly one of the three renders, so exactly one navigation happens per
|
|
106
|
+
* click. That is the whole fix — the branch below has no arm in which both a
|
|
107
|
+
* handler and an `href` reach the DOM.
|
|
108
|
+
*/
|
|
109
|
+
function OpenControl(
|
|
110
|
+
props: ListingCardOpenProps & { readonly listingId: number }
|
|
111
|
+
): ReactElement | null {
|
|
112
|
+
const t = useT();
|
|
113
|
+
const label = t(LISTINGS_I18N_KEYS.cardOpen);
|
|
114
|
+
|
|
115
|
+
if (props.href !== undefined) {
|
|
116
|
+
const Link = props.linkComponent;
|
|
117
|
+
// The host's component is rendered as it comes: this pair has no CSS and
|
|
118
|
+
// no way to hand antd's button styling to a foreign element, and a wrapper
|
|
119
|
+
// element around a link is a click target that is not the link. A host
|
|
120
|
+
// that wants the antd look styles its own `<Link>` — it is one component,
|
|
121
|
+
// written once, and it is already the thing that knows the design system.
|
|
122
|
+
return Link !== undefined ? (
|
|
123
|
+
<Link
|
|
124
|
+
href={props.href}
|
|
125
|
+
aria-label={label}
|
|
126
|
+
data-testid="listings-card-open"
|
|
127
|
+
data-analytics="none"
|
|
128
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
129
|
+
>
|
|
130
|
+
{label}
|
|
131
|
+
</Link>
|
|
132
|
+
) : (
|
|
133
|
+
<Button
|
|
134
|
+
size="small"
|
|
135
|
+
type="link"
|
|
136
|
+
href={props.href}
|
|
137
|
+
data-testid="listings-card-open"
|
|
138
|
+
data-analytics="none"
|
|
139
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
140
|
+
>
|
|
141
|
+
{label}
|
|
142
|
+
</Button>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (props.onOpen !== undefined) {
|
|
147
|
+
const onOpen = props.onOpen;
|
|
148
|
+
return (
|
|
149
|
+
<Button
|
|
150
|
+
size="small"
|
|
151
|
+
type="link"
|
|
152
|
+
data-testid="listings-card-open"
|
|
153
|
+
data-analytics="none"
|
|
154
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
155
|
+
onClick={() => {
|
|
156
|
+
onOpen(props.listingId);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{label}
|
|
160
|
+
</Button>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
53
167
|
export function ListingCard(props: ListingCardProps): ReactElement {
|
|
54
168
|
const t = useT();
|
|
55
169
|
const { listing } = props;
|
|
@@ -66,6 +180,8 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
66
180
|
? `${listing.price} ${listing.currency ?? ""}`.trim()
|
|
67
181
|
: t(LISTINGS_I18N_KEYS.cardPriceAbsent);
|
|
68
182
|
|
|
183
|
+
const favoriteGate = useActionGate(favorite.gate);
|
|
184
|
+
|
|
69
185
|
const favoriteLabel = t(
|
|
70
186
|
favorite.favorited
|
|
71
187
|
? LISTINGS_I18N_KEYS.cardFavoriteRemove
|
|
@@ -127,21 +243,7 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
127
243
|
) : null}
|
|
128
244
|
|
|
129
245
|
<Flex gap={8} align="center">
|
|
130
|
-
{props
|
|
131
|
-
<Button
|
|
132
|
-
size="small"
|
|
133
|
-
type="link"
|
|
134
|
-
{...(props.href !== undefined ? { href: props.href } : {})}
|
|
135
|
-
data-testid="listings-card-open"
|
|
136
|
-
data-analytics="none"
|
|
137
|
-
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
138
|
-
onClick={() => {
|
|
139
|
-
props.onOpen?.(listing.id);
|
|
140
|
-
}}
|
|
141
|
-
>
|
|
142
|
-
{t(LISTINGS_I18N_KEYS.cardOpen)}
|
|
143
|
-
</Button>
|
|
144
|
-
) : null}
|
|
246
|
+
<OpenControl {...props} listingId={listing.id} />
|
|
145
247
|
|
|
146
248
|
{props.showFavorite === false ? null : (
|
|
147
249
|
<Tooltip
|
|
@@ -171,6 +273,21 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
171
273
|
</Tooltip>
|
|
172
274
|
)}
|
|
173
275
|
</Flex>
|
|
276
|
+
|
|
277
|
+
{/* The reason IN WORDS, plus the door. A tooltip on a disabled
|
|
278
|
+
button is a reason nobody can read (core's actionGate.ts says so
|
|
279
|
+
in as many words), and a reason with no next action leaves the
|
|
280
|
+
visitor hunting for the header — which is what the storefront
|
|
281
|
+
had to write a paragraph about instead of shipping the screen. */}
|
|
282
|
+
{props.showFavorite === false || favoriteGate.reason === undefined ? null : (
|
|
283
|
+
<Typography.Text
|
|
284
|
+
type="secondary"
|
|
285
|
+
data-testid="listings-card-favorite-blocked"
|
|
286
|
+
>
|
|
287
|
+
{favoriteGate.reason}
|
|
288
|
+
<SignInLink cta={props.signIn} testId="listings-card-sign-in" />
|
|
289
|
+
</Typography.Text>
|
|
290
|
+
)}
|
|
174
291
|
</Flex>
|
|
175
292
|
</Card>
|
|
176
293
|
</ListingsSkinTheme>
|
|
@@ -5,12 +5,29 @@
|
|
|
5
5
|
* the component can be read top to bottom without knowing any of the other
|
|
6
6
|
* pairs:
|
|
7
7
|
*
|
|
8
|
-
* category `
|
|
8
|
+
* category `renderCategoryPicker` — the container's `<CategoryPickerField>`,
|
|
9
|
+
* given `setCategory` to call
|
|
9
10
|
* details `features` — the schema, drawn by `<FeatureFields>` (L0)
|
|
10
|
-
* photos `gallerySlot` — the container's `<MediaGalleryField>`,
|
|
11
|
-
* bag is handed
|
|
11
|
+
* photos `gallerySlot` — the container's `<MediaGalleryField bag>`,
|
|
12
|
+
* whose bag is handed here as `images`
|
|
12
13
|
* the draft this pair
|
|
13
14
|
*
|
|
15
|
+
* ── Why the category slot is a render prop and not a node ──────────────────
|
|
16
|
+
*
|
|
17
|
+
* It was a node (`categorySlot`), and a node cannot be mounted: the composer's
|
|
18
|
+
* category moves only through `bag.setCategory`, and a `ReactNode` handed in
|
|
19
|
+
* from outside has no way to reach it. There was no `onCategoryChange`either,
|
|
20
|
+
* so a container could neither set the category nor learn it — and `features`,
|
|
21
|
+
* the schema of the chosen category, was therefore unreachable rather than
|
|
22
|
+
* merely withheld. The screen could not be mounted at all (storefront Wave D,
|
|
23
|
+
* named gap G-1).
|
|
24
|
+
*
|
|
25
|
+
* `renderCategoryPicker({ value, setCategory })` is the shape
|
|
26
|
+
* `<CategoryPage renderListings>` already uses in the sibling pair, and the
|
|
27
|
+
* controlled pair `category` / `onCategoryChange` is there for the container
|
|
28
|
+
* that holds the id anyway — it must, because the schema read
|
|
29
|
+
* (`useCategoryFeatures(id)`) that fills `features` is keyed by it.
|
|
30
|
+
*
|
|
14
31
|
* ── Every blocked control says which of six reasons it is ──────────────────
|
|
15
32
|
*
|
|
16
33
|
* The publish button is the most-gated control in the fleet, and that is the
|
|
@@ -55,6 +72,19 @@ import { ErrorAlert } from "./ErrorAlert.js";
|
|
|
55
72
|
import { ListingsSkinTheme } from "./theme.js";
|
|
56
73
|
import type { ThemeModeProp } from "./types.js";
|
|
57
74
|
|
|
75
|
+
/**
|
|
76
|
+
* What `renderCategoryPicker` is handed: the current category and the ONLY
|
|
77
|
+
* function that changes it. Same shape as any other bag in this monorepo — the
|
|
78
|
+
* value plus the setter, never a setter alone.
|
|
79
|
+
*/
|
|
80
|
+
export interface ComposerCategorySlot {
|
|
81
|
+
/** The category the draft currently carries. Empty string: none chosen. */
|
|
82
|
+
readonly value: string;
|
|
83
|
+
/** Choose a category. Prunes the answers the new schema does not ask for
|
|
84
|
+
* (one render later, once `features` arrives) and reports upwards. */
|
|
85
|
+
readonly setCategory: (categoryId: string) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
export interface ListingComposerPageProps extends ThemeModeProp {
|
|
59
89
|
/** Editing an existing listing; absent for a new one. */
|
|
60
90
|
readonly listingId?: number;
|
|
@@ -63,7 +93,33 @@ export interface ListingComposerPageProps extends ThemeModeProp {
|
|
|
63
93
|
readonly features: readonly FeatureDef[];
|
|
64
94
|
readonly featuresLoading?: boolean;
|
|
65
95
|
readonly featuresError?: unknown;
|
|
66
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* The category chooser, handed the value and the setter. This is the slot a
|
|
98
|
+
* container fills with `@stapel/categories-react`'s `<CategoryPickerField>`:
|
|
99
|
+
*
|
|
100
|
+
* ```tsx
|
|
101
|
+
* renderCategoryPicker={({ value, setCategory }) => (
|
|
102
|
+
* <CategoryPickerField
|
|
103
|
+
* value={value === "" ? null : Number(value)}
|
|
104
|
+
* onChange={(id) => setCategory(id === null ? "" : String(id))}
|
|
105
|
+
* />
|
|
106
|
+
* )}
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
readonly renderCategoryPicker?: (slot: ComposerCategorySlot) => ReactNode;
|
|
110
|
+
/**
|
|
111
|
+
* The chosen category, when the container owns that state — the usual case,
|
|
112
|
+
* since the same id keys the `features` read.
|
|
113
|
+
*/
|
|
114
|
+
readonly category?: string;
|
|
115
|
+
/** Called whenever the category changes, controlled or not. */
|
|
116
|
+
readonly onCategoryChange?: (categoryId: string) => void;
|
|
117
|
+
/**
|
|
118
|
+
* @deprecated A node cannot reach `setCategory`, so a picker rendered into
|
|
119
|
+
* this slot could never tell the composer what was chosen. Use
|
|
120
|
+
* `renderCategoryPicker` (or the controlled `category` /
|
|
121
|
+
* `onCategoryChange` pair). Still rendered, so nothing that passed it breaks.
|
|
122
|
+
*/
|
|
67
123
|
readonly categorySlot?: ReactNode;
|
|
68
124
|
/** The photo grid. Its bag is what `images` carries. */
|
|
69
125
|
readonly gallerySlot?: ReactNode;
|
|
@@ -89,6 +145,10 @@ export function ListingComposerPage(
|
|
|
89
145
|
: {}),
|
|
90
146
|
editorTypes: BUILTIN_VALUE_EDITOR_TYPES,
|
|
91
147
|
...(props.images !== undefined ? { images: props.images } : {}),
|
|
148
|
+
...(props.category !== undefined ? { category: props.category } : {}),
|
|
149
|
+
...(props.onCategoryChange !== undefined
|
|
150
|
+
? { onCategoryChange: props.onCategoryChange }
|
|
151
|
+
: {}),
|
|
92
152
|
...(props.onPublished !== undefined
|
|
93
153
|
? {
|
|
94
154
|
// `listing_id` comes back in the response, so the callback never
|
|
@@ -160,16 +220,21 @@ export function ListingComposerPage(
|
|
|
160
220
|
extra={t(LISTINGS_I18N_KEYS.composeCategoryHelp)}
|
|
161
221
|
{...errorOf(CATEGORY_FIELD)}
|
|
162
222
|
>
|
|
163
|
-
{props.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
223
|
+
{props.renderCategoryPicker !== undefined
|
|
224
|
+
? props.renderCategoryPicker({
|
|
225
|
+
value: bag.values.categoryId,
|
|
226
|
+
setCategory: bag.setCategory,
|
|
227
|
+
})
|
|
228
|
+
: (props.categorySlot ?? (
|
|
229
|
+
<Input
|
|
230
|
+
value={bag.values.categoryId}
|
|
231
|
+
aria-label={t(LISTINGS_I18N_KEYS.composeCategory)}
|
|
232
|
+
data-testid="listings-composer-category"
|
|
233
|
+
onChange={(event) => {
|
|
234
|
+
bag.setCategory(event.target.value);
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
))}
|
|
173
238
|
</Form.Item>
|
|
174
239
|
|
|
175
240
|
{bag.droppedOnCategoryChange.length > 0 ? (
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The door beside a blocked control.
|
|
3
|
+
*
|
|
4
|
+
* `actionBlocked` ended the grey-rectangle incident by making every
|
|
5
|
+
* switched-off control state its reason. It did not end the next problem:
|
|
6
|
+
* "sign in to add this to favourites" is a reason whose next action is a LINK,
|
|
7
|
+
* and this pair rendered the sentence and stopped there — leaving the visitor
|
|
8
|
+
* to find the header themselves (storefront Wave D, G-3).
|
|
9
|
+
*
|
|
10
|
+
* WHERE that link goes is the container's business, never the pair's: the
|
|
11
|
+
* storefront's is `/login?next=<current>`, a tenant app's may be a modal. So
|
|
12
|
+
* the shape is core's `SignInCta` — `{href}` or `{onSignIn}`, never both — and
|
|
13
|
+
* the copy is this pair's, because core floors `en` and `ru` while this pair
|
|
14
|
+
* also ships `es`.
|
|
15
|
+
*
|
|
16
|
+
* A host that routes internally passes `onSignIn`; the `href` arm renders a
|
|
17
|
+
* plain anchor on purpose, because arriving at a sign-in page is one of the
|
|
18
|
+
* few navigations a full load costs nothing.
|
|
19
|
+
*/
|
|
20
|
+
import type { ReactElement } from "react";
|
|
21
|
+
import { Typography } from "antd";
|
|
22
|
+
import { useT } from "@stapel/core";
|
|
23
|
+
import type { SignInCta } from "@stapel/core";
|
|
24
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
25
|
+
|
|
26
|
+
export interface SignInLinkProps {
|
|
27
|
+
/** Absent: no link — a host with no sign-in route shows the reason alone. */
|
|
28
|
+
readonly cta: SignInCta | undefined;
|
|
29
|
+
readonly testId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SignInLink(props: SignInLinkProps): ReactElement | null {
|
|
33
|
+
const t = useT();
|
|
34
|
+
const { cta } = props;
|
|
35
|
+
if (cta === undefined) return null;
|
|
36
|
+
// The separating space belongs HERE, not at the call site: a reason with no
|
|
37
|
+
// door must render as exactly its own sentence, and a `{" "}` left behind by
|
|
38
|
+
// an absent link is a trailing space in every caller's assertion.
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{" "}
|
|
42
|
+
<Typography.Link
|
|
43
|
+
data-testid={props.testId}
|
|
44
|
+
data-analytics="none"
|
|
45
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
46
|
+
{...(cta.href !== undefined
|
|
47
|
+
? { href: cta.href }
|
|
48
|
+
: { onClick: cta.onSignIn })}
|
|
49
|
+
>
|
|
50
|
+
{t(LISTINGS_I18N_KEYS.cardSignIn)}
|
|
51
|
+
</Typography.Link>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -15,21 +15,33 @@
|
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
export { ListingCard } from "./ListingCard.js";
|
|
18
|
-
export type {
|
|
18
|
+
export type {
|
|
19
|
+
ListingCardProps,
|
|
20
|
+
ListingCardBaseProps,
|
|
21
|
+
ListingCardOpenProps,
|
|
22
|
+
} from "./ListingCard.js";
|
|
19
23
|
export { ListingDetailPane } from "./ListingDetailPane.js";
|
|
20
24
|
export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
|
|
21
25
|
export { ListingComposerPage } from "./ListingComposerPage.js";
|
|
22
|
-
export type {
|
|
26
|
+
export type {
|
|
27
|
+
ListingComposerPageProps,
|
|
28
|
+
ComposerCategorySlot,
|
|
29
|
+
} from "./ListingComposerPage.js";
|
|
23
30
|
export { MyListingsPane } from "./MyListingsPane.js";
|
|
24
31
|
export type { MyListingsPaneProps } from "./MyListingsPane.js";
|
|
25
32
|
export { FavoritesPane } from "./FavoritesPane.js";
|
|
26
|
-
export type {
|
|
33
|
+
export type {
|
|
34
|
+
FavoritesPaneProps,
|
|
35
|
+
FavoritesPaneOpenProps,
|
|
36
|
+
} from "./FavoritesPane.js";
|
|
27
37
|
|
|
28
38
|
export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
|
|
29
39
|
export type { ListingStatusProps } from "./StatusTags.js";
|
|
30
40
|
export { ListingPhoto } from "./ListingPhoto.js";
|
|
31
41
|
export type { ListingPhotoProps } from "./ListingPhoto.js";
|
|
32
42
|
export { ErrorAlert } from "./ErrorAlert.js";
|
|
43
|
+
export { SignInLink } from "./SignInLink.js";
|
|
44
|
+
export type { SignInLinkProps } from "./SignInLink.js";
|
|
33
45
|
export { ListingsSkinTheme } from "./theme.js";
|
|
34
46
|
export type { ListingsSkinThemeProps } from "./theme.js";
|
|
35
47
|
export type { ThemeModeProp } from "./types.js";
|
|
@@ -125,6 +125,23 @@ export interface UseListingComposerOptions {
|
|
|
125
125
|
/** The upload queue. Absent: no gallery, and `images` is set through
|
|
126
126
|
* `setValue` by whatever the host uses instead. */
|
|
127
127
|
readonly images?: ListingImagesBag;
|
|
128
|
+
/**
|
|
129
|
+
* The chosen category, when the CONTAINER owns that state.
|
|
130
|
+
*
|
|
131
|
+
* The category is the one value the composer cannot own alone: the picker
|
|
132
|
+
* lives in `@stapel/categories-react` and the schema read
|
|
133
|
+
* (`useCategoryFeatures(id)`) that fills `features` is keyed by it, so the
|
|
134
|
+
* container holds it either way. Passing it here makes this hook controlled
|
|
135
|
+
* on that field — `values.categoryId` mirrors it, and `setCategory` reports
|
|
136
|
+
* upwards instead of writing local state that would then disagree with the
|
|
137
|
+
* schema on screen.
|
|
138
|
+
*
|
|
139
|
+
* Absent: uncontrolled, exactly as before.
|
|
140
|
+
*/
|
|
141
|
+
readonly category?: string;
|
|
142
|
+
/** Called by `setCategory`, whether or not `category` is controlled. This is
|
|
143
|
+
* how a container learns which category to read the schema for. */
|
|
144
|
+
readonly onCategoryChange?: (categoryId: string) => void;
|
|
128
145
|
/** Seed for a brand-new draft (a category preselected from the URL, say). */
|
|
129
146
|
readonly initialValues?: Partial<ListingDraftValues>;
|
|
130
147
|
onDraftCreated?: (draft: ListingDraft) => void;
|
|
@@ -245,9 +262,19 @@ export function useListingComposer(
|
|
|
245
262
|
// The gallery is the upload bag's, whenever there is one: two sources of
|
|
246
263
|
// truth for the same list is how a publish sends photos the person removed.
|
|
247
264
|
const images = options.images?.refs ?? values.images;
|
|
265
|
+
// Same rule for the category when the container holds it: the schema on
|
|
266
|
+
// screen is read for the container's id, so a local copy that drifted from
|
|
267
|
+
// it would ask one category's questions and file the listing under another.
|
|
268
|
+
const controlledCategory = options.category;
|
|
248
269
|
const effectiveValues: ListingDraftValues = useMemo(
|
|
249
|
-
() => ({
|
|
250
|
-
|
|
270
|
+
() => ({
|
|
271
|
+
...values,
|
|
272
|
+
images,
|
|
273
|
+
...(controlledCategory !== undefined
|
|
274
|
+
? { categoryId: controlledCategory }
|
|
275
|
+
: {}),
|
|
276
|
+
}),
|
|
277
|
+
[values, images, controlledCategory]
|
|
251
278
|
);
|
|
252
279
|
|
|
253
280
|
const featuresDto = useMemo(
|
|
@@ -435,14 +462,20 @@ export function useListingComposer(
|
|
|
435
462
|
},
|
|
436
463
|
setCategory: (categoryId) => {
|
|
437
464
|
setSaved(false);
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
465
|
+
// Told upwards FIRST and unconditionally: the container's schema read is
|
|
466
|
+
// keyed by this id, and it must be asked for even when the composer is
|
|
467
|
+
// uncontrolled — that is the wire `features` arrives on.
|
|
468
|
+
options.onCategoryChange?.(categoryId);
|
|
469
|
+
if (controlledCategory === undefined) {
|
|
470
|
+
setValues((current) => {
|
|
471
|
+
if (current.categoryId === categoryId) return current;
|
|
472
|
+
// The features of the NEW category are not known yet (the
|
|
473
|
+
// container's schema read has not run), so nothing is pruned here —
|
|
474
|
+
// pruning happens in the effect below, once they arrive. Recording
|
|
475
|
+
// the intent, not guessing the outcome.
|
|
476
|
+
return { ...current, categoryId };
|
|
477
|
+
});
|
|
478
|
+
}
|
|
446
479
|
setDropped([]);
|
|
447
480
|
setRefusal(undefined);
|
|
448
481
|
},
|
package/src/i18n/es.ts
CHANGED
|
@@ -70,6 +70,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
70
70
|
"listings.card.favorite_add": "Guardar en favoritos",
|
|
71
71
|
"listings.card.favorite_remove": "Quitar de favoritos",
|
|
72
72
|
"listings.card.open": "Abrir",
|
|
73
|
+
"listings.card.sign_in": "Iniciar sesión",
|
|
73
74
|
|
|
74
75
|
"listings.detail.loading": "Cargando el anuncio…",
|
|
75
76
|
"listings.detail.load_failed": "No pudimos cargar este anuncio",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -63,6 +63,8 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
63
63
|
cardFavoriteAdd: "listings.card.favorite_add",
|
|
64
64
|
cardFavoriteRemove: "listings.card.favorite_remove",
|
|
65
65
|
cardOpen: "listings.card.open",
|
|
66
|
+
/** The door beside a blocked favourite: the container supplies WHERE. */
|
|
67
|
+
cardSignIn: "listings.card.sign_in",
|
|
66
68
|
|
|
67
69
|
// ── detail ───────────────────────────────────────────────────────────────
|
|
68
70
|
detailLoading: "listings.detail.loading",
|
|
@@ -218,6 +220,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
218
220
|
"listings.card.favorite_add": "Save to favourites",
|
|
219
221
|
"listings.card.favorite_remove": "Remove from favourites",
|
|
220
222
|
"listings.card.open": "Open",
|
|
223
|
+
"listings.card.sign_in": "Sign in",
|
|
221
224
|
|
|
222
225
|
"listings.detail.loading": "Loading the listing…",
|
|
223
226
|
"listings.detail.load_failed": "We could not load this listing",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -86,6 +86,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
86
86
|
"listings.card.favorite_add": "В избранное",
|
|
87
87
|
"listings.card.favorite_remove": "Убрать из избранного",
|
|
88
88
|
"listings.card.open": "Открыть",
|
|
89
|
+
"listings.card.sign_in": "Войти",
|
|
89
90
|
|
|
90
91
|
"listings.detail.loading": "Загружаем объявление…",
|
|
91
92
|
"listings.detail.load_failed": "Не удалось загрузить объявление",
|