@stapel/listings-react 0.9.0 → 0.10.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 +76 -0
- package/dist/api/generated/schema.d.ts +34 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FeedGrid.d.ts +42 -0
- package/dist/default/FeedGrid.d.ts.map +1 -0
- package/dist/default/FeedGrid.js +23 -0
- package/dist/default/FeedGrid.js.map +1 -0
- package/dist/default/ListingCard.d.ts +31 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +12 -4
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts +77 -0
- package/dist/default/ListingFeedCard.d.ts.map +1 -0
- package/dist/default/ListingFeedCard.js +75 -0
- package/dist/default/ListingFeedCard.js.map +1 -0
- package/dist/default/ListingSerpCard.d.ts +114 -0
- package/dist/default/ListingSerpCard.d.ts.map +1 -0
- package/dist/default/ListingSerpCard.js +72 -0
- package/dist/default/ListingSerpCard.js.map +1 -0
- package/dist/default/favorite.d.ts +49 -0
- package/dist/default/favorite.d.ts.map +1 -0
- package/dist/default/favorite.js +25 -0
- package/dist/default/favorite.js.map +1 -0
- package/dist/default/icons.d.ts +17 -3
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +13 -1
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +17 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +14 -1
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +11 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +15 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +3 -1
- package/manifest.json +45 -1
- package/nav-manifest.json +1 -1
- package/package.json +9 -9
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +34 -2
- package/src/default/FeedGrid.tsx +70 -0
- package/src/default/ListingCard.tsx +21 -5
- package/src/default/ListingFeedCard.tsx +232 -0
- package/src/default/ListingSerpCard.tsx +374 -0
- package/src/default/favorite.tsx +91 -0
- package/src/default/icons.tsx +37 -3
- package/src/default/index.ts +24 -1
- package/src/i18n/es.ts +4 -0
- package/src/i18n/keys.ts +15 -0
- package/src/i18n/ru.ts +4 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ListingFeedCard>` — the borderless card of a phone home feed (refs §1).
|
|
3
|
+
*
|
|
4
|
+
* ── What "borderless" is actually doing ───────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `<ListingCard>` and `<ListingSerpCard>` are antd `Card`s: a painted surface
|
|
7
|
+
* with a border, which is what makes one result read as one object next to
|
|
8
|
+
* another. A two-column feed is a different picture. Twenty bordered boxes on
|
|
9
|
+
* a 390px screen is twenty frames and forty vertical lines, and the photos —
|
|
10
|
+
* the only thing anyone is actually looking at — end up as small pictures
|
|
11
|
+
* inside chrome. So this card has no surface of its own: the photo is the
|
|
12
|
+
* card, the three lines under it sit on the page's own ground, and the RHYTHM
|
|
13
|
+
* of the grid is what separates one from the next. That is the ref, and it is
|
|
14
|
+
* also why this is a third component rather than a `bordered={false}` on the
|
|
15
|
+
* first: nothing else about the layout survives the change either.
|
|
16
|
+
*
|
|
17
|
+
* ── The heart is over the photo here, and only here ───────────────────────
|
|
18
|
+
*
|
|
19
|
+
* `<ListingCard>` argues at length that the heart belongs in a row UNDER the
|
|
20
|
+
* card rather than floating on the photograph, because a blocked favourite
|
|
21
|
+
* states its reason as text and there is nowhere to put a sentence on top of a
|
|
22
|
+
* picture. That argument is correct and this card does not repeat its
|
|
23
|
+
* conclusion, for a reason it states rather than hides: a 2-column feed tile
|
|
24
|
+
* has no line to spare. A full row of "Sign in to save this" under every one
|
|
25
|
+
* of twenty tiles is not twenty pieces of help — it is the feed.
|
|
26
|
+
*
|
|
27
|
+
* The fleet already has the mechanism for exactly this and it is not "hide the
|
|
28
|
+
* reason": `GateReasonScopeContext` / `<PaneGate>` pool identical reasons and
|
|
29
|
+
* render each ONCE for everything inside the scope, with every control's
|
|
30
|
+
* `aria-describedby` still pointing at that single copy. **A container drawing
|
|
31
|
+
* a feed should wrap `<FeedGrid>` in a `<PaneGate>`.** Unscoped, the reason
|
|
32
|
+
* still renders — over the photo, visible, never behind a hover — because a
|
|
33
|
+
* reason a person cannot read is the one outcome the doctrine forbids.
|
|
34
|
+
*
|
|
35
|
+
* ── Two lines of title, and then it stops ─────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* A feed tile is roughly 170px wide. A three-line title pushes the price below
|
|
38
|
+
* the fold of the row and makes the grid ragged; a one-line ellipsis throws
|
|
39
|
+
* away the half of a listing's name that distinguishes it from the one beside
|
|
40
|
+
* it. Two lines, clamped, is the ref's answer and the only one that keeps the
|
|
41
|
+
* price on the same y as its neighbour's.
|
|
42
|
+
*/
|
|
43
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
44
|
+
import { Flex, Typography } from "antd";
|
|
45
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
46
|
+
import { useT } from "@stapel/core";
|
|
47
|
+
import { radii, spacing } from "@stapel/tokens";
|
|
48
|
+
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
49
|
+
import { lifecycleCaption } from "../model/status.js";
|
|
50
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
51
|
+
import { FavoriteHeart } from "./favorite.js";
|
|
52
|
+
import {
|
|
53
|
+
CARD_TARGET_STYLE_HREF,
|
|
54
|
+
CardTarget,
|
|
55
|
+
cardTargetCss,
|
|
56
|
+
} from "./ListingCard.js";
|
|
57
|
+
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
58
|
+
import { ListingPhoto } from "./ListingPhoto.js";
|
|
59
|
+
import { ListingPrice } from "./ListingPrice.js";
|
|
60
|
+
import type { ThemeModeProp } from "./types.js";
|
|
61
|
+
|
|
62
|
+
/** Lines of title a tile draws before it clips. See the file header. */
|
|
63
|
+
const TITLE_LINES = 2;
|
|
64
|
+
|
|
65
|
+
/** The class the clamped title carries, for {@link feedCardCss}. */
|
|
66
|
+
export const FEED_TITLE_CLASS = "stapel-listing-feed-title";
|
|
67
|
+
|
|
68
|
+
/** The `href` the hoisted feed stylesheet is deduplicated by. */
|
|
69
|
+
export const FEED_CARD_STYLE_HREF = "stapel-listings-feed-card";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The clamp, as a real CSS rule rather than an inline style.
|
|
73
|
+
*
|
|
74
|
+
* `-webkit-line-clamp` and `-webkit-box-orient` are the two declarations that
|
|
75
|
+
* do not survive the trip through an inline style object: React's serializer
|
|
76
|
+
* and every DOM implementation that is not a browser drop them silently, so a
|
|
77
|
+
* tile written that way clamps in Chrome, does not clamp in a test, and
|
|
78
|
+
* nothing anywhere says which. A hoisted sheet keeps one copy for the document
|
|
79
|
+
* and makes the rule something a test can read.
|
|
80
|
+
*/
|
|
81
|
+
export function feedCardCss(): string {
|
|
82
|
+
return (
|
|
83
|
+
`.${FEED_TITLE_CLASS}{display:-webkit-box;-webkit-box-orient:vertical;` +
|
|
84
|
+
`-webkit-line-clamp:${String(TITLE_LINES)};overflow:hidden}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The tile. `position: relative` is what the heart and the badge overlay are
|
|
89
|
+
* pinned to; `minWidth: 0` keeps a long word inside its grid track. */
|
|
90
|
+
const TILE: CSSProperties = { position: "relative", minWidth: 0 };
|
|
91
|
+
|
|
92
|
+
/** Pinned to the photo's leading corner: the container's own marking. */
|
|
93
|
+
const BADGE: CSSProperties = {
|
|
94
|
+
position: "absolute",
|
|
95
|
+
insetBlockStart: spacing[2],
|
|
96
|
+
insetInlineStart: spacing[2],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Pinned to the photo's trailing corner. `alignItems: flex-end` so the
|
|
100
|
+
* pooled-or-not reason, when there is one, stacks under the heart against the
|
|
101
|
+
* same edge rather than pushing it inwards. */
|
|
102
|
+
const HEART: CSSProperties = {
|
|
103
|
+
position: "absolute",
|
|
104
|
+
insetBlockStart: spacing[2],
|
|
105
|
+
insetInlineEnd: spacing[2],
|
|
106
|
+
alignItems: "flex-end",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export interface ListingFeedCardBaseProps extends ThemeModeProp {
|
|
110
|
+
readonly listing: ListingCardData;
|
|
111
|
+
/**
|
|
112
|
+
* Drawn over the photo's leading corner — "New", "In stock", a promotion
|
|
113
|
+
* marking. A SLOT rather than a string: what earns an overlay on a feed is a
|
|
114
|
+
* deployment's decision, and DSA Art. 26 marking belongs to whichever pair
|
|
115
|
+
* received the fact.
|
|
116
|
+
*/
|
|
117
|
+
readonly badgeOverlay?: ReactNode;
|
|
118
|
+
/** Hide the favourite entirely — for a surface where it makes no sense.
|
|
119
|
+
* NOT a way to hide it from visitors. */
|
|
120
|
+
readonly showFavorite?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ListingFeedCardProps = ListingFeedCardBaseProps & ListingCardOpenProps;
|
|
124
|
+
|
|
125
|
+
export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
|
|
126
|
+
const t = useT();
|
|
127
|
+
const { listing } = props;
|
|
128
|
+
const status =
|
|
129
|
+
listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
130
|
+
const title = listing.title ?? "";
|
|
131
|
+
const targetLabel =
|
|
132
|
+
title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<SkinTheme
|
|
136
|
+
surface="bare"
|
|
137
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
138
|
+
>
|
|
139
|
+
<style href={CARD_TARGET_STYLE_HREF} precedence="default">
|
|
140
|
+
{cardTargetCss()}
|
|
141
|
+
</style>
|
|
142
|
+
<style href={FEED_CARD_STYLE_HREF} precedence="default">
|
|
143
|
+
{feedCardCss()}
|
|
144
|
+
</style>
|
|
145
|
+
<div
|
|
146
|
+
style={TILE}
|
|
147
|
+
data-testid="listings-feed-card"
|
|
148
|
+
data-listing-id={listing.id}
|
|
149
|
+
{...(status !== undefined
|
|
150
|
+
? { "data-listing-status": status.status }
|
|
151
|
+
: {})}
|
|
152
|
+
>
|
|
153
|
+
{/* One anchor over the whole tile. The photo is a still `<img>`, so
|
|
154
|
+
unlike the SERP card's swipeable strip it is safe inside a link —
|
|
155
|
+
and on a feed the picture IS the click. */}
|
|
156
|
+
<CardTarget
|
|
157
|
+
{...openProps(props)}
|
|
158
|
+
listingId={listing.id}
|
|
159
|
+
label={targetLabel}
|
|
160
|
+
testId="listings-feed-open"
|
|
161
|
+
bodyTestId="listings-feed-body"
|
|
162
|
+
>
|
|
163
|
+
<Flex vertical gap={spacing[1]}>
|
|
164
|
+
<ListingPhoto
|
|
165
|
+
imageRef={listing.images?.[0]}
|
|
166
|
+
alt={title.length > 0 ? title : String(listing.id)}
|
|
167
|
+
style={{ borderRadius: radii.lg }}
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
{/* Title before price: on a feed a person is browsing, not
|
|
171
|
+
comparing — the ref's order, and the reverse of the SERP's. */}
|
|
172
|
+
<Typography.Text
|
|
173
|
+
className={FEED_TITLE_CLASS}
|
|
174
|
+
data-testid="listings-feed-title"
|
|
175
|
+
>
|
|
176
|
+
{title}
|
|
177
|
+
</Typography.Text>
|
|
178
|
+
|
|
179
|
+
<Typography.Text strong data-testid="listings-feed-price">
|
|
180
|
+
<ListingPrice
|
|
181
|
+
amount={listing.price}
|
|
182
|
+
{...(listing.currency !== undefined
|
|
183
|
+
? { currency: listing.currency }
|
|
184
|
+
: {})}
|
|
185
|
+
/>
|
|
186
|
+
</Typography.Text>
|
|
187
|
+
|
|
188
|
+
{listing.location_label !== undefined &&
|
|
189
|
+
listing.location_label.length > 0 ? (
|
|
190
|
+
<Typography.Text
|
|
191
|
+
type="secondary"
|
|
192
|
+
ellipsis
|
|
193
|
+
data-testid="listings-feed-location"
|
|
194
|
+
>
|
|
195
|
+
{listing.location_label}
|
|
196
|
+
</Typography.Text>
|
|
197
|
+
) : null}
|
|
198
|
+
</Flex>
|
|
199
|
+
</CardTarget>
|
|
200
|
+
|
|
201
|
+
{props.badgeOverlay !== undefined && (
|
|
202
|
+
<div style={BADGE} data-testid="listings-feed-badge">
|
|
203
|
+
{props.badgeOverlay}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Outside the anchor — a button inside a link is neither valid HTML
|
|
208
|
+
nor operable — and pinned rather than stacked. See the header. */}
|
|
209
|
+
{props.showFavorite === false ? null : (
|
|
210
|
+
<FavoriteHeart
|
|
211
|
+
listingId={listing.id}
|
|
212
|
+
favorited={listing.is_favorited}
|
|
213
|
+
testId="listings-feed-favorite"
|
|
214
|
+
style={HEART}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</SkinTheme>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The three-armed open union, narrowed out of this card's own props — see
|
|
223
|
+
* `<ListingSerpCard>`'s copy for why it is picked rather than spread. */
|
|
224
|
+
function openProps(props: ListingFeedCardProps): ListingCardOpenProps {
|
|
225
|
+
if (props.href !== undefined) {
|
|
226
|
+
return props.linkComponent !== undefined
|
|
227
|
+
? { href: props.href, linkComponent: props.linkComponent }
|
|
228
|
+
: { href: props.href };
|
|
229
|
+
}
|
|
230
|
+
if (props.onOpen !== undefined) return { onOpen: props.onOpen };
|
|
231
|
+
return {};
|
|
232
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ListingSerpCard>` — the one-column result card of a phone SERP.
|
|
3
|
+
*
|
|
4
|
+
* `<ListingCard>` is the GRID card: a photo, a price, a title and a place,
|
|
5
|
+
* sized so twenty-four of them tile a desktop catalogue. This is the other
|
|
6
|
+
* shape a classified needs and the one the mobile refs are drawn to (refs §3):
|
|
7
|
+
* one card per row, the full width of the screen, a swipeable photo strip, and
|
|
8
|
+
* a column of actions down the right-hand side. They are two components rather
|
|
9
|
+
* than a `variant` prop for the reason the fleet keeps writing down — a mode
|
|
10
|
+
* switch produces one component nobody can photograph either arm of, and here
|
|
11
|
+
* the two arms differ in reading ORDER, not only in size.
|
|
12
|
+
*
|
|
13
|
+
* ── The price is first, and it is the biggest thing on the card ────────────
|
|
14
|
+
*
|
|
15
|
+
* On a grid card the photo carries the layout. On a one-column result the
|
|
16
|
+
* photo is already full-bleed above the text, so the first LINE is where the
|
|
17
|
+
* eye lands — and on a classified that line is the price. Title at regular
|
|
18
|
+
* weight underneath it, the seller's own spec line muted under that. This is
|
|
19
|
+
* the ref's order and it is also the order the fleet's own storefront review
|
|
20
|
+
* asked for: a person scanning a SERP is comparing prices, not reading names.
|
|
21
|
+
*
|
|
22
|
+
* ── Why the photo strip is OUTSIDE the anchor ─────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* `<ListingCard>` puts its photo inside the card's anchor, because a still
|
|
25
|
+
* `<img>` inside a link is just a bigger link. A `<SkinCarousel>` is not: it
|
|
26
|
+
* is a scroll container with its own tab stop, and a horizontal swipe that
|
|
27
|
+
* ends inside an `<a>` is a swipe the browser may deliver as a click. Putting
|
|
28
|
+
* the strip in the anchor would mean every attempt to look at photo two
|
|
29
|
+
* navigated to the listing — the defect that makes phone galleries unusable.
|
|
30
|
+
*
|
|
31
|
+
* So the strip sits above the anchor as a sibling, exactly as the heart sits
|
|
32
|
+
* below it as a sibling, and for the same class of reason: a link may not
|
|
33
|
+
* contain a control, and a swipeable strip is a control. The anchor still
|
|
34
|
+
* covers the four things that identify the listing — price, title, specs,
|
|
35
|
+
* badges — so the card opens from everything a person reads.
|
|
36
|
+
*
|
|
37
|
+
* ── The two slots, and what they are honestly for ─────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* `sellerSlot` is the seller's name and rating. It is a SLOT because a rating
|
|
40
|
+
* aggregate belongs to `@stapel/reviews-react` and this pair does not import
|
|
41
|
+
* another L2 pair; the container is the seam. **The caveat a host must know:**
|
|
42
|
+
* `<RatingBadge>` FETCHES — one request per card. On a page of twenty results
|
|
43
|
+
* that is twenty requests for a decoration. A container drawing this in a list
|
|
44
|
+
* should render the name plus a bare `<Rate>` from an aggregate the row
|
|
45
|
+
* already carries, and render nothing at all when the row carries none. It
|
|
46
|
+
* lives outside the anchor because a seller's name is usually a link to the
|
|
47
|
+
* seller, and a link inside a link is neither valid nor operable.
|
|
48
|
+
*
|
|
49
|
+
* `actionsRail` is the vertical column at the trailing edge — "call", "write"
|
|
50
|
+
* — filled by the container from `@stapel/chat-react` and from whatever the
|
|
51
|
+
* deployment uses for a phone number. The pair supplies the COLUMN and the
|
|
52
|
+
* favourite heart at the end of it, and nothing else: this package has no
|
|
53
|
+
* conversation and no telephone number, and the search projection carries no
|
|
54
|
+
* phone (gap G-2's neighbour, recorded in the wave spec §4).
|
|
55
|
+
*
|
|
56
|
+
* ── The price trend is a seam over data that does not exist yet ───────────
|
|
57
|
+
*
|
|
58
|
+
* `priceTrend` renders the ref's "was 1 800 000 ₽ ↓". The search projection
|
|
59
|
+
* carries no price history — no `price_was`, no direction — so nothing on a
|
|
60
|
+
* live SERP fills this today (wave gap **G-2**). It ships anyway, and the
|
|
61
|
+
* demos show it against fixture data, because the alternative is a card that
|
|
62
|
+
* has to be re-laid-out the day the projection grows the field. The seam is
|
|
63
|
+
* the honest half; the missing data is written down rather than faked.
|
|
64
|
+
*/
|
|
65
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
66
|
+
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
67
|
+
import { SkinCarousel, SkinTheme } from "@stapel/tokens-antd/skin";
|
|
68
|
+
import { useT } from "@stapel/core";
|
|
69
|
+
import { fontSize, spacing } from "@stapel/tokens";
|
|
70
|
+
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
71
|
+
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
72
|
+
import {
|
|
73
|
+
asFeatureDaoList,
|
|
74
|
+
featuresDtoFromDaoList,
|
|
75
|
+
featuresFromDaoList,
|
|
76
|
+
} from "../model/features.js";
|
|
77
|
+
import { lifecycleCaption } from "../model/status.js";
|
|
78
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
79
|
+
import { PriceTrendIcon } from "./icons.js";
|
|
80
|
+
import { FavoriteHeart } from "./favorite.js";
|
|
81
|
+
import {
|
|
82
|
+
CARD_TARGET_STYLE_HREF,
|
|
83
|
+
CardTarget,
|
|
84
|
+
cardTargetCss,
|
|
85
|
+
} from "./ListingCard.js";
|
|
86
|
+
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
87
|
+
import { LISTING_PHOTO_ASPECT, ListingPhoto } from "./ListingPhoto.js";
|
|
88
|
+
import { ListingPrice } from "./ListingPrice.js";
|
|
89
|
+
import type { ThemeModeProp } from "./types.js";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A price that moved, as the ref draws it: the old figure struck through and
|
|
93
|
+
* an arrow saying which way.
|
|
94
|
+
*
|
|
95
|
+
* `oldPrice` is a decimal STRING in the listing's own currency — the same
|
|
96
|
+
* dialect `Listing.price` speaks — so it is formatted by the same
|
|
97
|
+
* `<ListingPrice>` and never by a template literal.
|
|
98
|
+
*/
|
|
99
|
+
export interface ListingPriceTrend {
|
|
100
|
+
/** The previous asking price, as the wire spells money: a decimal string. */
|
|
101
|
+
readonly oldPrice: string;
|
|
102
|
+
/** Which way it moved. `"down"` is the one a classified shouts about. */
|
|
103
|
+
readonly direction: "down" | "up";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ListingSerpCardBaseProps extends ThemeModeProp {
|
|
107
|
+
readonly listing: ListingCardData;
|
|
108
|
+
/**
|
|
109
|
+
* The seller's own one-line summary — "Petrol 1.5 (147 hp), robot, front".
|
|
110
|
+
*
|
|
111
|
+
* A STRING the host derived, not a projection this card reads: the ref's
|
|
112
|
+
* spec line is a deployment's editorial choice about which features belong
|
|
113
|
+
* on a result and in what order, and that is a decision no library can take
|
|
114
|
+
* for a category it has never seen. When the host has no opinion the card
|
|
115
|
+
* falls back to the row's `features_title` — the projection's own summary is
|
|
116
|
+
* a better default line than an empty one (§83: the default skin does the
|
|
117
|
+
* right thing before the host wires anything).
|
|
118
|
+
*/
|
|
119
|
+
readonly specsLine?: string;
|
|
120
|
+
/** See the file header — a seam over data the projection does not carry. */
|
|
121
|
+
readonly priceTrend?: ListingPriceTrend;
|
|
122
|
+
/** The seller's name and rating. See the file header for the per-card fetch
|
|
123
|
+
* caveat a container has to avoid. */
|
|
124
|
+
readonly sellerSlot?: ReactNode;
|
|
125
|
+
/** The vertical action column at the trailing edge — call, write. The
|
|
126
|
+
* favourite heart is added at its end by this component. */
|
|
127
|
+
readonly actionsRail?: ReactNode;
|
|
128
|
+
/** Extra chrome above the price (a `promoted` tag from search — DSA Art. 26
|
|
129
|
+
* marking belongs to the pair that receives it). */
|
|
130
|
+
readonly badge?: ReactNode;
|
|
131
|
+
/** Hide the favourite entirely — for a surface where it makes no sense (the
|
|
132
|
+
* owner's own listings). NOT a way to hide it from visitors. */
|
|
133
|
+
readonly showFavorite?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type ListingSerpCardProps = ListingSerpCardBaseProps & ListingCardOpenProps;
|
|
137
|
+
|
|
138
|
+
/** The trailing column. Fixed width contribution: it must not take room from
|
|
139
|
+
* the text as the actions grow. */
|
|
140
|
+
const RAIL: CSSProperties = { flex: "0 0 auto" };
|
|
141
|
+
|
|
142
|
+
/** The reading column. `minWidth: 0` so a long unbroken word cannot push the
|
|
143
|
+
* rail off the card. */
|
|
144
|
+
const BODY: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
|
|
145
|
+
|
|
146
|
+
export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
147
|
+
const t = useT();
|
|
148
|
+
const { listing, priceTrend } = props;
|
|
149
|
+
const { token } = antdTheme.useToken();
|
|
150
|
+
|
|
151
|
+
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
152
|
+
const badgeFeatures = featuresFromDaoList(badgeDaos);
|
|
153
|
+
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
154
|
+
// The fallback spec line when the host derived none: the row's own
|
|
155
|
+
// `features_title` projection, exactly the line `ListingCard` draws.
|
|
156
|
+
const titleDaos =
|
|
157
|
+
props.specsLine !== undefined && props.specsLine.length > 0
|
|
158
|
+
? []
|
|
159
|
+
: asFeatureDaoList(listing.features_title);
|
|
160
|
+
|
|
161
|
+
const status =
|
|
162
|
+
listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
163
|
+
|
|
164
|
+
const title = listing.title ?? "";
|
|
165
|
+
const targetLabel =
|
|
166
|
+
title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
|
|
167
|
+
const photos = listing.images ?? [];
|
|
168
|
+
const currency =
|
|
169
|
+
listing.currency !== undefined ? { currency: listing.currency } : {};
|
|
170
|
+
|
|
171
|
+
// A one-photo strip gets neither a peek nor dots: the sliver of a next slide
|
|
172
|
+
// is an affordance for something that is there, and on a single photo it is
|
|
173
|
+
// just a strip of dead space at the trailing edge (`SkinCarousel` says the
|
|
174
|
+
// same thing from its own side).
|
|
175
|
+
const many = photos.length > 1;
|
|
176
|
+
|
|
177
|
+
const rail =
|
|
178
|
+
props.actionsRail !== undefined || props.showFavorite !== false ? (
|
|
179
|
+
<Flex
|
|
180
|
+
vertical
|
|
181
|
+
align="flex-end"
|
|
182
|
+
gap={spacing[2]}
|
|
183
|
+
style={RAIL}
|
|
184
|
+
data-testid="listings-serp-actions"
|
|
185
|
+
>
|
|
186
|
+
{props.actionsRail}
|
|
187
|
+
{props.showFavorite === false ? null : (
|
|
188
|
+
<FavoriteHeart
|
|
189
|
+
listingId={listing.id}
|
|
190
|
+
favorited={listing.is_favorited}
|
|
191
|
+
testId="listings-serp-favorite"
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
</Flex>
|
|
195
|
+
) : null;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<SkinTheme
|
|
199
|
+
surface="bare"
|
|
200
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
201
|
+
>
|
|
202
|
+
<style href={CARD_TARGET_STYLE_HREF} precedence="default">
|
|
203
|
+
{cardTargetCss()}
|
|
204
|
+
</style>
|
|
205
|
+
<Card
|
|
206
|
+
size="small"
|
|
207
|
+
data-testid="listings-serp-card"
|
|
208
|
+
data-listing-id={listing.id}
|
|
209
|
+
{...(status !== undefined
|
|
210
|
+
? { "data-listing-status": status.status }
|
|
211
|
+
: {})}
|
|
212
|
+
styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
|
|
213
|
+
style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
|
|
214
|
+
>
|
|
215
|
+
<Flex vertical gap={spacing[3]}>
|
|
216
|
+
{/* The strip is a SIBLING of the anchor, never a child — see the
|
|
217
|
+
file header. A listing with no photos still gets one slide, so
|
|
218
|
+
the card's height does not depend on whether a seller uploaded
|
|
219
|
+
anything. */}
|
|
220
|
+
<SkinCarousel
|
|
221
|
+
label={t(LISTINGS_I18N_KEYS.cardPhotos)}
|
|
222
|
+
aspectRatio={LISTING_PHOTO_ASPECT}
|
|
223
|
+
peek={many}
|
|
224
|
+
dots={many}
|
|
225
|
+
data-testid="listings-serp-photos"
|
|
226
|
+
>
|
|
227
|
+
{photos.length === 0 ? (
|
|
228
|
+
<ListingPhoto
|
|
229
|
+
imageRef={undefined}
|
|
230
|
+
alt={title.length > 0 ? title : String(listing.id)}
|
|
231
|
+
/>
|
|
232
|
+
) : (
|
|
233
|
+
photos.map((reference, index) => (
|
|
234
|
+
<ListingPhoto
|
|
235
|
+
key={reference}
|
|
236
|
+
imageRef={reference}
|
|
237
|
+
alt={t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
238
|
+
index: index + 1,
|
|
239
|
+
total: photos.length,
|
|
240
|
+
})}
|
|
241
|
+
/>
|
|
242
|
+
))
|
|
243
|
+
)}
|
|
244
|
+
</SkinCarousel>
|
|
245
|
+
|
|
246
|
+
<Flex gap={spacing[3]} align="flex-start">
|
|
247
|
+
<Flex vertical gap={spacing[1]} style={BODY}>
|
|
248
|
+
<CardTarget
|
|
249
|
+
{...openProps(props)}
|
|
250
|
+
listingId={listing.id}
|
|
251
|
+
label={targetLabel}
|
|
252
|
+
testId="listings-serp-open"
|
|
253
|
+
bodyTestId="listings-serp-body"
|
|
254
|
+
>
|
|
255
|
+
{props.badge}
|
|
256
|
+
|
|
257
|
+
{/* PRICE FIRST, and loud. `fontSize.xl` rather than an antd
|
|
258
|
+
heading: this is a price, not a section title, and it must
|
|
259
|
+
not enter the document outline of a page holding twenty of
|
|
260
|
+
them. */}
|
|
261
|
+
<Flex align="center" gap={spacing[2]} wrap>
|
|
262
|
+
<Typography.Text
|
|
263
|
+
strong
|
|
264
|
+
style={{ fontSize: fontSize.xl.fontSize }}
|
|
265
|
+
data-testid="listings-serp-price"
|
|
266
|
+
>
|
|
267
|
+
<ListingPrice amount={listing.price} {...currency} />
|
|
268
|
+
</Typography.Text>
|
|
269
|
+
{priceTrend !== undefined && (
|
|
270
|
+
<PriceTrendIcon
|
|
271
|
+
direction={priceTrend.direction}
|
|
272
|
+
label={t(
|
|
273
|
+
priceTrend.direction === "down"
|
|
274
|
+
? LISTINGS_I18N_KEYS.cardPriceDropped
|
|
275
|
+
: LISTINGS_I18N_KEYS.cardPriceRaised
|
|
276
|
+
)}
|
|
277
|
+
/>
|
|
278
|
+
)}
|
|
279
|
+
</Flex>
|
|
280
|
+
|
|
281
|
+
{priceTrend !== undefined && (
|
|
282
|
+
<Flex
|
|
283
|
+
align="baseline"
|
|
284
|
+
gap={spacing[1]}
|
|
285
|
+
data-testid="listings-serp-old-price"
|
|
286
|
+
>
|
|
287
|
+
{/* The strike-through is what a sighted reader sees and
|
|
288
|
+
nothing a screen reader announces, so the word is on
|
|
289
|
+
the line too rather than left to the styling. */}
|
|
290
|
+
<Typography.Text type="secondary">
|
|
291
|
+
{t(LISTINGS_I18N_KEYS.cardPriceWas)}
|
|
292
|
+
</Typography.Text>
|
|
293
|
+
<Typography.Text type="secondary" delete>
|
|
294
|
+
<ListingPrice amount={priceTrend.oldPrice} {...currency} />
|
|
295
|
+
</Typography.Text>
|
|
296
|
+
</Flex>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
<Typography.Text data-testid="listings-serp-title">
|
|
300
|
+
{title}
|
|
301
|
+
</Typography.Text>
|
|
302
|
+
|
|
303
|
+
{props.specsLine !== undefined && props.specsLine.length > 0 ? (
|
|
304
|
+
<Typography.Text
|
|
305
|
+
type="secondary"
|
|
306
|
+
ellipsis
|
|
307
|
+
data-testid="listings-serp-specs"
|
|
308
|
+
>
|
|
309
|
+
{props.specsLine}
|
|
310
|
+
</Typography.Text>
|
|
311
|
+
) : titleDaos.length > 0 ? (
|
|
312
|
+
<Typography.Text
|
|
313
|
+
type="secondary"
|
|
314
|
+
ellipsis
|
|
315
|
+
data-testid="listings-serp-specs"
|
|
316
|
+
>
|
|
317
|
+
<FeatureBadges
|
|
318
|
+
features={featuresFromDaoList(titleDaos).map(
|
|
319
|
+
(view) => view.feature,
|
|
320
|
+
)}
|
|
321
|
+
values={featuresDtoFromDaoList(titleDaos)}
|
|
322
|
+
/>
|
|
323
|
+
</Typography.Text>
|
|
324
|
+
) : null}
|
|
325
|
+
|
|
326
|
+
{badgeFeatures.length > 0 ? (
|
|
327
|
+
<FeatureBadges
|
|
328
|
+
features={badgeFeatures.map((view) => view.feature)}
|
|
329
|
+
values={badgeValues}
|
|
330
|
+
/>
|
|
331
|
+
) : null}
|
|
332
|
+
</CardTarget>
|
|
333
|
+
|
|
334
|
+
{/* Outside the anchor, both of them: a seller line usually holds
|
|
335
|
+
a link to the seller, and the place is the last thing read
|
|
336
|
+
rather than part of what the card is called. */}
|
|
337
|
+
{props.sellerSlot ?? null}
|
|
338
|
+
|
|
339
|
+
{listing.location_label !== undefined &&
|
|
340
|
+
listing.location_label.length > 0 ? (
|
|
341
|
+
<Typography.Text
|
|
342
|
+
type="secondary"
|
|
343
|
+
data-testid="listings-serp-location"
|
|
344
|
+
>
|
|
345
|
+
{listing.location_label}
|
|
346
|
+
</Typography.Text>
|
|
347
|
+
) : null}
|
|
348
|
+
</Flex>
|
|
349
|
+
|
|
350
|
+
{rail}
|
|
351
|
+
</Flex>
|
|
352
|
+
</Flex>
|
|
353
|
+
</Card>
|
|
354
|
+
</SkinTheme>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The three-armed open union, narrowed back out of this card's own props.
|
|
360
|
+
*
|
|
361
|
+
* Spreading `props` straight into `<CardTarget>` would hand it `listing`,
|
|
362
|
+
* `sellerSlot` and the rest as DOM attributes on the arms that render a plain
|
|
363
|
+
* `<a>`; picking the union's members explicitly keeps the anchor clean and
|
|
364
|
+
* keeps exactly one of the three arms reachable.
|
|
365
|
+
*/
|
|
366
|
+
function openProps(props: ListingSerpCardProps): ListingCardOpenProps {
|
|
367
|
+
if (props.href !== undefined) {
|
|
368
|
+
return props.linkComponent !== undefined
|
|
369
|
+
? { href: props.href, linkComponent: props.linkComponent }
|
|
370
|
+
: { href: props.href };
|
|
371
|
+
}
|
|
372
|
+
if (props.onOpen !== undefined) return { onOpen: props.onOpen };
|
|
373
|
+
return {};
|
|
374
|
+
}
|