@stapel/listings-react 0.4.0 → 0.8.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.
- package/CHANGELOG.md +180 -0
- package/dist/api/generated/schema.d.ts +94 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FavoritesPane.d.ts +40 -3
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +30 -18
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +81 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +97 -27
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +163 -23
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +85 -43
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +41 -4
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +38 -25
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +13 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +47 -9
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -0
- package/dist/default/ListingPrice.d.ts.map +1 -0
- package/dist/default/ListingPrice.js +60 -0
- package/dist/default/ListingPrice.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +7 -28
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +136 -67
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/SignInLink.js +8 -7
- package/dist/default/SignInLink.js.map +1 -1
- package/dist/default/StatusTags.d.ts +16 -5
- package/dist/default/StatusTags.d.ts.map +1 -1
- package/dist/default/StatusTags.js +19 -13
- package/dist/default/StatusTags.js.map +1 -1
- package/dist/default/index.d.ts +15 -9
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -6
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Favorites.d.ts +6 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +23 -7
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +12 -0
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +3 -0
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +23 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +33 -7
- package/dist/headless/ListingComposer.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 +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +23 -5
- 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 +30 -3
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +47 -7
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +16 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +35 -5
- 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/dist/model/draft.d.ts +9 -6
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +3 -1
- package/dist/model/draft.js.map +1 -1
- package/dist/model/features.d.ts +21 -7
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +26 -0
- package/dist/model/features.js.map +1 -1
- package/llms.txt +9 -8
- package/manifest.json +82 -31
- package/nav-manifest.json +1 -1
- package/package.json +19 -14
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +94 -4
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +283 -116
- package/src/default/ListingComposerPage.tsx +403 -187
- package/src/default/ListingDetailPane.tsx +200 -98
- package/src/default/ListingPhoto.tsx +89 -16
- package/src/default/ListingPrice.tsx +77 -0
- package/src/default/MyListingsPane.tsx +286 -228
- package/src/default/SignInLink.tsx +4 -4
- package/src/default/StatusTags.tsx +36 -18
- package/src/default/index.ts +18 -7
- package/src/headless/Favorites.tsx +36 -7
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/ListingDetail.tsx +12 -5
- package/src/headless/useMandateGate.ts +49 -5
- package/src/i18n/es.ts +29 -5
- 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 +54 -7
- package/src/i18n/ru.ts +41 -5
- package/src/index.ts +5 -1
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -25
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -40
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -67
|
@@ -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
|
|
@@ -22,19 +61,24 @@
|
|
|
22
61
|
* card. That property is the whole reason the projection exists, and it is
|
|
23
62
|
* what makes a grid of forty cards cost one query.
|
|
24
63
|
*
|
|
25
|
-
* ── The heart is never hidden
|
|
64
|
+
* ── The heart is never hidden, and never explained by hover ────────────────
|
|
26
65
|
*
|
|
27
66
|
* A visitor sees it, blocked, with the reason IN WORDS beside it and the
|
|
28
67
|
* sign-in link the container supplies (`signIn`, typically `?next=<current>`).
|
|
29
68
|
* Hiding it would teach nobody that favourites exist (private-space canon
|
|
30
|
-
* §6.3, spec §6.2 item 6)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
69
|
+
* §6.3, spec §6.2 item 6). The reason itself goes through the shared
|
|
70
|
+
* `<GatedControl>`, which renders it as text linked by `aria-describedby` —
|
|
71
|
+
* a disabled antd button receives no pointer events and is not focusable, so
|
|
72
|
+
* the `Tooltip` this component used to offer as a "quieter" volume was a
|
|
73
|
+
* reason nobody could read on any device. That arm is gone; `blockedReason`
|
|
74
|
+
* is now "text" (reason + door) or "line" (reason alone, for a grid).
|
|
33
75
|
*/
|
|
34
|
-
import type { ReactElement, ReactNode } from "react";
|
|
35
|
-
import { Button, Card, Flex,
|
|
76
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
77
|
+
import { Button, Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
78
|
+
import { GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
|
|
36
79
|
import { useActionGate, useT } from "@stapel/core";
|
|
37
80
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
81
|
+
import { spacing } from "@stapel/tokens";
|
|
38
82
|
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
39
83
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
40
84
|
import { asFeatureDaoList, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
@@ -44,7 +88,7 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
44
88
|
import { HeartIcon } from "./icons.js";
|
|
45
89
|
import { SignInLink } from "./SignInLink.js";
|
|
46
90
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
47
|
-
import {
|
|
91
|
+
import { ListingPrice } from "./ListingPrice.js";
|
|
48
92
|
import type { ThemeModeProp } from "./types.js";
|
|
49
93
|
|
|
50
94
|
/**
|
|
@@ -87,8 +131,74 @@ export type ListingCardOpenProps =
|
|
|
87
131
|
readonly linkComponent?: undefined;
|
|
88
132
|
};
|
|
89
133
|
|
|
134
|
+
/**
|
|
135
|
+
* How loudly a blocked favourite states its reason on THIS surface.
|
|
136
|
+
*
|
|
137
|
+
* `"text"` (the default) is the sentence under the controls, with the sign-in
|
|
138
|
+
* door beside it — the right volume for a card that stands alone.
|
|
139
|
+
*
|
|
140
|
+
* `"line"` collapses it to the reason alone, no door: a grid of forty cards
|
|
141
|
+
* repeating "sign in to do this — sign in" forty times is not forty pieces of
|
|
142
|
+
* help, it is the loudest thing on the page, and every one of those doors
|
|
143
|
+
* leads where the header's own sign-in button already leads.
|
|
144
|
+
*
|
|
145
|
+
* There is no third setting. The reason is on screen in both, because the only
|
|
146
|
+
* way to make it quieter than a line of text is to hide it behind hover, and
|
|
147
|
+
* hover does not exist on the device most of these cards are read on.
|
|
148
|
+
*/
|
|
149
|
+
export type ListingCardBlockedReason = "text" | "line";
|
|
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
|
+
|
|
90
198
|
export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
|
|
91
199
|
readonly listing: ListingCardData;
|
|
200
|
+
/** See {@link ListingCardBlockedReason}. Default `"text"`. */
|
|
201
|
+
readonly blockedReason?: ListingCardBlockedReason;
|
|
92
202
|
/** Extra chrome the container adds (a `promoted` tag from search, say —
|
|
93
203
|
* DSA Art. 26 marking belongs to the pair that receives it). */
|
|
94
204
|
readonly badge?: ReactNode;
|
|
@@ -100,55 +210,66 @@ export interface ListingCardBaseProps extends ThemeModeProp, SignInCtaProp {
|
|
|
100
210
|
export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
|
|
101
211
|
|
|
102
212
|
/**
|
|
103
|
-
* The
|
|
213
|
+
* The card's own body, wrapped in whatever makes it openable: an anchor, a
|
|
214
|
+
* button, or nothing at all.
|
|
104
215
|
*
|
|
105
216
|
* Exactly one of the three renders, so exactly one navigation happens per
|
|
106
|
-
* click
|
|
107
|
-
*
|
|
217
|
+
* click — the branch below has no arm in which both a handler and an `href`
|
|
218
|
+
* reach the DOM.
|
|
219
|
+
*
|
|
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.
|
|
108
224
|
*/
|
|
109
|
-
function
|
|
110
|
-
props: ListingCardOpenProps & {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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;
|
|
114
233
|
|
|
115
234
|
if (props.href !== undefined) {
|
|
116
235
|
const Link = props.linkComponent;
|
|
117
236
|
// The host's component is rendered as it comes: this pair has no CSS and
|
|
118
|
-
// no way to hand
|
|
119
|
-
//
|
|
120
|
-
// that wants the antd look styles its own `<Link>` — it is one component,
|
|
121
|
-
// written once, and it is already the thing that knows the design system.
|
|
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.
|
|
122
239
|
return Link !== undefined ? (
|
|
123
240
|
<Link
|
|
124
241
|
href={props.href}
|
|
125
242
|
aria-label={label}
|
|
243
|
+
className={CARD_TARGET_CLASS}
|
|
126
244
|
data-testid="listings-card-open"
|
|
127
245
|
data-analytics="none"
|
|
128
246
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
129
247
|
>
|
|
130
|
-
{
|
|
248
|
+
{children}
|
|
131
249
|
</Link>
|
|
132
250
|
) : (
|
|
133
|
-
<
|
|
134
|
-
size="small"
|
|
135
|
-
type="link"
|
|
251
|
+
<a
|
|
136
252
|
href={props.href}
|
|
253
|
+
aria-label={label}
|
|
254
|
+
className={CARD_TARGET_CLASS}
|
|
255
|
+
style={TARGET_STYLE}
|
|
137
256
|
data-testid="listings-card-open"
|
|
138
257
|
data-analytics="none"
|
|
139
258
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
140
259
|
>
|
|
141
|
-
{
|
|
142
|
-
</
|
|
260
|
+
{children}
|
|
261
|
+
</a>
|
|
143
262
|
);
|
|
144
263
|
}
|
|
145
264
|
|
|
146
265
|
if (props.onOpen !== undefined) {
|
|
147
266
|
const onOpen = props.onOpen;
|
|
148
267
|
return (
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
aria-label={label}
|
|
271
|
+
className={CARD_TARGET_CLASS}
|
|
272
|
+
style={BUTTON_TARGET_STYLE}
|
|
152
273
|
data-testid="listings-card-open"
|
|
153
274
|
data-analytics="none"
|
|
154
275
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
@@ -156,18 +277,20 @@ function OpenControl(
|
|
|
156
277
|
onOpen(props.listingId);
|
|
157
278
|
}}
|
|
158
279
|
>
|
|
159
|
-
{
|
|
160
|
-
</
|
|
280
|
+
{children}
|
|
281
|
+
</button>
|
|
161
282
|
);
|
|
162
283
|
}
|
|
163
284
|
|
|
164
|
-
|
|
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>;
|
|
165
287
|
}
|
|
166
288
|
|
|
167
289
|
export function ListingCard(props: ListingCardProps): ReactElement {
|
|
168
290
|
const t = useT();
|
|
169
291
|
const { listing } = props;
|
|
170
292
|
const favorite = useFavoriteToggle(listing.id, listing.is_favorited);
|
|
293
|
+
const { token } = antdTheme.useToken();
|
|
171
294
|
|
|
172
295
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
173
296
|
const badgeFeatures = featuresFromDaoList(badgeDaos);
|
|
@@ -175,12 +298,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
175
298
|
const titleDaos = asFeatureDaoList(listing.features_title);
|
|
176
299
|
|
|
177
300
|
const status = listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
178
|
-
const price =
|
|
179
|
-
listing.price !== undefined && listing.price.length > 0
|
|
180
|
-
? `${listing.price} ${listing.currency ?? ""}`.trim()
|
|
181
|
-
: t(LISTINGS_I18N_KEYS.cardPriceAbsent);
|
|
182
301
|
|
|
183
302
|
const favoriteGate = useActionGate(favorite.gate);
|
|
303
|
+
const blockedReason = props.blockedReason ?? "text";
|
|
184
304
|
|
|
185
305
|
const favoriteLabel = t(
|
|
186
306
|
favorite.favorited
|
|
@@ -188,8 +308,83 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
188
308
|
: LISTINGS_I18N_KEYS.cardFavoriteAdd
|
|
189
309
|
);
|
|
190
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
|
+
|
|
191
380
|
return (
|
|
192
|
-
<
|
|
381
|
+
<SkinTheme
|
|
382
|
+
surface="bare"
|
|
383
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
384
|
+
>
|
|
385
|
+
<style href={CARD_TARGET_STYLE_HREF} precedence="default">
|
|
386
|
+
{cardTargetCss()}
|
|
387
|
+
</style>
|
|
193
388
|
<Card
|
|
194
389
|
size="small"
|
|
195
390
|
data-testid="listings-card"
|
|
@@ -197,69 +392,41 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
197
392
|
{...(status !== undefined
|
|
198
393
|
? { "data-listing-status": status.status }
|
|
199
394
|
: {})}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
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 }}
|
|
207
401
|
>
|
|
208
|
-
<
|
|
209
|
-
{
|
|
402
|
+
<CardTarget {...props} listingId={listing.id} label={targetLabel}>
|
|
403
|
+
{content}
|
|
404
|
+
</CardTarget>
|
|
210
405
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
) : null}
|
|
230
|
-
|
|
231
|
-
{badgeFeatures.length > 0 ? (
|
|
232
|
-
<FeatureBadges
|
|
233
|
-
features={badgeFeatures.map((view) => view.feature)}
|
|
234
|
-
values={badgeValues}
|
|
235
|
-
/>
|
|
236
|
-
) : null}
|
|
237
|
-
|
|
238
|
-
{listing.location_label !== undefined &&
|
|
239
|
-
listing.location_label.length > 0 ? (
|
|
240
|
-
<Typography.Text type="secondary" data-testid="listings-card-location">
|
|
241
|
-
{listing.location_label}
|
|
242
|
-
</Typography.Text>
|
|
243
|
-
) : null}
|
|
244
|
-
|
|
245
|
-
<Flex gap={8} align="center">
|
|
246
|
-
<OpenControl {...props} listingId={listing.id} />
|
|
247
|
-
|
|
248
|
-
{props.showFavorite === false ? null : (
|
|
249
|
-
<Tooltip
|
|
250
|
-
title={
|
|
251
|
-
favorite.gate.available
|
|
252
|
-
? favoriteLabel
|
|
253
|
-
: t(favorite.gate.block.code, favorite.gate.block.params)
|
|
254
|
-
}
|
|
255
|
-
>
|
|
256
|
-
{/* A disabled antd Button swallows pointer events, so the
|
|
257
|
-
tooltip needs the wrapper to hear them — which is also
|
|
258
|
-
what makes the REASON reachable by keyboard. */}
|
|
259
|
-
<span data-testid="listings-card-favorite-wrap">
|
|
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
|
+
>
|
|
417
|
+
<GatedControl
|
|
418
|
+
gate={favorite.gate}
|
|
419
|
+
testId="listings-card-actions"
|
|
420
|
+
style={{ width: "100%" }}
|
|
421
|
+
>
|
|
422
|
+
{(bind) => (
|
|
423
|
+
<Flex justify="flex-end" style={{ width: "100%" }}>
|
|
260
424
|
<Button
|
|
261
|
-
|
|
262
|
-
disabled=
|
|
425
|
+
disabled={bind.disabled}
|
|
426
|
+
data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
|
|
427
|
+
{...(bind["aria-describedby"] !== undefined
|
|
428
|
+
? { "aria-describedby": bind["aria-describedby"] }
|
|
429
|
+
: {})}
|
|
263
430
|
aria-label={favoriteLabel}
|
|
264
431
|
aria-pressed={favorite.favorited}
|
|
265
432
|
data-testid="listings-card-favorite"
|
|
@@ -269,27 +436,27 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
269
436
|
onClick={favorite.toggle}
|
|
270
437
|
icon={<HeartIcon filled={favorite.favorited} />}
|
|
271
438
|
/>
|
|
272
|
-
</
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
</Flex>
|
|
439
|
+
</Flex>
|
|
440
|
+
)}
|
|
441
|
+
</GatedControl>
|
|
276
442
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
)}
|
|
292
459
|
</Card>
|
|
293
|
-
</
|
|
460
|
+
</SkinTheme>
|
|
294
461
|
);
|
|
295
462
|
}
|