@stapel/listings-react 0.2.0 → 0.3.1

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 (55) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/MODULE.md +107 -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/ListingDetailPane.d.ts.map +1 -1
  17. package/dist/default/ListingDetailPane.js +1 -1
  18. package/dist/default/ListingDetailPane.js.map +1 -1
  19. package/dist/default/SignInLink.d.ts +28 -0
  20. package/dist/default/SignInLink.d.ts.map +1 -0
  21. package/dist/default/SignInLink.js +17 -0
  22. package/dist/default/SignInLink.js.map +1 -0
  23. package/dist/default/index.d.ts +5 -3
  24. package/dist/default/index.d.ts.map +1 -1
  25. package/dist/default/index.js +1 -0
  26. package/dist/default/index.js.map +1 -1
  27. package/dist/headless/ListingComposer.d.ts +17 -0
  28. package/dist/headless/ListingComposer.d.ts.map +1 -1
  29. package/dist/headless/ListingComposer.js +26 -10
  30. package/dist/headless/ListingComposer.js.map +1 -1
  31. package/dist/i18n/es.d.ts.map +1 -1
  32. package/dist/i18n/es.js +2 -1
  33. package/dist/i18n/es.js.map +1 -1
  34. package/dist/i18n/keys.d.ts +5 -0
  35. package/dist/i18n/keys.d.ts.map +1 -1
  36. package/dist/i18n/keys.js +7 -1
  37. package/dist/i18n/keys.js.map +1 -1
  38. package/dist/i18n/ru.d.ts.map +1 -1
  39. package/dist/i18n/ru.js +2 -1
  40. package/dist/i18n/ru.js.map +1 -1
  41. package/llms.txt +1 -1
  42. package/manifest.json +2 -1
  43. package/nav-manifest.json +1 -1
  44. package/package.json +4 -4
  45. package/src/analytics/generated/events.json +1 -1
  46. package/src/default/FavoritesPane.tsx +40 -11
  47. package/src/default/ListingCard.tsx +144 -27
  48. package/src/default/ListingComposerPage.tsx +79 -14
  49. package/src/default/ListingDetailPane.tsx +6 -1
  50. package/src/default/SignInLink.tsx +54 -0
  51. package/src/default/index.ts +15 -3
  52. package/src/headless/ListingComposer.tsx +43 -10
  53. package/src/i18n/es.ts +2 -1
  54. package/src/i18n/keys.ts +7 -1
  55. package/src/i18n/ru.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,167 @@
1
1
  # @stapel/listings-react
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 67793b0: The stock row printed `{count}` at a buyer, and the wiring snippet taught the
8
+ banned idiom
9
+
10
+ Two things the live storefront walk found, both of them the shape of a
11
+ sentence rather than a bug in behaviour.
12
+
13
+ **`<ListingDetailPane>`'s stock row.** `listings.detail.stock` was a whole
14
+ sentence — `"В наличии: {count}"` — used as a `<Descriptions.Item>` LABEL, with
15
+ the quantity in the value cell beside it. antd renders the label as written, so
16
+ the page said `В наличии: {count}: 3`: a raw placeholder in front of a shopper,
17
+ on the one row that tells them whether the thing is in stock. The row is now
18
+ what a `<Descriptions>` row is — a label cell (`"In stock"` / `"В наличии"` /
19
+ `"Disponibles"`, no placeholder; antd draws the colon) and a value cell holding
20
+ the number, `data-testid="listings-detail-stock"`. A host that overrode this
21
+ key must drop the `{count}` from its own copy.
22
+
23
+ **MODULE.md §4.1.** The composer's wiring example handed features in as
24
+ `features={features.data ?? []}` — the exact expression `stapel/
25
+ no-flattened-load-state` exists to refuse, published as the recommended way to
26
+ mount the screen. It now reads `loadStateFromQuery` → `loadedRowsOrEmpty` +
27
+ `isLoadLoading` / `isLoadFailed`, so "still loading", "this category asks
28
+ nothing" and "the schema read failed" stay three answers where the publish gate
29
+ can still tell them apart.
30
+
31
+ **The peer floor states what the imports already require.** `LinkComponent`,
32
+ `SignInCta` and `SignInCtaProp` first shipped in `@stapel/core@0.16.0`, and
33
+ this package has imported them since; the declared floor still said
34
+ `>=0.15.0`. It now reads `>=0.16.0 <1.0.0`.
35
+
36
+ ## 0.3.0
37
+
38
+ ### Minor Changes
39
+
40
+ - 88a8be4: The composer's category seam runs in both directions, so `/new` can be mounted
41
+
42
+ A seam that only goes one way is not a seam. `ListingComposerPage.categorySlot`
43
+ was a `ReactNode`, and the composer's category moves only through
44
+ `bag.setCategory` — which a node handed in from outside cannot reach. There was
45
+ no `onCategoryChange` either, so a container could neither set the category nor
46
+ learn it, and `features` — the schema OF the chosen category, the entire reason
47
+ the slot exists — was unreachable rather than withheld. The screen rendered and
48
+ could not be used; the storefront named it a gap instead of shipping it (Wave
49
+ D, G-1).
50
+
51
+ Two ways in, and `categorySlot` keeps rendering (deprecated, nothing breaks):
52
+
53
+ ```tsx
54
+ <ListingComposerPage
55
+ category={categoryId === null ? "" : String(categoryId)}
56
+ onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
57
+ renderCategoryPicker={({ value, setCategory }) => (
58
+ <CategoryPickerField
59
+ value={value === "" ? null : Number(value)}
60
+ onChange={(id) => setCategory(id === null ? "" : String(id))}
61
+ />
62
+ )}
63
+ features={features.data ?? []}
64
+ featuresLoading={features.isPending}
65
+ featuresError={features.error ?? undefined}
66
+ />
67
+ ```
68
+
69
+ `renderCategoryPicker({ value, setCategory })` is the render-prop shape
70
+ `<CategoryPage renderListings>` already uses in the sibling pair. `category` /
71
+ `onCategoryChange` make the hook controlled on that one field, for the
72
+ container that holds the id anyway — it must, because
73
+ `useCategoryFeatures(id)` is keyed by it. `onCategoryChange` fires either way:
74
+ it is the wire the schema read is asked for on.
75
+
76
+ `useListingComposer` takes the same two options, so a host with its own skin
77
+ gets the same seam.
78
+
79
+ The README's example is now the wiring that works, and
80
+ `test/composerCategorySeam.test.tsx` gates it against the props declaration —
81
+ this pair documented `<MediaGalleryField bag={…}>` for a whole release while
82
+ the package had no such prop, and nothing in the suite could tell.
83
+
84
+ - 9230f5f: `<ListingCard>`: one click, one navigation — and a `<Link>` it can be handed
85
+
86
+ `href` and `onOpen` were two optional props and the card rendered BOTH when
87
+ both were given: the handler ran, the container routed, and the browser then
88
+ followed the anchor still sitting on the button. Two navigations for one click.
89
+ The storefront's workaround was `onOpen` alone, which cost the most linkable
90
+ element in the app its anchor — no middle-click, no "open in new tab", nothing
91
+ for a crawler to follow (Wave D, G-2).
92
+
93
+ `ListingCardOpenProps` is now a union with three arms and no fourth:
94
+
95
+ ```tsx
96
+ <ListingCard listing={row} href={`/l/${row.id}`} /> // an anchor
97
+ <ListingCard listing={row} href={`/l/${row.id}`} linkComponent={Link} /> // the host's <Link>
98
+ <ListingCard listing={row} onOpen={(id) => navigate(`/l/${id}`)} /> // a button
99
+ <ListingCard listing={row} /> // no open control
100
+ ```
101
+
102
+ Passing `href` and `onOpen` together no longer typechecks, and neither does a
103
+ `linkComponent` on the callback arm — `linkComponent` IS the link.
104
+
105
+ `linkComponent` is `@stapel/core`'s `LinkComponent`, a component taking a plain
106
+ `href`, so this pair stays router-agnostic and a container keeps a real anchor
107
+ while the click stays inside the SPA:
108
+
109
+ ```tsx
110
+ const RouterLink: LinkComponent = ({ href, children, ...rest }) => (
111
+ <Link to={href} {...rest}>
112
+ {children}
113
+ </Link>
114
+ );
115
+ ```
116
+
117
+ `<FavoritesPane>` takes the same union one level up (`hrefFor` / `onOpen` /
118
+ `linkComponent`), so a pane cannot re-introduce upstream what the card refuses.
119
+
120
+ Breaking only for a caller that passed both props — which is the defect this
121
+ release removes, and which had no correct behaviour to preserve.
122
+
123
+ - 3e2e2a3: A blocked control now carries the door, not just the reason: `signIn`
124
+
125
+ `actionBlocked` ended the grey-rectangle incident by making every switched-off
126
+ control state its reason. It did not end the next one. "Sign in to save this",
127
+ "sign in to leave a review", "sign in to message the seller" are reasons whose
128
+ next action is a LINK, and no pair took one — so the storefront had to put its
129
+ own notice a screen away from each of the three controls it was about, and
130
+ named it a gap rather than shipping it (Wave D, G-3).
131
+
132
+ All three now take the same prop, core's `SignInCta`:
133
+
134
+ ```tsx
135
+ <ListingCard listing={row} signIn={{ href: `/login?next=${here}` }} />
136
+ <ReviewsPanel target={target} signIn={{ href: `/login?next=${here}` }} />
137
+ <StartChatButton sellerId={sellerId} signIn={{ onSignIn: () => openModal() }} />
138
+ ```
139
+
140
+ `{href}` **or** `{onSignIn}`, never both. Omit it and the reason renders alone,
141
+ with no trailing whitespace where the link is not — a host with no sign-in
142
+ route is a supported host.
143
+
144
+ Two more things each pair had to fix to make the door reachable:
145
+
146
+ - **listings**: the favourite's reason lived only in a `title` on a DISABLED
147
+ button, which receives no pointer events in any browser — core's own
148
+ `actionGate.ts` calls that "a reason nobody can read". It is now text beside
149
+ the heart (`listings-card-favorite-blocked`), with the link inside it. The
150
+ heart is still never hidden from a visitor.
151
+ - **chat**: `StartDirectChat` had no mandate gate at all, so a visitor could
152
+ press "message the seller" and collect a 401 — a refusal delivered at the one
153
+ moment it is useless. The axis is now the first arm of its `firstBlock`, read
154
+ through core's `MandateSource` seam. `member` may write; `guest`/`anonymous`
155
+ are told to sign in; `asking` says we are still asking. `unavailable` stays
156
+ AVAILABLE on purpose: that is what core answers outside a `<MandateProvider>`
157
+ too, and a host that never wired the axis must not lose its button — "we
158
+ could not ask" is not "you may not". This raises chat-react's `@stapel/core`
159
+ floor to `>=0.15.0`, where `useMandate`/`matchMandate` shipped.
160
+
161
+ The link's LABEL is each pair's own (`listings.card.sign_in`,
162
+ `reviews.form.sign_in`, `chat.start.sign_in`), in all three locales — core
163
+ floors `en` and `ru`, and these pairs also ship `es`.
164
+
3
165
  ## 0.2.0
4
166
 
5
167
  ### Minor Changes
package/MODULE.md CHANGED
@@ -137,13 +137,117 @@ never import each other (the monorepo README states the direction). So:
137
137
  by `useUploadQueue()`'s bag. `bag.refs` IS the value of `images_draft` (same
138
138
  order, first tile the cover) and `bag.settled` is the submit gate. That pair
139
139
  wrote its bag to this contract on purpose (its §13.6 note 9);
140
- - **the category schema** is a plain `readonly FeatureDef[]`, plus a
141
- `categorySlot` for the chooser;
140
+ - **the category schema** is a plain `readonly FeatureDef[]`, plus
141
+ `renderCategoryPicker` for the chooser (see below);
142
142
  - **a stored image reference** is resolved by a host-supplied
143
- `ListingImageResolver`.
143
+ `ListingImageResolver`;
144
+ - **navigation** is core's `LinkComponent` — a component taking a plain `href`.
145
+ Not react-router: there are several routers and a library that picks one
146
+ picks it for every host.
144
147
 
145
148
  `@stapel/attributes-react` IS a dependency; it is L0, like `@stapel/image`.
146
149
 
150
+ ### 4.0 How a card opens is one contract, and the type enforces it
151
+
152
+ `href` and `onOpen` were two optional props, and a card given both navigated
153
+ TWICE for one click: the handler ran, the container routed, and the browser
154
+ then followed the anchor still sitting on the button. The storefront worked
155
+ around it by passing `onOpen` alone — which cost the most linkable element in
156
+ the app its anchor (no middle-click, no "open in new tab", nothing for a
157
+ crawler) and was named as gap G-2 rather than shipped as a preference.
158
+
159
+ `ListingCardOpenProps` is now a three-armed union — `{href, linkComponent?}`,
160
+ `{onOpen}`, or neither — so exactly one navigation mechanism reaches the DOM
161
+ and "both" does not typecheck. `linkComponent` rides on the link arm because
162
+ it IS the link; handing one to a callback card would be two answers to one
163
+ question again. `<FavoritesPane>` takes the same union one level up
164
+ (`hrefFor` / `onOpen` / `linkComponent`), so a pane cannot re-introduce
165
+ upstream what the card refuses.
166
+
167
+ `test/cardNavigation.test.tsx` asserts on the DOM, not on the props: an anchor
168
+ and no handler, a button and no `href`, a `linkComponent` and no `<a href>` at
169
+ all, plus two `@ts-expect-error` cases for the combinations that used to
170
+ compile.
171
+
172
+ ### 4.1 The category seam has to run in both directions
173
+
174
+ A seam that only goes one way is not a seam. Until 0.3.0 the chooser arrived
175
+ as `categorySlot: ReactNode` and the composer's category moved only through
176
+ `bag.setCategory` — which a node handed in from outside cannot reach. There
177
+ was no `onCategoryChange` either, so the container could neither set the
178
+ category nor learn it, and `features` — the schema OF the chosen category,
179
+ which is the entire reason the slot exists — was unreachable rather than
180
+ withheld. The screen could not be mounted at all (storefront Wave D, G-1).
181
+
182
+ Two ways in, both shipping in 0.3.0:
183
+
184
+ - `renderCategoryPicker({ value, setCategory })` — the render-prop shape
185
+ `<CategoryPage renderListings>` already uses in the sibling pair;
186
+ - `category` / `onCategoryChange` — controlled, for the container that holds
187
+ the id anyway.
188
+
189
+ `categorySlot` still renders (nothing that passed it breaks) and is
190
+ deprecated.
191
+
192
+ **The features wiring, end to end, and it is the container's:**
193
+
194
+ ```tsx
195
+ const [categoryId, setCategoryId] = useState<number | null>(null);
196
+ const query = useCategoryFeatures(categoryId); // @stapel/categories-react
197
+ const features = loadStateFromQuery(query); // @stapel/core
198
+
199
+ <ListingComposerPage
200
+ category={categoryId === null ? "" : String(categoryId)}
201
+ onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
202
+ renderCategoryPicker={({ value, setCategory }) => (
203
+ <CategoryPickerField
204
+ value={value === "" ? null : Number(value)}
205
+ onChange={(id) => setCategory(id === null ? "" : String(id))}
206
+ />
207
+ )}
208
+ features={loadedRowsOrEmpty(features)}
209
+ featuresLoading={isLoadLoading(features)}
210
+ featuresError={isLoadFailed(features) ? features.error : undefined}
211
+ />
212
+ ```
213
+
214
+ `features={query.data ?? []}` is what this snippet used to say, and the fleet's
215
+ own `stapel/no-flattened-load-state` forbids it: `?? []` collapses "still
216
+ loading", "loaded and this category asks nothing" and "the schema read failed"
217
+ into one empty array, and the publish gate downstream then cannot tell the
218
+ three apart. `loadStateFromQuery` keeps the discriminant and
219
+ `loadedRowsOrEmpty` is the sanctioned way to hand a non-discriminating
220
+ consumer its rows, with the state — not a flattened array — answering the two
221
+ flags beside it.
222
+
223
+ `useCategoryFeatures` returns `UseQueryResult<readonly CategoryFeature[]>` and
224
+ `CategoryFeature` IS `FeatureDef` (categories-react re-exports the L0 type
225
+ rather than mirroring it), so nothing converts. The three props travel
226
+ together on purpose: `features={[]}` alone would say "this category asks
227
+ nothing", which during the read is a lie the publish gate must not repeat.
228
+
229
+ Category ids cross this seam as STRINGS because that is what
230
+ `Listing.category` carries on the wire; the picker speaks numbers, and the two
231
+ lines above are the whole conversion — done once, in the container, where both
232
+ halves are visible.
233
+
234
+ ### 4.2 A stated reason needs a next action
235
+
236
+ `actionBlocked` ended the grey-rectangle incident: every switched-off control
237
+ states its reason. It did not end the next one. "Sign in to save this" is a
238
+ reason whose next action is a LINK, and no pair took one — so the storefront
239
+ put its own notice a screen away from the three controls it was about (Wave D,
240
+ G-3), and the card's reason lived only in a `title` on a disabled button, which
241
+ receives no pointer events in any browser.
242
+
243
+ Both halves are closed here: the reason renders as text beside the heart, and
244
+ `signIn` — core's `SignInCta`, `{href}` or `{onSignIn}` and never both — is
245
+ rendered inside the same element. `<StartChatButton>` and `<ReviewFormCard>`
246
+ take the identical prop, so a container writes the destination once.
247
+
248
+ The LABEL is this pair's (`listings.card.sign_in`, all three locales) rather
249
+ than core's: core floors `en` and `ru`, and this pair also ships `es`.
250
+
147
251
  ## 5. Gates and their reasons
148
252
 
149
253
  Every write in the pair is behind an `ActionAvailability`. The mandate axis is
package/README.md CHANGED
@@ -68,6 +68,45 @@ container is the seam:
68
68
  that carries each type's display config beside its value. A grid of forty
69
69
  cards therefore costs one query and **no category read**.
70
70
 
71
+ ### How a card opens: one of three, never two
72
+
73
+ ```tsx
74
+ <ListingCard listing={row} href={`/l/${row.id}`} /> // an anchor
75
+ <ListingCard listing={row} href={`/l/${row.id}`} linkComponent={Link} /> // the host's <Link>
76
+ <ListingCard listing={row} onOpen={(id) => navigate(`/l/${id}`)} /> // a button
77
+ <ListingCard listing={row} /> // no open control
78
+ ```
79
+
80
+ `href` and `onOpen` are arms of a union, not two optional props, because a card
81
+ that had both navigated **twice** for one click — the handler ran and the
82
+ browser then followed the anchor. Passing both is now a type error.
83
+
84
+ `linkComponent` is `@stapel/core`'s `LinkComponent`: a component taking a plain
85
+ `href`, so the pair stays router-agnostic and a container keeps a real anchor
86
+ (middle-click, "open in new tab", a crawler) while the click stays inside the
87
+ SPA:
88
+
89
+ ```tsx
90
+ const RouterLink: LinkComponent = ({ href, children, ...rest }) => (
91
+ <Link to={href} {...rest}>{children}</Link>
92
+ );
93
+ ```
94
+
95
+ `<FavoritesPane>` takes the same union (`hrefFor` / `onOpen` / `linkComponent`),
96
+ so a pane cannot re-introduce upstream what the card no longer allows.
97
+
98
+ ### The heart a visitor can see, read, and act on
99
+
100
+ ```tsx
101
+ <ListingCard listing={row} href={`/l/${row.id}`} signIn={{ href: `/login?next=${here}` }} />
102
+ ```
103
+
104
+ The favourite control is never hidden from a visitor — it is switched off, the
105
+ reason is printed as TEXT beside it (a tooltip on a disabled button is a reason
106
+ nobody can read), and `signIn` is the door. `SignInCta` is core's, `{href}`
107
+ **or** `{onSignIn}`, the same prop `@stapel/chat-react` and
108
+ `@stapel/reviews-react` take. Omit it and the reason renders alone.
109
+
71
110
  ## Submitting a listing
72
111
 
73
112
  Four contracts meet on the composer, and three of them arrive as seams rather
@@ -80,17 +119,43 @@ than dependencies:
80
119
  | the value editors | `@stapel/attributes-react` | L0, so a direct dependency |
81
120
 
82
121
  ```tsx
122
+ const [categoryId, setCategoryId] = useState<number | null>(null);
83
123
  const gallery = useUploadQueue({ max: 10 }); // @stapel/cdn-react
84
124
  const features = useCategoryFeatures(categoryId); // @stapel/categories-react
85
125
 
86
126
  <ListingComposerPage
87
- features={features}
127
+ features={features.data ?? []}
128
+ featuresLoading={features.isPending}
129
+ featuresError={features.error ?? undefined}
88
130
  images={gallery}
89
- categorySlot={<CategoryPickerField value={categoryId} onChange={setCategoryId} />}
131
+ category={categoryId === null ? "" : String(categoryId)}
132
+ onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
133
+ renderCategoryPicker={({ value, setCategory }) => (
134
+ <CategoryPickerField
135
+ value={value === "" ? null : Number(value)}
136
+ onChange={(id) => setCategory(id === null ? "" : String(id))}
137
+ />
138
+ )}
90
139
  gallerySlot={<MediaGalleryField bag={gallery} />}
91
140
  />
92
141
  ```
93
142
 
143
+ The category is a **render prop**, not a node, because the composer's category
144
+ moves only through `setCategory` — a node rendered into a slot cannot reach it,
145
+ and a picker that cannot report what was chosen means `features` (the whole
146
+ point of the slot) is never read. `category` / `onCategoryChange` are there
147
+ because the container holds the id anyway: `useCategoryFeatures(id)` is keyed
148
+ by it.
149
+
150
+ The gallery is a node, but the BAG is the same object the composer got — that
151
+ is what makes `bag.refs` the value of `images_draft` and `bag.settled` the
152
+ publish gate. Two queues means the gate talks about photos it cannot see.
153
+
154
+ The schema arrives as three props, not one, so an empty list is never mistaken
155
+ for "this category asks nothing": `featuresLoading` and `featuresError` each
156
+ block the publish with their own sentence, and only a settled, empty schema
157
+ prints "no extra details for this category".
158
+
94
159
  The flow is `create draft → save into it → publish`. The composer always saves
95
160
  before it publishes, because `publish` promotes the STORED draft: publishing
96
161
  without saving would promote whatever was there before the last keystroke.
@@ -8,11 +8,26 @@
8
8
  * the substitution that cost the fleet an incident (spec §7.4).
9
9
  */
10
10
  import type { ReactElement } from "react";
11
+ import type { LinkComponent } from "@stapel/core";
11
12
  import type { ThemeModeProp } from "./types.js";
12
- export interface FavoritesPaneProps extends ThemeModeProp {
13
+ /** How a card in this grid opens — the same one-contract union `<ListingCard>`
14
+ * takes, one level up, so a pane cannot re-introduce the double navigation the
15
+ * card no longer allows. */
16
+ export type FavoritesPaneOpenProps = {
13
17
  /** Where a card leads. The container owns routing. */
14
- readonly hrefFor?: (id: number) => string;
15
- readonly onOpen?: (id: number) => void;
16
- }
18
+ readonly hrefFor: (id: number) => string;
19
+ /** The host's `<Link>`, so a click stays inside the SPA. */
20
+ readonly linkComponent?: LinkComponent;
21
+ readonly onOpen?: undefined;
22
+ } | {
23
+ readonly onOpen: (id: number) => void;
24
+ readonly hrefFor?: undefined;
25
+ readonly linkComponent?: undefined;
26
+ } | {
27
+ readonly hrefFor?: undefined;
28
+ readonly onOpen?: undefined;
29
+ readonly linkComponent?: undefined;
30
+ };
31
+ export type FavoritesPaneProps = ThemeModeProp & FavoritesPaneOpenProps;
17
32
  export declare function FavoritesPane(props: FavoritesPaneProps): ReactElement;
18
33
  //# sourceMappingURL=FavoritesPane.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FavoritesPane.d.ts","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAQ1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,kBAAmB,SAAQ,aAAa;IACvD,sDAAsD;IACtD,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1C,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,YAAY,CAiGrE"}
1
+ {"version":3,"file":"FavoritesPane.d.ts","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAG1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAOlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;4BAE4B;AAC5B,MAAM,MAAM,sBAAsB,GAC9B;IACE,sDAAsD;IACtD,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B,GACD;IACE,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,GACD;IACE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,CAAC;AAEN,MAAM,MAAM,kBAAkB,GAAG,aAAa,GAAG,sBAAsB,CAAC;AAiBxE,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,YAAY,CA2FrE"}
@@ -6,6 +6,18 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
6
6
  import { ErrorAlert } from "./ErrorAlert.js";
7
7
  import { ListingCard } from "./ListingCard.js";
8
8
  import { ListingsSkinTheme } from "./theme.js";
9
+ /** The card's own open props for one row. One arm, never two. */
10
+ function cardOpenProps(props, id) {
11
+ if (props.hrefFor !== undefined) {
12
+ const href = props.hrefFor(id);
13
+ return props.linkComponent !== undefined
14
+ ? { href, linkComponent: props.linkComponent }
15
+ : { href };
16
+ }
17
+ if (props.onOpen !== undefined)
18
+ return { onOpen: props.onOpen };
19
+ return {};
20
+ }
9
21
  export function FavoritesPane(props) {
10
22
  const t = useT();
11
23
  const describe = useDescribeFlowError();
@@ -20,9 +32,7 @@ export function FavoritesPane(props) {
20
32
  language: undefined,
21
33
  }), action: _jsx(Button, { size: "small", "data-analytics": "none", "data-analytics-reason": "retrying a read the person already asked for; not a business action", onClick: bag.refetch, children: t(LISTINGS_I18N_KEYS.mineRetry) }) })),
22
34
  empty: () => (_jsx(Empty, { "data-testid": "listings-favorites-empty", description: t(LISTINGS_I18N_KEYS.favoritesEmpty) })),
23
- ready: (rows) => (_jsx(Flex, { wrap: true, gap: 16, "data-testid": "listings-favorites-grid", children: rows.map((row) => (_jsx("div", { style: { width: 240 }, children: _jsx(ListingCard, { listing: row, ...(props.hrefFor !== undefined
24
- ? { href: props.hrefFor(row.id) }
25
- : {}), ...(props.onOpen !== undefined ? { onOpen: props.onOpen } : {}) }) }, row.id))) })),
35
+ ready: (rows) => (_jsx(Flex, { wrap: true, gap: 16, "data-testid": "listings-favorites-grid", children: rows.map((row) => (_jsx("div", { style: { width: 240 }, children: _jsx(ListingCard, { listing: row, ...cardOpenProps(props, row.id) }) }, row.id))) })),
26
36
  }), _jsxs(Space, { children: [_jsx(Button, { size: "small", disabled: !bag.prevPage.available, "data-testid": "listings-favorites-prev", "data-analytics": "none", "data-analytics-reason": "paging a list the person is already reading; not a business action", onClick: bag.goPrev, children: t(LISTINGS_I18N_KEYS.pagePrev) }), _jsx(Button, { size: "small", disabled: !bag.nextPage.available, "data-testid": "listings-favorites-next", "data-analytics": "none", "data-analytics-reason": "paging a list the person is already reading; not a business action", onClick: bag.goNext, children: t(LISTINGS_I18N_KEYS.pageNext) })] })] }) }));
27
37
  }
28
38
  //# sourceMappingURL=FavoritesPane.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"FavoritesPane.js","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"FavoritesPane.js","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":"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"}
@@ -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,21 +24,55 @@
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";
35
+ import type { LinkComponent, SignInCtaProp } from "@stapel/core";
24
36
  import type { ListingCard as ListingCardData } from "../api/types.js";
25
37
  import type { ThemeModeProp } from "./types.js";
26
- export interface ListingCardProps extends ThemeModeProp {
38
+ /**
39
+ * How the card opens — ONE of three, and the type says so.
40
+ *
41
+ * It used to be two optional props, and a caller that passed both got two
42
+ * navigations for one click: the handler ran and the browser then followed the
43
+ * anchor anyway. The storefront worked around it by passing `onOpen` only,
44
+ * which cost it a real anchor (no middle-click, no "open in new tab", nothing
45
+ * for a crawler to follow) on the most linkable element in the app.
46
+ *
47
+ * So the union has three arms and no fourth: a link, a button, or neither.
48
+ * `linkComponent` belongs to the link arm because it IS the link — handing a
49
+ * `<Link>` to a card that navigates by callback would be two answers to one
50
+ * question again.
51
+ */
52
+ export type ListingCardOpenProps = {
53
+ /** Where the card leads. A plain path — the pair never calls
54
+ * `window.location` and never builds a router descriptor. */
55
+ readonly href: string;
56
+ /** The host's `<Link>`, so the click stays inside the SPA. Absent: an
57
+ * antd link-button carrying the `href`, which reloads the page in a
58
+ * router app — correct, just not fast. */
59
+ readonly linkComponent?: LinkComponent;
60
+ readonly onOpen?: undefined;
61
+ } | {
62
+ /** The card opens by callback: rendered as a button, with no `href` for
63
+ * the browser to follow after the handler has already navigated. */
64
+ readonly onOpen: (id: number) => void;
65
+ readonly href?: undefined;
66
+ readonly linkComponent?: undefined;
67
+ } | {
68
+ /** No open control at all — a card inside a screen that is already the
69
+ * listing. */
70
+ readonly href?: undefined;
71
+ readonly onOpen?: undefined;
72
+ readonly linkComponent?: undefined;
73
+ };
74
+ export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
27
75
  readonly listing: ListingCardData;
28
- /** Where the card leads. The container owns routing, so this is a plain
29
- * href a `<Link>` or an `<a>` can carry — the pair never calls
30
- * `window.location`. */
31
- readonly href?: string;
32
- /** Called instead of following `href`, for a host with its own navigation. */
33
- readonly onOpen?: (id: number) => void;
34
76
  /** Extra chrome the container adds (a `promoted` tag from search, say —
35
77
  * DSA Art. 26 marking belongs to the pair that receives it). */
36
78
  readonly badge?: ReactNode;
@@ -38,5 +80,6 @@ export interface ListingCardProps extends ThemeModeProp {
38
80
  * sense (the owner's own dashboard). NOT a way to hide it from visitors. */
39
81
  readonly showFavorite?: boolean;
40
82
  }
83
+ export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
41
84
  export declare function ListingCard(props: ListingCardProps): ReactElement;
42
85
  //# sourceMappingURL=ListingCard.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ListingCard.d.ts","sourceRoot":"","sources":["../../src/default/ListingCard.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIrD,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAQtE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;;4BAEwB;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC;oEACgE;IAChE,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B;gFAC4E;IAC5E,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CACjC;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,YAAY,CA6HjE"}
1
+ {"version":3,"file":"ListingCard.d.ts","sourceRoot":"","sources":["../../src/default/ListingCard.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGrD,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAEjE,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAStE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,oBAAoB,GAC5B;IACE;iEAC6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;8CAE0C;IAC1C,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B,GACD;IACE;wEACoE;IACpE,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,GACD;IACE;kBACc;IACd,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;CACpC,CAAC;AAEN,MAAM,WAAW,oBAAqB,SAAQ,aAAa,EAAE,aAAa;IACxE,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC;oEACgE;IAChE,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B;gFAC4E;IAC5E,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CACjC;AAED,MAAM,MAAM,gBAAgB,GAAG,oBAAoB,GAAG,oBAAoB,CAAC;AAmE3E,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,YAAY,CAgIjE"}
@@ -1,14 +1,42 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Card, Flex, Tooltip, Typography } from "antd";
3
- import { useT } from "@stapel/core";
3
+ import { useActionGate, useT } from "@stapel/core";
4
4
  import { FeatureBadges } from "@stapel/attributes-react/default";
5
5
  import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
6
6
  import { lifecycleCaption } from "../model/status.js";
7
7
  import { useFavoriteToggle } from "../headless/Favorites.js";
8
8
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
9
9
  import { HeartIcon } from "./icons.js";
10
+ import { SignInLink } from "./SignInLink.js";
10
11
  import { ListingPhoto } from "./ListingPhoto.js";
11
12
  import { ListingsSkinTheme } from "./theme.js";
13
+ /**
14
+ * The one control that opens the card: an anchor, a button, or nothing.
15
+ *
16
+ * Exactly one of the three renders, so exactly one navigation happens per
17
+ * click. That is the whole fix — the branch below has no arm in which both a
18
+ * handler and an `href` reach the DOM.
19
+ */
20
+ function OpenControl(props) {
21
+ const t = useT();
22
+ const label = t(LISTINGS_I18N_KEYS.cardOpen);
23
+ if (props.href !== undefined) {
24
+ const Link = props.linkComponent;
25
+ // The host's component is rendered as it comes: this pair has no CSS and
26
+ // no way to hand antd's button styling to a foreign element, and a wrapper
27
+ // element around a link is a click target that is not the link. A host
28
+ // that wants the antd look styles its own `<Link>` — it is one component,
29
+ // written once, and it is already the thing that knows the design system.
30
+ return Link !== undefined ? (_jsx(Link, { href: props.href, "aria-label": label, "data-testid": "listings-card-open", "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", children: label })) : (_jsx(Button, { size: "small", type: "link", href: props.href, "data-testid": "listings-card-open", "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", children: label }));
31
+ }
32
+ if (props.onOpen !== undefined) {
33
+ const onOpen = props.onOpen;
34
+ return (_jsx(Button, { size: "small", type: "link", "data-testid": "listings-card-open", "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: () => {
35
+ onOpen(props.listingId);
36
+ }, children: label }));
37
+ }
38
+ return null;
39
+ }
12
40
  export function ListingCard(props) {
13
41
  const t = useT();
14
42
  const { listing } = props;
@@ -21,6 +49,7 @@ export function ListingCard(props) {
21
49
  const price = listing.price !== undefined && listing.price.length > 0
22
50
  ? `${listing.price} ${listing.currency ?? ""}`.trim()
23
51
  : t(LISTINGS_I18N_KEYS.cardPriceAbsent);
52
+ const favoriteGate = useActionGate(favorite.gate);
24
53
  const favoriteLabel = t(favorite.favorited
25
54
  ? LISTINGS_I18N_KEYS.cardFavoriteRemove
26
55
  : LISTINGS_I18N_KEYS.cardFavoriteAdd);
@@ -28,10 +57,8 @@ export function ListingCard(props) {
28
57
  ? { "data-listing-status": status.status }
29
58
  : {}), cover: _jsx(ListingPhoto, { imageRef: listing.images?.[0], alt: listing.title ?? String(listing.id), style: { aspectRatio: "4 / 3" } }), children: _jsxs(Flex, { vertical: true, gap: 4, children: [props.badge, _jsx(Typography.Text, { strong: true, "data-testid": "listings-card-price", children: price }), _jsx(Typography.Text, { ellipsis: true, "data-testid": "listings-card-title", children: listing.title ?? "" }), titleDaos.length > 0 ? (_jsx(Typography.Text, { type: "secondary", ellipsis: true, children: _jsx(FeatureBadges, { features: featuresFromDaoList(titleDaos)
30
59
  .map((view) => view.feature), values: featuresDtoFromDaoList(titleDaos) }) })) : null, badgeFeatures.length > 0 ? (_jsx(FeatureBadges, { features: badgeFeatures.map((view) => view.feature), values: badgeValues })) : null, listing.location_label !== undefined &&
31
- listing.location_label.length > 0 ? (_jsx(Typography.Text, { type: "secondary", "data-testid": "listings-card-location", children: listing.location_label })) : null, _jsxs(Flex, { gap: 8, align: "center", children: [props.href !== undefined || props.onOpen !== undefined ? (_jsx(Button, { size: "small", type: "link", ...(props.href !== undefined ? { href: props.href } : {}), "data-testid": "listings-card-open", "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: () => {
32
- props.onOpen?.(listing.id);
33
- }, children: t(LISTINGS_I18N_KEYS.cardOpen) })) : null, props.showFavorite === false ? null : (_jsx(Tooltip, { title: favorite.gate.available
60
+ listing.location_label.length > 0 ? (_jsx(Typography.Text, { type: "secondary", "data-testid": "listings-card-location", children: listing.location_label })) : null, _jsxs(Flex, { gap: 8, align: "center", children: [_jsx(OpenControl, { ...props, listingId: listing.id }), props.showFavorite === false ? null : (_jsx(Tooltip, { title: favorite.gate.available
34
61
  ? favoriteLabel
35
- : t(favorite.gate.block.code, favorite.gate.block.params), children: _jsx("span", { "data-testid": "listings-card-favorite-wrap", children: _jsx(Button, { size: "small", disabled: !favorite.gate.available, "aria-label": favoriteLabel, "aria-pressed": favorite.favorited, "data-testid": "listings-card-favorite", "data-favorited": String(favorite.favorited), "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: favorite.toggle, icon: _jsx(HeartIcon, { filled: favorite.favorited }) }) }) }))] })] }) }) }));
62
+ : t(favorite.gate.block.code, favorite.gate.block.params), children: _jsx("span", { "data-testid": "listings-card-favorite-wrap", children: _jsx(Button, { size: "small", disabled: !favorite.gate.available, "aria-label": favoriteLabel, "aria-pressed": favorite.favorited, "data-testid": "listings-card-favorite", "data-favorited": String(favorite.favorited), "data-analytics": "none", "data-analytics-reason": "business action \u2014 host app wraps with its own tracked()", onClick: favorite.toggle, icon: _jsx(HeartIcon, { filled: favorite.favorited }) }) }) }))] }), props.showFavorite === false || favoriteGate.reason === undefined ? null : (_jsxs(Typography.Text, { type: "secondary", "data-testid": "listings-card-favorite-blocked", children: [favoriteGate.reason, _jsx(SignInLink, { cta: props.signIn, testId: "listings-card-sign-in" })] }))] }) }) }));
36
63
  }
37
64
  //# sourceMappingURL=ListingCard.js.map