@stapel/listings-react 0.1.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 (55) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +97 -3
  4. package/README.md +67 -2
  5. package/dist/api/types.d.ts +1 -1
  6. package/dist/api/types.js +1 -1
  7. package/dist/default/FavoritesPane.d.ts +19 -4
  8. package/dist/default/FavoritesPane.d.ts.map +1 -1
  9. package/dist/default/FavoritesPane.js +13 -3
  10. package/dist/default/FavoritesPane.js.map +1 -1
  11. package/dist/default/ListingCard.d.ts +54 -11
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +32 -5
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingComposerPage.d.ts +59 -4
  16. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  17. package/dist/default/ListingComposerPage.js +12 -3
  18. package/dist/default/ListingComposerPage.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 +1 -0
  33. package/dist/i18n/es.js.map +1 -1
  34. package/dist/i18n/keys.d.ts +2 -0
  35. package/dist/i18n/keys.d.ts.map +1 -1
  36. package/dist/i18n/keys.js +3 -0
  37. package/dist/i18n/keys.js.map +1 -1
  38. package/dist/i18n/ru.d.ts +1 -1
  39. package/dist/i18n/ru.d.ts.map +1 -1
  40. package/dist/i18n/ru.js +2 -1
  41. package/dist/i18n/ru.js.map +1 -1
  42. package/llms.txt +1 -1
  43. package/manifest.json +2 -1
  44. package/nav-manifest.json +1 -1
  45. package/package.json +16 -16
  46. package/src/analytics/generated/events.json +1 -1
  47. package/src/default/FavoritesPane.tsx +40 -11
  48. package/src/default/ListingCard.tsx +144 -27
  49. package/src/default/ListingComposerPage.tsx +79 -14
  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 +1 -0
  54. package/src/i18n/keys.ts +3 -0
  55. package/src/i18n/ru.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -1 +1,195 @@
1
1
  # @stapel/listings-react
2
+
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 88a8be4: The composer's category seam runs in both directions, so `/new` can be mounted
8
+
9
+ A seam that only goes one way is not a seam. `ListingComposerPage.categorySlot`
10
+ was a `ReactNode`, and the composer's category moves only through
11
+ `bag.setCategory` — which a node handed in from outside cannot reach. There was
12
+ no `onCategoryChange` either, so a container could neither set the category nor
13
+ learn it, and `features` — the schema OF the chosen category, the entire reason
14
+ the slot exists — was unreachable rather than withheld. The screen rendered and
15
+ could not be used; the storefront named it a gap instead of shipping it (Wave
16
+ D, G-1).
17
+
18
+ Two ways in, and `categorySlot` keeps rendering (deprecated, nothing breaks):
19
+
20
+ ```tsx
21
+ <ListingComposerPage
22
+ category={categoryId === null ? "" : String(categoryId)}
23
+ onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
24
+ renderCategoryPicker={({ value, setCategory }) => (
25
+ <CategoryPickerField
26
+ value={value === "" ? null : Number(value)}
27
+ onChange={(id) => setCategory(id === null ? "" : String(id))}
28
+ />
29
+ )}
30
+ features={features.data ?? []}
31
+ featuresLoading={features.isPending}
32
+ featuresError={features.error ?? undefined}
33
+ />
34
+ ```
35
+
36
+ `renderCategoryPicker({ value, setCategory })` is the render-prop shape
37
+ `<CategoryPage renderListings>` already uses in the sibling pair. `category` /
38
+ `onCategoryChange` make the hook controlled on that one field, for the
39
+ container that holds the id anyway — it must, because
40
+ `useCategoryFeatures(id)` is keyed by it. `onCategoryChange` fires either way:
41
+ it is the wire the schema read is asked for on.
42
+
43
+ `useListingComposer` takes the same two options, so a host with its own skin
44
+ gets the same seam.
45
+
46
+ The README's example is now the wiring that works, and
47
+ `test/composerCategorySeam.test.tsx` gates it against the props declaration —
48
+ this pair documented `<MediaGalleryField bag={…}>` for a whole release while
49
+ the package had no such prop, and nothing in the suite could tell.
50
+
51
+ - 9230f5f: `<ListingCard>`: one click, one navigation — and a `<Link>` it can be handed
52
+
53
+ `href` and `onOpen` were two optional props and the card rendered BOTH when
54
+ both were given: the handler ran, the container routed, and the browser then
55
+ followed the anchor still sitting on the button. Two navigations for one click.
56
+ The storefront's workaround was `onOpen` alone, which cost the most linkable
57
+ element in the app its anchor — no middle-click, no "open in new tab", nothing
58
+ for a crawler to follow (Wave D, G-2).
59
+
60
+ `ListingCardOpenProps` is now a union with three arms and no fourth:
61
+
62
+ ```tsx
63
+ <ListingCard listing={row} href={`/l/${row.id}`} /> // an anchor
64
+ <ListingCard listing={row} href={`/l/${row.id}`} linkComponent={Link} /> // the host's <Link>
65
+ <ListingCard listing={row} onOpen={(id) => navigate(`/l/${id}`)} /> // a button
66
+ <ListingCard listing={row} /> // no open control
67
+ ```
68
+
69
+ Passing `href` and `onOpen` together no longer typechecks, and neither does a
70
+ `linkComponent` on the callback arm — `linkComponent` IS the link.
71
+
72
+ `linkComponent` is `@stapel/core`'s `LinkComponent`, a component taking a plain
73
+ `href`, so this pair stays router-agnostic and a container keeps a real anchor
74
+ while the click stays inside the SPA:
75
+
76
+ ```tsx
77
+ const RouterLink: LinkComponent = ({ href, children, ...rest }) => (
78
+ <Link to={href} {...rest}>
79
+ {children}
80
+ </Link>
81
+ );
82
+ ```
83
+
84
+ `<FavoritesPane>` takes the same union one level up (`hrefFor` / `onOpen` /
85
+ `linkComponent`), so a pane cannot re-introduce upstream what the card refuses.
86
+
87
+ Breaking only for a caller that passed both props — which is the defect this
88
+ release removes, and which had no correct behaviour to preserve.
89
+
90
+ - 3e2e2a3: A blocked control now carries the door, not just the reason: `signIn`
91
+
92
+ `actionBlocked` ended the grey-rectangle incident by making every switched-off
93
+ control state its reason. It did not end the next one. "Sign in to save this",
94
+ "sign in to leave a review", "sign in to message the seller" are reasons whose
95
+ next action is a LINK, and no pair took one — so the storefront had to put its
96
+ own notice a screen away from each of the three controls it was about, and
97
+ named it a gap rather than shipping it (Wave D, G-3).
98
+
99
+ All three now take the same prop, core's `SignInCta`:
100
+
101
+ ```tsx
102
+ <ListingCard listing={row} signIn={{ href: `/login?next=${here}` }} />
103
+ <ReviewsPanel target={target} signIn={{ href: `/login?next=${here}` }} />
104
+ <StartChatButton sellerId={sellerId} signIn={{ onSignIn: () => openModal() }} />
105
+ ```
106
+
107
+ `{href}` **or** `{onSignIn}`, never both. Omit it and the reason renders alone,
108
+ with no trailing whitespace where the link is not — a host with no sign-in
109
+ route is a supported host.
110
+
111
+ Two more things each pair had to fix to make the door reachable:
112
+
113
+ - **listings**: the favourite's reason lived only in a `title` on a DISABLED
114
+ button, which receives no pointer events in any browser — core's own
115
+ `actionGate.ts` calls that "a reason nobody can read". It is now text beside
116
+ the heart (`listings-card-favorite-blocked`), with the link inside it. The
117
+ heart is still never hidden from a visitor.
118
+ - **chat**: `StartDirectChat` had no mandate gate at all, so a visitor could
119
+ press "message the seller" and collect a 401 — a refusal delivered at the one
120
+ moment it is useless. The axis is now the first arm of its `firstBlock`, read
121
+ through core's `MandateSource` seam. `member` may write; `guest`/`anonymous`
122
+ are told to sign in; `asking` says we are still asking. `unavailable` stays
123
+ AVAILABLE on purpose: that is what core answers outside a `<MandateProvider>`
124
+ too, and a host that never wired the axis must not lose its button — "we
125
+ could not ask" is not "you may not". This raises chat-react's `@stapel/core`
126
+ floor to `>=0.15.0`, where `useMandate`/`matchMandate` shipped.
127
+
128
+ The link's LABEL is each pair's own (`listings.card.sign_in`,
129
+ `reviews.form.sign_in`, `chat.start.sign_in`), in all three locales — core
130
+ floors `en` and `ru`, and these pairs also ship `es`.
131
+
132
+ ## 0.2.0
133
+
134
+ ### Minor Changes
135
+
136
+ - fcc9f1e: Two axes, both on screen — the storefront's long pole
137
+
138
+ `@stapel/listings-react` is the pair for stapel-listings: the listing page, the
139
+ submission flow, the seller's dashboard, and favourites.
140
+
141
+ The module has two independent state axes and 0.5.0 made them genuinely
142
+ diverge. `status` decides whether anyone can see a listing and nothing else
143
+ does; `moderation_status` decides nothing about that. Editing a LIVE listing
144
+ keeps `status: published` and moves only the moderation axis, so "published,
145
+ and we are reviewing your changes" is a real state — one a dashboard that
146
+ derived either field from the other could not say, because it would either
147
+ hide a listing buyers are reading or never tell its owner their edit is being
148
+ screened. `model/status.ts` produces both halves of the sentence from both
149
+ fields, once; the 9 × 4 table is asserted.
150
+
151
+ A publish refusal is per-field and arrives in an unusual envelope: an invalid
152
+ draft comes back as a BARE `ValidationBatchResult`, while a promotion that
153
+ fails afterwards comes back as the ordinary one. `publishRefusal` branches on
154
+ the body rather than the status, and `featureErrorsBySlug` adds the `field`
155
+ the fleet's routing convention reads — so "this box is wrong" never degrades
156
+ into "something is wrong".
157
+
158
+ Three contracts meet on the composer and none of them is an import. L2 pairs do
159
+ not import each other, so the gallery arrives as a two-member structural bag
160
+ (`@stapel/cdn-react`'s `refs` IS `images_draft`; its `settled` is the submit
161
+ gate), the category schema as a plain `FeatureDef[]`, and a stored CDN
162
+ reference through a host-supplied resolver — because no contract in this fleet
163
+ resolves a stranger's reference, and inventing `${cdnBase}/${ref}` would be
164
+ writing a contract nobody agreed to. `@stapel/attributes-react` is a real
165
+ dependency; it is L0, and it owns the editors, the mirror and the formatter.
166
+
167
+ Four things the pair says out loud rather than papering over:
168
+
169
+ - **there is no owner-scoped list endpoint.** `GET /listings/` answers
170
+ `published()` and takes no owner parameter, so a seller's drafts are
171
+ unreachable. The counters are real and are shown; the rows come from an
172
+ injected source, and with none the dashboard reports a NAMED failure instead
173
+ of an empty grid;
174
+ - **no read returns the `*_draft` twin**, so an abandoned draft reopens empty
175
+ and the composer says so. Editing a live listing is unaffected — the
176
+ published half IS the listing;
177
+ - **`PUT`/`PATCH` skip the ownership check** that every other owner operation
178
+ in the module performs, so they are absent from `ListingsApi`;
179
+ `save-draft` does the same write with the check;
180
+ - **`GET /{pk}/` has no `published()` filter**, so a draft answers 200 to
181
+ anyone with the id. The pane reports it instead of dressing a draft up as a
182
+ shop page.
183
+
184
+ Also here: the card another pair renders (badges formatted from the stored DAO
185
+ projection, so a grid of forty costs one query and no category read); a soft
186
+ delete that reads as "this listing was removed" rather than as a typo, using
187
+ the AllowAny status probe that still answers for it; favourites (owner verdict
188
+ F7) with the heart blocked-and-explained for a visitor rather than hidden; and
189
+ ru/es carrying the UI copy, not only the error keys, because the storefront is
190
+ ru-first and a half-translated submission form is visible immediately.
191
+
192
+ Generated against stapel-listings **0.6.1** — the release that fixed
193
+ `FeatureDto`/`FeatureDao`'s `discriminator.mapping` from one bogus `"null"`
194
+ entry to the ten type slugs, which is what makes the generated union usable as
195
+ the wire type at all.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/MODULE.md CHANGED
@@ -137,13 +137,107 @@ 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 features = useCategoryFeatures(categoryId); // @stapel/categories-react
197
+
198
+ <ListingComposerPage
199
+ category={categoryId === null ? "" : String(categoryId)}
200
+ onCategoryChange={(id) => setCategoryId(id === "" ? null : Number(id))}
201
+ renderCategoryPicker={({ value, setCategory }) => (
202
+ <CategoryPickerField
203
+ value={value === "" ? null : Number(value)}
204
+ onChange={(id) => setCategory(id === null ? "" : String(id))}
205
+ />
206
+ )}
207
+ features={features.data ?? []}
208
+ featuresLoading={features.isPending}
209
+ featuresError={features.error ?? undefined}
210
+ />
211
+ ```
212
+
213
+ `useCategoryFeatures` returns `UseQueryResult<readonly CategoryFeature[]>` and
214
+ `CategoryFeature` IS `FeatureDef` (categories-react re-exports the L0 type
215
+ rather than mirroring it), so nothing converts. The three props travel
216
+ together on purpose: `features={[]}` alone would say "this category asks
217
+ nothing", which during the read is a lie the publish gate must not repeat.
218
+
219
+ Category ids cross this seam as STRINGS because that is what
220
+ `Listing.category` carries on the wire; the picker speaks numbers, and the two
221
+ lines above are the whole conversion — done once, in the container, where both
222
+ halves are visible.
223
+
224
+ ### 4.2 A stated reason needs a next action
225
+
226
+ `actionBlocked` ended the grey-rectangle incident: every switched-off control
227
+ states its reason. It did not end the next one. "Sign in to save this" is a
228
+ reason whose next action is a LINK, and no pair took one — so the storefront
229
+ put its own notice a screen away from the three controls it was about (Wave D,
230
+ G-3), and the card's reason lived only in a `title` on a disabled button, which
231
+ receives no pointer events in any browser.
232
+
233
+ Both halves are closed here: the reason renders as text beside the heart, and
234
+ `signIn` — core's `SignInCta`, `{href}` or `{onSignIn}` and never both — is
235
+ rendered inside the same element. `<StartChatButton>` and `<ReviewFormCard>`
236
+ take the identical prop, so a container writes the destination once.
237
+
238
+ The LABEL is this pair's (`listings.card.sign_in`, all three locales) rather
239
+ than core's: core floors `en` and `ru`, and this pair also ships `es`.
240
+
147
241
  ## 5. Gates and their reasons
148
242
 
149
243
  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.
@@ -146,7 +146,7 @@ export interface ListingPageParams {
146
146
  /**
147
147
  * The currency a composer starts a new listing in.
148
148
  *
149
- * RUB by owner verdict F6 (`tasks/darom-storefront-design.md`, the owner's
149
+ * RUB by owner verdict F6 (storefront spec, the owner's
150
150
  * fork verdicts of 2026-08-22), and a default rather than a constant: `currency` is
151
151
  * a free `maxLength: 8` string on the wire (stapel-listings has no currency
152
152
  * enum — the vocabulary lives in stapel-currencies), so a deployment that
package/dist/api/types.js CHANGED
@@ -27,7 +27,7 @@ export const MODERATION_STATUSES = [
27
27
  /**
28
28
  * The currency a composer starts a new listing in.
29
29
  *
30
- * RUB by owner verdict F6 (`tasks/darom-storefront-design.md`, the owner's
30
+ * RUB by owner verdict F6 (storefront spec, the owner's
31
31
  * fork verdicts of 2026-08-22), and a default rather than a constant: `currency` is
32
32
  * a free `maxLength: 8` string on the wire (stapel-listings has no currency
33
33
  * enum — the vocabulary lives in stapel-currencies), so a deployment that
@@ -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"}