@stapel/listings-react 0.7.0 → 0.8.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 +88 -0
- package/dist/api/generated/schema.d.ts +92 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ListingCard.d.ts +56 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +92 -29
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +21 -7
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +19 -7
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +7 -5
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/headless/useMandateGate.d.ts +33 -2
- package/dist/headless/useMandateGate.d.ts.map +1 -1
- package/dist/headless/useMandateGate.js +42 -4
- package/dist/headless/useMandateGate.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/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +11 -1
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +12 -2
- 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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/llms.txt +3 -3
- package/manifest.json +30 -21
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +92 -2
- package/src/default/ListingCard.tsx +233 -108
- package/src/default/ListingComposerPage.tsx +21 -7
- package/src/headless/Favorites.tsx +26 -7
- package/src/headless/ListingDetail.tsx +12 -5
- package/src/headless/useMandateGate.ts +49 -5
- package/src/i18n/es.ts +3 -1
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +7 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +12 -2
- package/src/i18n/ru.ts +3 -1
- package/src/index.ts +5 -1
|
@@ -6,6 +6,26 @@
|
|
|
6
6
|
* the CONTAINER is the seam, which is why this component takes a plain card
|
|
7
7
|
* row and a plain `href` rather than reaching for a router.
|
|
8
8
|
*
|
|
9
|
+
* ── The CARD is the link ───────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* Owner ruling (2026-08-28, from the live stand): a card carrying its own
|
|
12
|
+
* "view" button is plainly wrong. The card used to render a separate "Open"
|
|
13
|
+
* control under its own content — a full-width primary button captioned with
|
|
14
|
+
* `listings.card.open` — so a person looking at a photo, a price and a title
|
|
15
|
+
* had to find and press a fourth thing to act on the three they were reading.
|
|
16
|
+
* Nothing on a classified works that way: the card IS the target, and the only
|
|
17
|
+
* separate control on it is the favourite heart.
|
|
18
|
+
*
|
|
19
|
+
* So the photo, the price, the title, the badges and the location now live
|
|
20
|
+
* INSIDE one anchor that covers the whole card, and `listings.card.open` is
|
|
21
|
+
* retired rather than left orphaned in three catalogues.
|
|
22
|
+
*
|
|
23
|
+
* What that must not cost is the anchor semantics won earlier: this is a real
|
|
24
|
+
* `<a href>`, so middle-click opens a tab, ⌘-click opens a tab, "copy link
|
|
25
|
+
* address" works and a crawler can follow it. It is NOT an `onClick` on a div,
|
|
26
|
+
* which is the shape every "whole card clickable" rewrite reaches for first
|
|
27
|
+
* and which has none of those properties.
|
|
28
|
+
*
|
|
9
29
|
* ── One click, one navigation ──────────────────────────────────────────────
|
|
10
30
|
*
|
|
11
31
|
* `href` and `onOpen` used to be two optional props, and a card given both
|
|
@@ -14,6 +34,25 @@
|
|
|
14
34
|
* button, or neither — and `linkComponent` rides on the link arm so a
|
|
15
35
|
* container can hand in its router's `<Link>` and keep the anchor.
|
|
16
36
|
*
|
|
37
|
+
* ── The accessible name is the TITLE, and only the title ───────────────────
|
|
38
|
+
*
|
|
39
|
+
* An anchor's name is computed from its contents unless it is given one, and
|
|
40
|
+
* the contents here are a photo, a price, three badges and a place: a screen
|
|
41
|
+
* reader reading a list of forty of those announces forty paragraphs. The
|
|
42
|
+
* anchor therefore carries an explicit `aria-label` — the listing's title,
|
|
43
|
+
* nothing else — and everything inside it stays readable by ordinary browsing.
|
|
44
|
+
* A listing with no title falls back to `listings.card.untitled`, because a
|
|
45
|
+
* link announced as nothing is worse than one announced as untitled.
|
|
46
|
+
*
|
|
47
|
+
* ── Why the heart is a row under the card and not floating on the photo ────
|
|
48
|
+
*
|
|
49
|
+
* Because for a signed-out visitor — which is most of the traffic a storefront
|
|
50
|
+
* gets — the heart is BLOCKED, and a blocked control states its reason as text
|
|
51
|
+
* beside it. There is nowhere to put that sentence on top of a photograph. The
|
|
52
|
+
* heart therefore sits in its own row beneath the content, outside the anchor
|
|
53
|
+
* (a button inside a link is neither valid nor operable), where the reason has
|
|
54
|
+
* a line to live on.
|
|
55
|
+
*
|
|
17
56
|
* ── What it renders without asking the server anything else ────────────────
|
|
18
57
|
*
|
|
19
58
|
* Badges. `features_badges` is a stored DAO projection, and a DAO carries the
|
|
@@ -34,8 +73,8 @@
|
|
|
34
73
|
* reason nobody could read on any device. That arm is gone; `blockedReason`
|
|
35
74
|
* is now "text" (reason + door) or "line" (reason alone, for a grid).
|
|
36
75
|
*/
|
|
37
|
-
import type { ReactElement, ReactNode } from "react";
|
|
38
|
-
import { Button, Card, Flex, Typography } from "antd";
|
|
76
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
77
|
+
import { Button, Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
39
78
|
import { GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
|
|
40
79
|
import { useActionGate, useT } from "@stapel/core";
|
|
41
80
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
@@ -109,6 +148,53 @@ export type ListingCardOpenProps =
|
|
|
109
148
|
*/
|
|
110
149
|
export type ListingCardBlockedReason = "text" | "line";
|
|
111
150
|
|
|
151
|
+
/** The class the whole-card target carries, for {@link cardTargetCss}. */
|
|
152
|
+
export const CARD_TARGET_CLASS = "stapel-listing-card-target";
|
|
153
|
+
|
|
154
|
+
/** The `href` the hoisted card stylesheet is deduplicated by. */
|
|
155
|
+
export const CARD_TARGET_STYLE_HREF = "stapel-listings-card-target";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The one rule an inline style cannot express: `:focus-visible`.
|
|
159
|
+
*
|
|
160
|
+
* A whole-card link is the largest focus target on a results page and it must
|
|
161
|
+
* SHOW that it has focus — a keyboard visitor tabbing a grid of forty cards
|
|
162
|
+
* with no ring has no idea which one Enter will open. The outline is drawn
|
|
163
|
+
* from the theme's own focus colour, which arrives as a custom property on the
|
|
164
|
+
* element (the sheet is static, so one hoisted copy serves either theme).
|
|
165
|
+
*
|
|
166
|
+
* `--listing-*` rather than `--stapel-*`: the `--stapel-` namespace is the
|
|
167
|
+
* design system's ROLE catalogue and this is a component's private plumbing.
|
|
168
|
+
*/
|
|
169
|
+
export function cardTargetCss(): string {
|
|
170
|
+
return [
|
|
171
|
+
`.${CARD_TARGET_CLASS}{display:block;color:inherit;text-decoration:none}`,
|
|
172
|
+
`.${CARD_TARGET_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
|
|
173
|
+
].join("");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The anchor: a block that inherits the card's own type colour rather than
|
|
177
|
+
* painting every card's contents link-blue. */
|
|
178
|
+
const TARGET_STYLE: CSSProperties = {
|
|
179
|
+
display: "block",
|
|
180
|
+
color: "inherit",
|
|
181
|
+
textDecoration: "none",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The callback arm's button, reset to look like the anchor does. A card that
|
|
185
|
+
* is a target on one deployment and a card-plus-a-button on another would be
|
|
186
|
+
* two different products. */
|
|
187
|
+
const BUTTON_TARGET_STYLE: CSSProperties = {
|
|
188
|
+
...TARGET_STYLE,
|
|
189
|
+
width: "100%",
|
|
190
|
+
padding: 0,
|
|
191
|
+
border: "none",
|
|
192
|
+
background: "none",
|
|
193
|
+
font: "inherit",
|
|
194
|
+
textAlign: "start",
|
|
195
|
+
cursor: "pointer",
|
|
196
|
+
};
|
|
197
|
+
|
|
112
198
|
export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
|
|
113
199
|
readonly listing: ListingCardData;
|
|
114
200
|
/** See {@link ListingCardBlockedReason}. Default `"text"`. */
|
|
@@ -124,60 +210,66 @@ export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
|
|
|
124
210
|
export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
|
|
125
211
|
|
|
126
212
|
/**
|
|
127
|
-
* The
|
|
213
|
+
* The card's own body, wrapped in whatever makes it openable: an anchor, a
|
|
214
|
+
* button, or nothing at all.
|
|
128
215
|
*
|
|
129
216
|
* Exactly one of the three renders, so exactly one navigation happens per
|
|
130
|
-
* click
|
|
131
|
-
*
|
|
217
|
+
* click — the branch below has no arm in which both a handler and an `href`
|
|
218
|
+
* reach the DOM.
|
|
132
219
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
220
|
+
* The BUTTON arm exists for a container that routes by callback. It wraps the
|
|
221
|
+
* same content in a `<button>` reset to look like nothing, rather than drawing
|
|
222
|
+
* a separate captioned control: a card that is a target on one deployment and
|
|
223
|
+
* a card-plus-a-button on another would be two different products.
|
|
137
224
|
*/
|
|
138
|
-
function
|
|
139
|
-
props: ListingCardOpenProps & {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
225
|
+
function CardTarget(
|
|
226
|
+
props: ListingCardOpenProps & {
|
|
227
|
+
readonly listingId: number;
|
|
228
|
+
readonly label: string;
|
|
229
|
+
readonly children: ReactNode;
|
|
230
|
+
}
|
|
231
|
+
): ReactElement {
|
|
232
|
+
const { label, children } = props;
|
|
143
233
|
|
|
144
234
|
if (props.href !== undefined) {
|
|
145
235
|
const Link = props.linkComponent;
|
|
146
236
|
// The host's component is rendered as it comes: this pair has no CSS and
|
|
147
|
-
// no way to hand
|
|
148
|
-
//
|
|
149
|
-
// that wants the antd look styles its own `<Link>` — it is one component,
|
|
150
|
-
// written once, and it is already the thing that knows the design system.
|
|
237
|
+
// no way to hand its own styling to a foreign element. A wrapper element
|
|
238
|
+
// around a link would be a click target that is not the link.
|
|
151
239
|
return Link !== undefined ? (
|
|
152
240
|
<Link
|
|
153
241
|
href={props.href}
|
|
154
242
|
aria-label={label}
|
|
243
|
+
className={CARD_TARGET_CLASS}
|
|
155
244
|
data-testid="listings-card-open"
|
|
156
245
|
data-analytics="none"
|
|
157
246
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
158
247
|
>
|
|
159
|
-
{
|
|
248
|
+
{children}
|
|
160
249
|
</Link>
|
|
161
250
|
) : (
|
|
162
|
-
<
|
|
163
|
-
type="primary"
|
|
164
|
-
block
|
|
251
|
+
<a
|
|
165
252
|
href={props.href}
|
|
253
|
+
aria-label={label}
|
|
254
|
+
className={CARD_TARGET_CLASS}
|
|
255
|
+
style={TARGET_STYLE}
|
|
166
256
|
data-testid="listings-card-open"
|
|
167
257
|
data-analytics="none"
|
|
168
258
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
169
259
|
>
|
|
170
|
-
{
|
|
171
|
-
</
|
|
260
|
+
{children}
|
|
261
|
+
</a>
|
|
172
262
|
);
|
|
173
263
|
}
|
|
174
264
|
|
|
175
265
|
if (props.onOpen !== undefined) {
|
|
176
266
|
const onOpen = props.onOpen;
|
|
177
267
|
return (
|
|
178
|
-
<
|
|
179
|
-
type="
|
|
180
|
-
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
aria-label={label}
|
|
271
|
+
className={CARD_TARGET_CLASS}
|
|
272
|
+
style={BUTTON_TARGET_STYLE}
|
|
181
273
|
data-testid="listings-card-open"
|
|
182
274
|
data-analytics="none"
|
|
183
275
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
@@ -185,18 +277,20 @@ function OpenControl(
|
|
|
185
277
|
onOpen(props.listingId);
|
|
186
278
|
}}
|
|
187
279
|
>
|
|
188
|
-
{
|
|
189
|
-
</
|
|
280
|
+
{children}
|
|
281
|
+
</button>
|
|
190
282
|
);
|
|
191
283
|
}
|
|
192
284
|
|
|
193
|
-
|
|
285
|
+
// No open control at all — a card inside a screen that IS the listing.
|
|
286
|
+
return <div data-testid="listings-card-body">{children}</div>;
|
|
194
287
|
}
|
|
195
288
|
|
|
196
289
|
export function ListingCard(props: ListingCardProps): ReactElement {
|
|
197
290
|
const t = useT();
|
|
198
291
|
const { listing } = props;
|
|
199
292
|
const favorite = useFavoriteToggle(listing.id, listing.is_favorited);
|
|
293
|
+
const { token } = antdTheme.useToken();
|
|
200
294
|
|
|
201
295
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
202
296
|
const badgeFeatures = featuresFromDaoList(badgeDaos);
|
|
@@ -214,11 +308,83 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
214
308
|
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
215
309
|
);
|
|
216
310
|
|
|
311
|
+
const title = listing.title ?? "";
|
|
312
|
+
// The anchor's name is the TITLE and nothing else. A card with no title is
|
|
313
|
+
// still a link, and a link announced as nothing is worse than one announced
|
|
314
|
+
// as untitled.
|
|
315
|
+
const targetLabel =
|
|
316
|
+
title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Everything a person reads on the card, in the order a classified reads it:
|
|
320
|
+
* photo, price, title, the seller's own spec line, the badges, the place.
|
|
321
|
+
*
|
|
322
|
+
* The search projection carries `title`, `price`, `currency`,
|
|
323
|
+
* `location_label`, `image` and `published_at` and NO feature badges — so
|
|
324
|
+
* every line below is conditional and the card has to look deliberate with
|
|
325
|
+
* all of them absent. That is why the photo and the price carry the layout:
|
|
326
|
+
* they are the two fields a result always has.
|
|
327
|
+
*/
|
|
328
|
+
const content = (
|
|
329
|
+
<>
|
|
330
|
+
<ListingPhoto
|
|
331
|
+
imageRef={listing.images?.[0]}
|
|
332
|
+
alt={title.length > 0 ? title : String(listing.id)}
|
|
333
|
+
/>
|
|
334
|
+
<Flex
|
|
335
|
+
vertical
|
|
336
|
+
gap={spacing[1]}
|
|
337
|
+
style={{ minWidth: 0, padding: token.paddingSM }}
|
|
338
|
+
>
|
|
339
|
+
{props.badge}
|
|
340
|
+
|
|
341
|
+
<Typography.Text strong data-testid="listings-card-price">
|
|
342
|
+
<ListingPrice
|
|
343
|
+
amount={listing.price}
|
|
344
|
+
{...(listing.currency !== undefined ? { currency: listing.currency } : {})}
|
|
345
|
+
/>
|
|
346
|
+
</Typography.Text>
|
|
347
|
+
|
|
348
|
+
<Typography.Text ellipsis data-testid="listings-card-title">
|
|
349
|
+
{title}
|
|
350
|
+
</Typography.Text>
|
|
351
|
+
|
|
352
|
+
{/* The title features are a stored projection too — the seller's
|
|
353
|
+
"1.5 TB, black" line, already ordered by the server. */}
|
|
354
|
+
{titleDaos.length > 0 ? (
|
|
355
|
+
<Typography.Text type="secondary" ellipsis>
|
|
356
|
+
<FeatureBadges
|
|
357
|
+
features={featuresFromDaoList(titleDaos).map((view) => view.feature)}
|
|
358
|
+
values={featuresDtoFromDaoList(titleDaos)}
|
|
359
|
+
/>
|
|
360
|
+
</Typography.Text>
|
|
361
|
+
) : null}
|
|
362
|
+
|
|
363
|
+
{badgeFeatures.length > 0 ? (
|
|
364
|
+
<FeatureBadges
|
|
365
|
+
features={badgeFeatures.map((view) => view.feature)}
|
|
366
|
+
values={badgeValues}
|
|
367
|
+
/>
|
|
368
|
+
) : null}
|
|
369
|
+
|
|
370
|
+
{listing.location_label !== undefined &&
|
|
371
|
+
listing.location_label.length > 0 ? (
|
|
372
|
+
<Typography.Text type="secondary" data-testid="listings-card-location">
|
|
373
|
+
{listing.location_label}
|
|
374
|
+
</Typography.Text>
|
|
375
|
+
) : null}
|
|
376
|
+
</Flex>
|
|
377
|
+
</>
|
|
378
|
+
);
|
|
379
|
+
|
|
217
380
|
return (
|
|
218
381
|
<SkinTheme
|
|
219
382
|
surface="bare"
|
|
220
383
|
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
221
384
|
>
|
|
385
|
+
<style href={CARD_TARGET_STYLE_HREF} precedence="default">
|
|
386
|
+
{cardTargetCss()}
|
|
387
|
+
</style>
|
|
222
388
|
<Card
|
|
223
389
|
size="small"
|
|
224
390
|
data-testid="listings-card"
|
|
@@ -226,74 +392,35 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
226
392
|
{...(status !== undefined
|
|
227
393
|
? { "data-listing-status": status.status }
|
|
228
394
|
: {})}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
395
|
+
// The body's own padding is zero because the ANCHOR fills the card:
|
|
396
|
+
// padding outside it would be a strip of card that looks pressable and
|
|
397
|
+
// is not. The text block inside the anchor carries the same padding
|
|
398
|
+
// back, from the same token.
|
|
399
|
+
styles={{ body: { minWidth: 0, padding: 0 } }}
|
|
400
|
+
style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
|
|
236
401
|
>
|
|
237
|
-
<
|
|
238
|
-
{
|
|
239
|
-
|
|
240
|
-
<Typography.Text strong data-testid="listings-card-price">
|
|
241
|
-
<ListingPrice
|
|
242
|
-
amount={listing.price}
|
|
243
|
-
{...(listing.currency !== undefined ? { currency: listing.currency } : {})}
|
|
244
|
-
/>
|
|
245
|
-
</Typography.Text>
|
|
246
|
-
|
|
247
|
-
<Typography.Text ellipsis data-testid="listings-card-title">
|
|
248
|
-
{listing.title ?? ""}
|
|
249
|
-
</Typography.Text>
|
|
402
|
+
<CardTarget {...props} listingId={listing.id} label={targetLabel}>
|
|
403
|
+
{content}
|
|
404
|
+
</CardTarget>
|
|
250
405
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
{badgeFeatures.length > 0 ? (
|
|
264
|
-
<FeatureBadges
|
|
265
|
-
features={badgeFeatures.map((view) => view.feature)}
|
|
266
|
-
values={badgeValues}
|
|
267
|
-
/>
|
|
268
|
-
) : null}
|
|
269
|
-
|
|
270
|
-
{listing.location_label !== undefined &&
|
|
271
|
-
listing.location_label.length > 0 ? (
|
|
272
|
-
<Typography.Text type="secondary" data-testid="listings-card-location">
|
|
273
|
-
{listing.location_label}
|
|
274
|
-
</Typography.Text>
|
|
275
|
-
) : null}
|
|
276
|
-
|
|
277
|
-
{/* The action row is wrapped in ONE `GatedControl`, so the reason a
|
|
278
|
-
blocked heart carries lands full width UNDER both controls
|
|
279
|
-
rather than in the two-centimetre column beside the icon. */}
|
|
280
|
-
{props.showFavorite === false ? (
|
|
281
|
-
<OpenControl {...props} listingId={listing.id} />
|
|
282
|
-
) : (
|
|
406
|
+
{/* The heart, and only the heart, lives OUTSIDE the anchor: a button
|
|
407
|
+
inside a link is neither valid HTML nor operable. Its refusal gets
|
|
408
|
+
a line of its own here, which is the whole reason it is a row under
|
|
409
|
+
the card rather than a glyph floating on the photograph. */}
|
|
410
|
+
{props.showFavorite === false ? null : (
|
|
411
|
+
<div
|
|
412
|
+
style={{
|
|
413
|
+
paddingInline: token.paddingSM,
|
|
414
|
+
paddingBlockEnd: token.paddingSM,
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
283
417
|
<GatedControl
|
|
284
418
|
gate={favorite.gate}
|
|
285
419
|
testId="listings-card-actions"
|
|
286
420
|
style={{ width: "100%" }}
|
|
287
421
|
>
|
|
288
422
|
{(bind) => (
|
|
289
|
-
<Flex
|
|
290
|
-
gap={spacing[2]}
|
|
291
|
-
align="center"
|
|
292
|
-
style={{ width: "100%", minWidth: 0 }}
|
|
293
|
-
>
|
|
294
|
-
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
|
|
295
|
-
<OpenControl {...props} listingId={listing.id} />
|
|
296
|
-
</div>
|
|
423
|
+
<Flex justify="flex-end" style={{ width: "100%" }}>
|
|
297
424
|
<Button
|
|
298
425
|
disabled={bind.disabled}
|
|
299
426
|
data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
|
|
@@ -312,25 +439,23 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
312
439
|
</Flex>
|
|
313
440
|
)}
|
|
314
441
|
</GatedControl>
|
|
315
|
-
)}
|
|
316
442
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
</Flex>
|
|
443
|
+
{/* The door. `GatedControl` above already prints the reason and
|
|
444
|
+
wires `aria-describedby` to it; what it cannot know is WHERE a
|
|
445
|
+
visitor signs in, which is the container's business and arrives
|
|
446
|
+
as `signIn`. On a grid `blockedReason="line"` drops the door and
|
|
447
|
+
keeps the sentence — twenty-four doors to one place is not
|
|
448
|
+
twenty-four pieces of help. */}
|
|
449
|
+
{favoriteGate.reason === undefined || blockedReason === "line" ? null : (
|
|
450
|
+
<Typography.Text
|
|
451
|
+
type="secondary"
|
|
452
|
+
data-testid="listings-card-favorite-blocked"
|
|
453
|
+
>
|
|
454
|
+
<SignInLink cta={props.signIn} testId="listings-card-sign-in" />
|
|
455
|
+
</Typography.Text>
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
)}
|
|
334
459
|
</Card>
|
|
335
460
|
</SkinTheme>
|
|
336
461
|
);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* category `renderCategoryPicker` — the container's `<CategoryPickerField>`,
|
|
9
9
|
* given `setCategory` to call
|
|
10
|
-
* where `locationPicker` — the container's `<
|
|
10
|
+
* where `locationPicker` — the container's `<LocationField>`
|
|
11
11
|
* (`@stapel/geo-react`)
|
|
12
12
|
* currency `renderCurrencyPicker` — whatever vocabulary the deployment sells in
|
|
13
13
|
* details `features` — the schema, drawn by `<FeatureFields>` (L0)
|
|
@@ -143,7 +143,7 @@ export interface ComposerCurrencySlot {
|
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* What a location picker component is handed — the contract
|
|
146
|
-
* `@stapel/geo-react`'s `<
|
|
146
|
+
* `@stapel/geo-react`'s `<LocationField>` was built to fill.
|
|
147
147
|
*
|
|
148
148
|
* `{ value, onChange }`, and `value` is the pin plus the address a resolver
|
|
149
149
|
* found for it. `lat`/`lon` are NUMBERS here (a picker works in numbers) while
|
|
@@ -156,13 +156,15 @@ export interface ComposerCurrencySlot {
|
|
|
156
156
|
* writes is three lines and no state:
|
|
157
157
|
*
|
|
158
158
|
* ```tsx
|
|
159
|
-
* import {
|
|
159
|
+
* import { LocationField } from "@stapel/geo-react/default";
|
|
160
160
|
*
|
|
161
161
|
* <ListingComposerPage
|
|
162
162
|
* locationPicker={({ value, onChange }) => (
|
|
163
|
-
* <
|
|
163
|
+
* <LocationField
|
|
164
164
|
* {...(value.lat !== null && value.lon !== null
|
|
165
|
-
* ? { value: { lat: value.lat, lon: value.lon }
|
|
165
|
+
* ? { value: { point: { lat: value.lat, lon: value.lon },
|
|
166
|
+
* ...(value.address !== undefined
|
|
167
|
+
* ? { address: value.address } : {}) } }
|
|
166
168
|
* : {})}
|
|
167
169
|
* onChange={(picked) => {
|
|
168
170
|
* onChange({ lat: picked.point.lat, lon: picked.point.lon,
|
|
@@ -173,6 +175,17 @@ export interface ComposerCurrencySlot {
|
|
|
173
175
|
* />
|
|
174
176
|
* ```
|
|
175
177
|
*
|
|
178
|
+
* `LocationField` and not `LocationPickerField`, and the difference is the
|
|
179
|
+
* whole reason this slot exists. The older component is a BUTTON — "Choose on
|
|
180
|
+
* the map" — that prints its answer underneath itself, so a form the person
|
|
181
|
+
* has filled in goes on looking empty and the question names the mechanism
|
|
182
|
+
* rather than the thing being asked. `LocationField` is a field: it states
|
|
183
|
+
* the question while empty and holds the chosen place inside itself once it
|
|
184
|
+
* is not, and one tap runs the whole ladder behind it — the permission
|
|
185
|
+
* pre-prompt before the browser's one-shot prompt, the server's IP guess when
|
|
186
|
+
* that is refused, then the map. Either component fits this contract; only
|
|
187
|
+
* one of them looks like an answer to "where is it?".
|
|
188
|
+
*
|
|
176
189
|
* `geohash` is absent from this contract on purpose: since stapel-listings
|
|
177
190
|
* 0.7.1 the server computes it from the coordinates and ignores anything sent
|
|
178
191
|
* in the body, so a picker that has one has nowhere to put it and no reason
|
|
@@ -254,8 +267,9 @@ export interface ListingComposerPageProps extends ThemeModeProp {
|
|
|
254
267
|
readonly renderCurrencyPicker?: (slot: ComposerCurrencySlot) => ReactNode;
|
|
255
268
|
/**
|
|
256
269
|
* WHERE the thing is, asked the host's way — the slot
|
|
257
|
-
* `@stapel/geo-react`'s `<
|
|
258
|
-
* {@link ComposerLocationPickerProps} for the
|
|
270
|
+
* `@stapel/geo-react`'s `<LocationField>` fills (see
|
|
271
|
+
* {@link ComposerLocationPickerProps} for the adapter, and for why the
|
|
272
|
+
* field beats the older button).
|
|
259
273
|
*
|
|
260
274
|
* Unfilled: a named placeholder. The composer will NOT fall back to two
|
|
261
275
|
* decimal boxes — a seller does not know their latitude, and those boxes
|
|
@@ -13,7 +13,10 @@ import type { ListingCard, ListingPageParams } from "../api/types.js";
|
|
|
13
13
|
import { useMyFavorites } from "../model/queries.js";
|
|
14
14
|
import { useFavoriteListing } from "../model/mutations.js";
|
|
15
15
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
LISTINGS_ELEVATION_ACTIONS,
|
|
18
|
+
useElevatableMandateGate,
|
|
19
|
+
} from "./useMandateGate.js";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Saving something for later — first-class in stapel-listings (a `Favorite`
|
|
@@ -48,23 +51,28 @@ export function useFavoriteToggle(
|
|
|
48
51
|
id: number,
|
|
49
52
|
favorited: boolean | null | undefined
|
|
50
53
|
): FavoriteToggleBag {
|
|
51
|
-
const mandate =
|
|
54
|
+
const { gate: mandate, elevation } = useElevatableMandateGate(
|
|
55
|
+
LISTINGS_ELEVATION_ACTIONS.favorite
|
|
56
|
+
);
|
|
52
57
|
const mutation = useFavoriteListing();
|
|
53
58
|
const gate = firstBlock(
|
|
54
59
|
mandate,
|
|
55
|
-
mutation.isPending
|
|
60
|
+
mutation.isPending || elevation.pending
|
|
56
61
|
? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
|
|
57
62
|
: actionAvailable()
|
|
58
63
|
);
|
|
59
64
|
return {
|
|
60
65
|
favorited: favorited === true,
|
|
61
66
|
gate,
|
|
67
|
+
// On a host with auto-anonymous wired, the first heart an anonymous
|
|
68
|
+
// visitor presses mints their account and then saves — one press, no
|
|
69
|
+
// form, nothing said about it. Everywhere else `run` performs directly.
|
|
62
70
|
toggle: () => {
|
|
63
71
|
if (!gate.available) return;
|
|
64
|
-
mutation.mutate({ id, favorited: favorited !== true });
|
|
72
|
+
elevation.run(() => mutation.mutate({ id, favorited: favorited !== true }));
|
|
65
73
|
},
|
|
66
|
-
inFlight: mutation.isPending,
|
|
67
|
-
error: mutation.error,
|
|
74
|
+
inFlight: mutation.isPending || elevation.pending,
|
|
75
|
+
error: mutation.error ?? elevation.error,
|
|
68
76
|
};
|
|
69
77
|
}
|
|
70
78
|
|
|
@@ -92,7 +100,18 @@ export interface UseFavoritesOptions {
|
|
|
92
100
|
/** The favourites page: a real keyset list, unlike the owner's own listings
|
|
93
101
|
* (see `MyListings.tsx` for why those are different). */
|
|
94
102
|
export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
|
|
95
|
-
|
|
103
|
+
// The READ side of elevation, and the one place `identified` is the right
|
|
104
|
+
// question. A guest who saved listings must be able to come back and see
|
|
105
|
+
// them — an account that can save and cannot re-read is worse than the
|
|
106
|
+
// refusal it replaced — but a visitor who has never elevated has nothing
|
|
107
|
+
// here, so this page must not mint just to render. `covers` alone would
|
|
108
|
+
// open it for them and buy a 401.
|
|
109
|
+
const { gate: mandateGate, elevation } = useElevatableMandateGate(
|
|
110
|
+
LISTINGS_ELEVATION_ACTIONS.favorite
|
|
111
|
+
);
|
|
112
|
+
const gate = elevation.covers && !elevation.identified
|
|
113
|
+
? actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn)
|
|
114
|
+
: mandateGate;
|
|
96
115
|
const [page, setPage] = useState<ListingPageParams>(
|
|
97
116
|
options.limit !== undefined ? { limit: options.limit } : {}
|
|
98
117
|
);
|
|
@@ -21,7 +21,10 @@ import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from ".
|
|
|
21
21
|
import { listingStatusView } from "../model/status.js";
|
|
22
22
|
import type { ListingStatusView } from "../model/status.js";
|
|
23
23
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
LISTINGS_ELEVATION_ACTIONS,
|
|
26
|
+
useElevatableMandateGate,
|
|
27
|
+
} from "./useMandateGate.js";
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* `images` is `string[] | NULL` on the wire, and the null is the SERVER
|
|
@@ -110,7 +113,9 @@ export function useListingDetail(
|
|
|
110
113
|
const detail = useListing(id);
|
|
111
114
|
const probe = useListingStatus(id);
|
|
112
115
|
const favorite = useFavoriteListing();
|
|
113
|
-
const mandate =
|
|
116
|
+
const { gate: mandate, elevation } = useElevatableMandateGate(
|
|
117
|
+
LISTINGS_ELEVATION_ACTIONS.favorite
|
|
118
|
+
);
|
|
114
119
|
|
|
115
120
|
const state: LoadState<ListingDetailData> =
|
|
116
121
|
detail.status === "error"
|
|
@@ -178,7 +183,7 @@ export function useListingDetail(
|
|
|
178
183
|
|
|
179
184
|
const favoriteGate = firstBlock(
|
|
180
185
|
mandate,
|
|
181
|
-
favorite.isPending
|
|
186
|
+
favorite.isPending || elevation.pending
|
|
182
187
|
? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
|
|
183
188
|
: actionAvailable(),
|
|
184
189
|
detail.data === undefined
|
|
@@ -206,9 +211,11 @@ export function useListingDetail(
|
|
|
206
211
|
favoriteGate,
|
|
207
212
|
toggleFavorite: () => {
|
|
208
213
|
if (!favoriteGate.available) return;
|
|
209
|
-
|
|
214
|
+
// Mints the anonymous account first where the host permits it for this
|
|
215
|
+
// action; a direct call everywhere else.
|
|
216
|
+
elevation.run(() => favorite.mutate({ id, favorited: isFavorited !== true }));
|
|
210
217
|
},
|
|
211
|
-
favoriteInFlight: favorite.isPending,
|
|
218
|
+
favoriteInFlight: favorite.isPending || elevation.pending,
|
|
212
219
|
refetch: () => {
|
|
213
220
|
void detail.refetch();
|
|
214
221
|
void probe.refetch();
|