@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/MODULE.md +97 -3
  3. package/README.md +67 -2
  4. package/dist/default/FavoritesPane.d.ts +19 -4
  5. package/dist/default/FavoritesPane.d.ts.map +1 -1
  6. package/dist/default/FavoritesPane.js +13 -3
  7. package/dist/default/FavoritesPane.js.map +1 -1
  8. package/dist/default/ListingCard.d.ts +54 -11
  9. package/dist/default/ListingCard.d.ts.map +1 -1
  10. package/dist/default/ListingCard.js +32 -5
  11. package/dist/default/ListingCard.js.map +1 -1
  12. package/dist/default/ListingComposerPage.d.ts +59 -4
  13. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  14. package/dist/default/ListingComposerPage.js +12 -3
  15. package/dist/default/ListingComposerPage.js.map +1 -1
  16. package/dist/default/SignInLink.d.ts +28 -0
  17. package/dist/default/SignInLink.d.ts.map +1 -0
  18. package/dist/default/SignInLink.js +17 -0
  19. package/dist/default/SignInLink.js.map +1 -0
  20. package/dist/default/index.d.ts +5 -3
  21. package/dist/default/index.d.ts.map +1 -1
  22. package/dist/default/index.js +1 -0
  23. package/dist/default/index.js.map +1 -1
  24. package/dist/headless/ListingComposer.d.ts +17 -0
  25. package/dist/headless/ListingComposer.d.ts.map +1 -1
  26. package/dist/headless/ListingComposer.js +26 -10
  27. package/dist/headless/ListingComposer.js.map +1 -1
  28. package/dist/i18n/es.d.ts.map +1 -1
  29. package/dist/i18n/es.js +1 -0
  30. package/dist/i18n/es.js.map +1 -1
  31. package/dist/i18n/keys.d.ts +2 -0
  32. package/dist/i18n/keys.d.ts.map +1 -1
  33. package/dist/i18n/keys.js +3 -0
  34. package/dist/i18n/keys.js.map +1 -1
  35. package/dist/i18n/ru.d.ts.map +1 -1
  36. package/dist/i18n/ru.js +1 -0
  37. package/dist/i18n/ru.js.map +1 -1
  38. package/llms.txt +1 -1
  39. package/manifest.json +2 -1
  40. package/nav-manifest.json +1 -1
  41. package/package.json +2 -2
  42. package/src/analytics/generated/events.json +1 -1
  43. package/src/default/FavoritesPane.tsx +40 -11
  44. package/src/default/ListingCard.tsx +144 -27
  45. package/src/default/ListingComposerPage.tsx +79 -14
  46. package/src/default/SignInLink.tsx +54 -0
  47. package/src/default/index.ts +15 -3
  48. package/src/headless/ListingComposer.tsx +43 -10
  49. package/src/i18n/es.ts +1 -0
  50. package/src/i18n/keys.ts +3 -0
  51. 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.2.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.15.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",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -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
- export interface FavoritesPaneProps extends ThemeModeProp {
21
- /** Where a card leads. The container owns routing. */
22
- readonly hrefFor?: (id: number) => string;
23
- readonly onOpen?: (id: number) => void;
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`/`onOpen` rather than reaching for a router.
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 and the sign-in CTA the
20
- * container attaches (`?next=`). Hiding it would teach nobody that
21
- * favourites exist (private-space canon §6.3, spec §6.2 item 6).
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
- export interface ListingCardProps extends ThemeModeProp {
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.href !== undefined || props.onOpen !== undefined ? (
131
- <Button
132
- size="small"
133
- type="link"
134
- {...(props.href !== undefined ? { href: props.href } : {})}
135
- data-testid="listings-card-open"
136
- data-analytics="none"
137
- data-analytics-reason="business action — host app wraps with its own tracked()"
138
- onClick={() => {
139
- props.onOpen?.(listing.id);
140
- }}
141
- >
142
- {t(LISTINGS_I18N_KEYS.cardOpen)}
143
- </Button>
144
- ) : null}
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 `categorySlot` — the container's `<CategoryPickerField>`
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>`, whose
11
- * bag is handed to the composer as `images`
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
- /** The category chooser. A slot for the same reason. */
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.categorySlot ?? (
164
- <Input
165
- value={bag.values.categoryId}
166
- aria-label={t(LISTINGS_I18N_KEYS.composeCategory)}
167
- data-testid="listings-composer-category"
168
- onChange={(event) => {
169
- bag.setCategory(event.target.value);
170
- }}
171
- />
172
- )}
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
+ }
@@ -15,21 +15,33 @@
15
15
  * ```
16
16
  */
17
17
  export { ListingCard } from "./ListingCard.js";
18
- export type { ListingCardProps } from "./ListingCard.js";
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 { ListingComposerPageProps } from "./ListingComposerPage.js";
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 { FavoritesPaneProps } from "./FavoritesPane.js";
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
- () => ({ ...values, images }),
250
- [values, images]
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
- setValues((current) => {
439
- if (current.categoryId === categoryId) return current;
440
- // The features of the NEW category are not known yet (the container's
441
- // schema read has not run), so nothing is pruned here — pruning
442
- // happens in the effect below, once they arrive. Recording the
443
- // intent, not guessing the outcome.
444
- return { ...current, categoryId };
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": "Не удалось загрузить объявление",