@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.
- package/CHANGELOG.md +162 -0
- package/MODULE.md +107 -3
- package/README.md +67 -2
- package/dist/default/FavoritesPane.d.ts +19 -4
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +13 -3
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +54 -11
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +32 -5
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +59 -4
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +12 -3
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +1 -1
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/SignInLink.d.ts +28 -0
- package/dist/default/SignInLink.d.ts.map +1 -0
- package/dist/default/SignInLink.js +17 -0
- package/dist/default/SignInLink.js.map +1 -0
- package/dist/default/index.d.ts +5 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +17 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +26 -10
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +2 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +5 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +7 -1
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +2 -1
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +2 -1
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FavoritesPane.tsx +40 -11
- package/src/default/ListingCard.tsx +144 -27
- package/src/default/ListingComposerPage.tsx +79 -14
- package/src/default/ListingDetailPane.tsx +6 -1
- package/src/default/SignInLink.tsx +54 -0
- package/src/default/index.ts +15 -3
- package/src/headless/ListingComposer.tsx +43 -10
- package/src/i18n/es.ts +2 -1
- package/src/i18n/keys.ts +7 -1
- 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
|
|
141
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
|
|
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;
|
|
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.
|
|
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":";AAUA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAC3E,OAAO,EAAE,SAAS,EAAE,oBAAoB,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"FavoritesPane.js","sourceRoot":"","sources":["../../src/default/FavoritesPane.tsx"],"names":[],"mappings":";AAUA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAC3E,OAAO,EAAE,SAAS,EAAE,oBAAoB,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAErE,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AA2B/C,iEAAiE;AACjE,SAAS,aAAa,CACpB,KAA6B,EAC7B,EAAU;IAEV,IAAI,KAAK,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;QAChC,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC/B,OAAO,KAAK,CAAC,aAAa,KAAK,SAAS;YACtC,CAAC,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,CAAC,aAAa,EAAE;YAC9C,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IACf,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS;QAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC;IAChE,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,KAAyB;IACrD,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC;IACjB,MAAM,QAAQ,GAAG,oBAAoB,EAAE,CAAC;IACxC,MAAM,GAAG,GAAG,YAAY,EAAE,CAAC;IAE3B,OAAO,CACL,KAAC,iBAAiB,OAAK,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,YAC3E,MAAC,IAAI,IAAC,QAAQ,QAAC,GAAG,EAAE,EAAE,iBAAc,oBAAoB,aACtD,KAAC,UAAU,CAAC,KAAK,IAAC,KAAK,EAAE,CAAC,YACvB,CAAC,CAAC,kBAAkB,CAAC,cAAc,CAAC,GACpB,EAElB,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CACrB,KAAC,KAAK,IACJ,IAAI,EAAC,MAAM,EACX,QAAQ,uBACI,4BAA4B,EACxC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GACtD,CACH,CAAC,CAAC,CAAC,IAAI,EAEP,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE;oBACnB,OAAO,EAAE,GAAG,EAAE,CAAC,CACb,KAAC,IAAI,IAAC,OAAO,EAAC,QAAQ,iBAAa,4BAA4B,YAC7D,KAAC,IAAI,kBAAa,CAAC,CAAC,kBAAkB,CAAC,gBAAgB,CAAC,GAAI,GACvD,CACR;oBACD,MAAM,EAAE,GAAG,EAAE,CAAC,CACZ,KAAC,UAAU,IACT,MAAM,EAAC,0BAA0B,EACjC,KAAK,EAAE,QAAQ,CAAC;4BACd,IAAI,EAAE,kBAAkB,CAAC,mBAAmB;4BAC5C,MAAM,EAAE,EAAE;4BACV,MAAM,EAAE,CAAC;4BACT,OAAO,EAAE,SAAS;4BAClB,QAAQ,EAAE,SAAS;yBACpB,CAAC,EACF,MAAM,EACJ,KAAC,MAAM,IACL,IAAI,EAAC,OAAO,oBACG,MAAM,2BACC,qEAAqE,EAC3F,OAAO,EAAE,GAAG,CAAC,OAAO,YAEnB,CAAC,CAAC,kBAAkB,CAAC,SAAS,CAAC,GACzB,GAEX,CACH;oBACD,KAAK,EAAE,GAAG,EAAE,CAAC,CACX,KAAC,KAAK,mBACQ,0BAA0B,EACtC,WAAW,EAAE,CAAC,CAAC,kBAAkB,CAAC,cAAc,CAAC,GACjD,CACH;oBACD,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CACf,KAAC,IAAI,IAAC,IAAI,QAAC,GAAG,EAAE,EAAE,iBAAc,yBAAyB,YACtD,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CACjB,cAAkB,KAAK,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,YACrC,KAAC,WAAW,IAAC,OAAO,EAAE,GAAG,KAAM,aAAa,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,CAAC,GAAI,IADvD,GAAG,CAAC,EAAE,CAEV,CACP,CAAC,GACG,CACR;iBACF,CAAC,EAEF,MAAC,KAAK,eACJ,KAAC,MAAM,IACL,IAAI,EAAC,OAAO,EACZ,QAAQ,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,iBACrB,yBAAyB,oBACtB,MAAM,2BACC,oEAAoE,EAC1F,OAAO,EAAE,GAAG,CAAC,MAAM,YAElB,CAAC,CAAC,kBAAkB,CAAC,QAAQ,CAAC,GACxB,EACT,KAAC,MAAM,IACL,IAAI,EAAC,OAAO,EACZ,QAAQ,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,iBACrB,yBAAyB,oBACtB,MAAM,2BACC,oEAAoE,EAC1F,OAAO,EAAE,GAAG,CAAC,MAAM,YAElB,CAAC,CAAC,kBAAkB,CAAC,QAAQ,CAAC,GACxB,IACH,IACH,GACW,CACrB,CAAC;AACJ,CAAC"}
|
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
* `@stapel/search-react` takes a `renderCard` slot and the container fills it
|
|
5
5
|
* with this (spec §3.7 / §6.2 item 1). The two pairs never import each other;
|
|
6
6
|
* the CONTAINER is the seam, which is why this component takes a plain card
|
|
7
|
-
* row and a plain `href
|
|
7
|
+
* row and a plain `href` rather than reaching for a router.
|
|
8
|
+
*
|
|
9
|
+
* ── One click, one navigation ──────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* `href` and `onOpen` used to be two optional props, and a card given both
|
|
12
|
+
* navigated TWICE: the handler ran, and the browser then followed the anchor
|
|
13
|
+
* that was still on the button. They are now three arms of a union — link,
|
|
14
|
+
* button, or neither — and `linkComponent` rides on the link arm so a
|
|
15
|
+
* container can hand in its router's `<Link>` and keep the anchor.
|
|
8
16
|
*
|
|
9
17
|
* ── What it renders without asking the server anything else ────────────────
|
|
10
18
|
*
|
|
@@ -16,21 +24,55 @@
|
|
|
16
24
|
*
|
|
17
25
|
* ── The heart is never hidden ──────────────────────────────────────────────
|
|
18
26
|
*
|
|
19
|
-
* A visitor sees it, blocked, with the reason
|
|
20
|
-
* container
|
|
21
|
-
* favourites exist (private-space canon
|
|
27
|
+
* A visitor sees it, blocked, with the reason IN WORDS beside it and the
|
|
28
|
+
* sign-in link the container supplies (`signIn`, typically `?next=<current>`).
|
|
29
|
+
* Hiding it would teach nobody that favourites exist (private-space canon
|
|
30
|
+
* §6.3, spec §6.2 item 6) — and until 0.3.0 the reason lived only in a tooltip
|
|
31
|
+
* on a disabled button, which receives no pointer events in any browser: a
|
|
32
|
+
* reason nobody could read, and no door to walk through.
|
|
22
33
|
*/
|
|
23
34
|
import type { ReactElement, ReactNode } from "react";
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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
|