@stapel/listings-react 0.12.0 → 0.14.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 +83 -0
- package/dist/default/ListingCard.d.ts +59 -3
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +79 -17
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +108 -9
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +76 -151
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +30 -0
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +33 -0
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts +11 -0
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +7 -15
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/index.d.ts +3 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -3
- package/dist/default/index.js.map +1 -1
- package/llms.txt +4 -3
- package/manifest.json +20 -1
- package/nav-manifest.json +1 -1
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/default/ListingCard.tsx +209 -110
- package/src/default/ListingComposerPage.tsx +73 -54
- package/src/default/ListingPhoto.tsx +64 -0
- package/src/default/ListingSerpCard.tsx +130 -135
- package/src/default/index.ts +13 -3
|
@@ -50,21 +50,44 @@
|
|
|
50
50
|
* do not import each other; the container is the seam. A library that picked
|
|
51
51
|
* one would pick it for every host.
|
|
52
52
|
*
|
|
53
|
-
* ──
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
53
|
+
* ── The SECTION ORDER is this component's, and it is not a prop ────────────
|
|
54
|
+
*
|
|
55
|
+
* One order, at every width:
|
|
56
|
+
*
|
|
57
|
+
* category → title → description → price → currency → where → photos →
|
|
58
|
+
* the category's characteristics → the listing's own options
|
|
59
|
+
*
|
|
60
|
+
* The category comes first because everything after it depends on the choice.
|
|
61
|
+
* Then the five questions a seller came to answer — what it is, what it says,
|
|
62
|
+
* what it costs, where it is, what it looks like — and only then the questions
|
|
63
|
+
* the CATEGORY asks.
|
|
64
|
+
*
|
|
65
|
+
* This used to be two orders chosen by the form's width, and the narrow one
|
|
66
|
+
* put the characteristics directly under the category. The reason was real:
|
|
67
|
+
* measured on a live classified deployment, Mobile phones put the first
|
|
68
|
+
* attribute control at y=1596, two viewports below the fold, while the footer
|
|
69
|
+
* said "10 required details not filled in" with none of them on screen. The
|
|
70
|
+
* fix was not: the same leaf carries 32 imported fields, so on the next
|
|
71
|
+
* measurement of the same form the seller was asked for the parcel's weight,
|
|
72
|
+
* its length and "what the goods are measured in" BEFORE the title — title
|
|
73
|
+
* y=5575, price y=5871, photos y=6245, of a 7308px form. A composer that asks
|
|
74
|
+
* about postage before it asks what is being sold has no funnel left.
|
|
75
|
+
*
|
|
76
|
+
* So the discoverability problem is solved where it actually lives, and by two
|
|
77
|
+
* things that do not move the questions around:
|
|
78
|
+
*
|
|
79
|
+
* - the count of unfilled details is a CONTROL that goes to the first of them
|
|
80
|
+
* (`listings-composer-goto-missing`, below), so "10 required details" is
|
|
81
|
+
* never a dead end wherever the region sits;
|
|
82
|
+
* - the region itself is short, because `groupCollapse="auto"` opens the
|
|
83
|
+
* groups that ask something required and leaves the plumbing (delivery
|
|
84
|
+
* dimensions, wholesale terms) closed under its own heading.
|
|
85
|
+
*
|
|
86
|
+
* `detailsPlacement` is therefore gone as a decision — `data-placement` stays
|
|
87
|
+
* on the region as a constant, because an e2e that measured the regression
|
|
88
|
+
* needs something to read that is not a pixel count. A host that wants a
|
|
89
|
+
* different order does not want this page; it wants `<FeatureFields>` and its
|
|
90
|
+
* own form, which is exactly why that component is exported separately.
|
|
68
91
|
*
|
|
69
92
|
* ── Every blocked control says which of six reasons it is ──────────────────
|
|
70
93
|
*
|
|
@@ -76,7 +99,6 @@
|
|
|
76
99
|
* would be told, and `<GatedButton>` renders the reason beside the button —
|
|
77
100
|
* never a grey rectangle, never a hover.
|
|
78
101
|
*/
|
|
79
|
-
import { useRef } from "react";
|
|
80
102
|
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
81
103
|
import {
|
|
82
104
|
Alert,
|
|
@@ -94,7 +116,6 @@ import {
|
|
|
94
116
|
GatedButton,
|
|
95
117
|
PaneGate,
|
|
96
118
|
SkinTheme,
|
|
97
|
-
useElementWidth,
|
|
98
119
|
} from "@stapel/tokens-antd/skin";
|
|
99
120
|
import {
|
|
100
121
|
SlotPlaceholder,
|
|
@@ -104,7 +125,7 @@ import {
|
|
|
104
125
|
useI18n,
|
|
105
126
|
useT,
|
|
106
127
|
} from "@stapel/core";
|
|
107
|
-
import {
|
|
128
|
+
import { spacing } from "@stapel/tokens";
|
|
108
129
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
109
130
|
import {
|
|
110
131
|
BUILTIN_VALUE_EDITOR_TYPES,
|
|
@@ -135,16 +156,14 @@ import type { ThemeModeProp } from "./types.js";
|
|
|
135
156
|
export const COMPOSER_MEASURE = "44rem";
|
|
136
157
|
|
|
137
158
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* the
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* A composer drawn in a 360px settings pane on a 1440px desktop is narrow; a
|
|
145
|
-
* viewport query would call it wide and bury its questions.
|
|
159
|
+
* What `data-placement` says on the characteristics region.
|
|
160
|
+
*
|
|
161
|
+
* A constant, and deliberately so: the order is the composer's and does not
|
|
162
|
+
* depend on the width any more (see the header). The attribute stays because
|
|
163
|
+
* an e2e suite measured the regression through it and needs a reading that is
|
|
164
|
+
* not a pixel count.
|
|
146
165
|
*/
|
|
147
|
-
export const
|
|
166
|
+
export const COMPOSER_DETAILS_PLACEMENT = "after-core-fields";
|
|
148
167
|
|
|
149
168
|
/**
|
|
150
169
|
* The DOM id of the control that answers one of the composer's own fields —
|
|
@@ -167,11 +186,18 @@ const FOCUSABLE =
|
|
|
167
186
|
"input,select,textarea,button,[href],[tabindex]:not([tabindex='-1'])";
|
|
168
187
|
|
|
169
188
|
/**
|
|
170
|
-
* Put the person in front of one field:
|
|
171
|
-
* they are meant to answer.
|
|
189
|
+
* Put the person in front of one field: open whatever it is folded inside,
|
|
190
|
+
* bring it into view, and focus what they are meant to answer.
|
|
191
|
+
*
|
|
192
|
+
* The disclosure step is the one that is easy to forget. A characteristic
|
|
193
|
+
* lives in a `<details>` section that may be closed (`groupCollapse="auto"`),
|
|
194
|
+
* and scrolling to a control inside a closed disclosure scrolls to nothing —
|
|
195
|
+
* the "take me to the first empty field" button would report success and move
|
|
196
|
+
* the page nowhere. Any depth of nesting is opened, because the answer to
|
|
197
|
+
* "where is it" must not depend on how the section was drawn.
|
|
172
198
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
199
|
+
* The rest is guarded rather than assumed. `scrollIntoView` does not exist in
|
|
200
|
+
* every environment this renders in (jsdom, older embedded engines), and a
|
|
175
201
|
* field whose control came from a slot may have nothing focusable in it at
|
|
176
202
|
* all — in which case scrolling to it is still the whole of the help that can
|
|
177
203
|
* honestly be given.
|
|
@@ -180,6 +206,13 @@ function revealField(id: string): void {
|
|
|
180
206
|
if (typeof document === "undefined") return;
|
|
181
207
|
const anchor = document.getElementById(id);
|
|
182
208
|
if (anchor === null) return;
|
|
209
|
+
for (
|
|
210
|
+
let folded = anchor.closest("details");
|
|
211
|
+
folded !== null;
|
|
212
|
+
folded = folded.parentElement?.closest("details") ?? null
|
|
213
|
+
) {
|
|
214
|
+
folded.open = true;
|
|
215
|
+
}
|
|
183
216
|
if (typeof anchor.scrollIntoView === "function") {
|
|
184
217
|
anchor.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
185
218
|
}
|
|
@@ -483,15 +516,6 @@ export function ListingComposerPage(
|
|
|
483
516
|
);
|
|
484
517
|
|
|
485
518
|
const LocationPicker = props.locationPicker;
|
|
486
|
-
// The FORM's width, which is what decides the section order — see the
|
|
487
|
-
// header. `?? false` because an unmeasured box is not a narrow one (the rule
|
|
488
|
-
// `useElementWidth` states for every caller): the wide order is the one that
|
|
489
|
-
// reflows gracefully, so it is what the first frame draws.
|
|
490
|
-
const form = useRef<HTMLDivElement>(null);
|
|
491
|
-
const { below } = useElementWidth(form, {
|
|
492
|
-
thresholds: { stacked: COMPOSER_STACKED_BELOW },
|
|
493
|
-
});
|
|
494
|
-
const stacked = below.stacked ?? false;
|
|
495
519
|
|
|
496
520
|
/**
|
|
497
521
|
* The characteristics of the chosen category — built once and rendered in
|
|
@@ -506,7 +530,7 @@ export function ListingComposerPage(
|
|
|
506
530
|
const details = (
|
|
507
531
|
<div
|
|
508
532
|
data-testid="listings-composer-details"
|
|
509
|
-
data-placement={
|
|
533
|
+
data-placement={COMPOSER_DETAILS_PLACEMENT}
|
|
510
534
|
>
|
|
511
535
|
<Divider />
|
|
512
536
|
<Typography.Title level={5}>
|
|
@@ -539,6 +563,11 @@ export function ListingComposerPage(
|
|
|
539
563
|
errors={bag.fieldErrors}
|
|
540
564
|
disabled={bag.publishing}
|
|
541
565
|
onChange={bag.setFeature}
|
|
566
|
+
// An imported leaf is mostly plumbing: 32 fields across seven
|
|
567
|
+
// headings, four of them parcel dimensions. Open what is required or
|
|
568
|
+
// already answered, leave the rest one tap away under its own
|
|
569
|
+
// heading — see `FeatureGroupCollapse`.
|
|
570
|
+
groupCollapse="auto"
|
|
542
571
|
/>
|
|
543
572
|
)}
|
|
544
573
|
</div>
|
|
@@ -603,11 +632,6 @@ export function ListingComposerPage(
|
|
|
603
632
|
/>
|
|
604
633
|
) : null}
|
|
605
634
|
|
|
606
|
-
{/* The measured box is the FORM, so a host that renders this page in
|
|
607
|
-
a narrow pane gets the narrow order without the viewport agreeing.
|
|
608
|
-
A plain block wrapper: antd's `Form` forwards its ref to the form
|
|
609
|
-
INSTANCE, not to a node. */}
|
|
610
|
-
<div ref={form}>
|
|
611
635
|
<Form layout="vertical" data-testid="listings-composer-form">
|
|
612
636
|
<SlotField
|
|
613
637
|
label={t(LISTINGS_I18N_KEYS.composeCategory)}
|
|
@@ -634,10 +658,6 @@ export function ListingComposerPage(
|
|
|
634
658
|
/>
|
|
635
659
|
) : null}
|
|
636
660
|
|
|
637
|
-
{/* Narrow: the category's own questions, directly under the choice
|
|
638
|
-
that produced them and above the photo dropzone. */}
|
|
639
|
-
{stacked ? details : null}
|
|
640
|
-
|
|
641
661
|
<Form.Item
|
|
642
662
|
label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
|
|
643
663
|
{...errorOf(TITLE_FIELD)}
|
|
@@ -788,9 +808,9 @@ export function ListingComposerPage(
|
|
|
788
808
|
</>
|
|
789
809
|
) : null}
|
|
790
810
|
|
|
791
|
-
{/*
|
|
792
|
-
|
|
793
|
-
{
|
|
811
|
+
{/* The category's own questions, after the five the seller came to
|
|
812
|
+
answer. See the header on why this is one order and not two. */}
|
|
813
|
+
{details}
|
|
794
814
|
|
|
795
815
|
<Divider />
|
|
796
816
|
|
|
@@ -832,7 +852,6 @@ export function ListingComposerPage(
|
|
|
832
852
|
</Checkbox>
|
|
833
853
|
</Form.Item>
|
|
834
854
|
</Form>
|
|
835
|
-
</div>
|
|
836
855
|
|
|
837
856
|
{bag.refusal?.kind === "invalid_draft" ? (
|
|
838
857
|
<ErrorAlert
|
|
@@ -25,6 +25,7 @@ import type { CSSProperties, ReactElement } from "react";
|
|
|
25
25
|
import { useMemo } from "react";
|
|
26
26
|
import { Typography, theme as antdTheme } from "antd";
|
|
27
27
|
import { Image } from "@stapel/image";
|
|
28
|
+
import { SkinCarousel } from "@stapel/tokens-antd/skin";
|
|
28
29
|
import { useT } from "@stapel/core";
|
|
29
30
|
import { radii, spacing } from "@stapel/tokens";
|
|
30
31
|
import { useListingsRuntime } from "../model/context.js";
|
|
@@ -133,3 +134,66 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
|
|
|
133
134
|
/>
|
|
134
135
|
);
|
|
135
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A listing's photos as ONE swipeable strip — the shape every card surface in
|
|
140
|
+
* this pair uses, so there is one gallery and not three.
|
|
141
|
+
*
|
|
142
|
+
* ── Why the strip is never inside a card's anchor ──────────────────────────
|
|
143
|
+
*
|
|
144
|
+
* A `<SkinCarousel>` is a scroll container with its own tab stop, and a
|
|
145
|
+
* horizontal swipe that ends inside an `<a>` is a swipe the browser may
|
|
146
|
+
* deliver as a click: every attempt to look at photo two would open the
|
|
147
|
+
* listing. A link may not contain a control, and a swipeable strip is a
|
|
148
|
+
* control. So a card renders this as a SIBLING of its anchor and keeps the
|
|
149
|
+
* anchor around everything a person READS — which is the arrangement
|
|
150
|
+
* `<ListingSerpCard>` has shipped since it existed, and the one the live phone
|
|
151
|
+
* SERP was measured correct on while the desktop card (a still `<img>` inside
|
|
152
|
+
* the anchor, one photo, no dots) was not.
|
|
153
|
+
*
|
|
154
|
+
* A listing with no photos still gets one slide, so a row's height does not
|
|
155
|
+
* depend on whether a seller uploaded anything, and a ONE-photo strip gets
|
|
156
|
+
* neither peek nor dots: the sliver of a next slide is an affordance for
|
|
157
|
+
* something that is there.
|
|
158
|
+
*/
|
|
159
|
+
export function ListingPhotoStrip(props: {
|
|
160
|
+
/** The stored references, in the seller's order. */
|
|
161
|
+
readonly images: readonly string[];
|
|
162
|
+
/** The listing's title — the fallback alt for a single photo. */
|
|
163
|
+
readonly title: string;
|
|
164
|
+
/** The surface's own test id, so a screen holding two kinds of card does
|
|
165
|
+
* not hand a test two elements under one name. */
|
|
166
|
+
readonly testId: string;
|
|
167
|
+
}): ReactElement {
|
|
168
|
+
const t = useT();
|
|
169
|
+
const { images, title } = props;
|
|
170
|
+
const many = images.length > 1;
|
|
171
|
+
return (
|
|
172
|
+
<SkinCarousel
|
|
173
|
+
label={t(LISTINGS_I18N_KEYS.cardPhotos)}
|
|
174
|
+
aspectRatio={LISTING_PHOTO_ASPECT}
|
|
175
|
+
peek={many}
|
|
176
|
+
dots={many}
|
|
177
|
+
data-testid={props.testId}
|
|
178
|
+
>
|
|
179
|
+
{images.length === 0 ? (
|
|
180
|
+
<ListingPhoto imageRef={undefined} alt={title} />
|
|
181
|
+
) : (
|
|
182
|
+
images.map((reference, index) => (
|
|
183
|
+
<ListingPhoto
|
|
184
|
+
key={reference}
|
|
185
|
+
imageRef={reference}
|
|
186
|
+
alt={
|
|
187
|
+
many
|
|
188
|
+
? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
189
|
+
index: index + 1,
|
|
190
|
+
total: images.length,
|
|
191
|
+
})
|
|
192
|
+
: title
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
))
|
|
196
|
+
)}
|
|
197
|
+
</SkinCarousel>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -34,6 +34,17 @@
|
|
|
34
34
|
* covers the four things that identify the listing — price, title, specs,
|
|
35
35
|
* badges — so the card opens from everything a person reads.
|
|
36
36
|
*
|
|
37
|
+
* ── One column on a phone, one ROW on a desktop ───────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* The card is still one per row; what changes with width is where the photo
|
|
40
|
+
* sits. Full-bleed above the text is right at 390px and wrong at 974px, where
|
|
41
|
+
* it makes the picture the whole screen and the offer a footnote — measured on
|
|
42
|
+
* the live desktop SERP as one card per scroll. Above
|
|
43
|
+
* {@link LISTING_CARD_ROW_MIN} the strip moves BESIDE the text at a fixed
|
|
44
|
+
* width, from the card's own `@container` query rather than a viewport
|
|
45
|
+
* breakpoint: this card knows how wide it is and has no business knowing how
|
|
46
|
+
* wide the window is.
|
|
47
|
+
*
|
|
37
48
|
* ── The two slots, and what they are honestly for ─────────────────────────
|
|
38
49
|
*
|
|
39
50
|
* `sellerSlot` is the seller's name and rating. It is a SLOT because a rating
|
|
@@ -64,7 +75,7 @@
|
|
|
64
75
|
*/
|
|
65
76
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
66
77
|
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
67
|
-
import {
|
|
78
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
68
79
|
import { useT } from "@stapel/core";
|
|
69
80
|
import { fontSize, spacing } from "@stapel/tokens";
|
|
70
81
|
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
@@ -80,12 +91,16 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
80
91
|
import { PriceTrendIcon } from "./icons.js";
|
|
81
92
|
import { FavoriteHeart } from "./favorite.js";
|
|
82
93
|
import {
|
|
94
|
+
CARD_FRAME_CLASS,
|
|
95
|
+
CARD_MAIN_CLASS,
|
|
96
|
+
CARD_MEDIA_CLASS,
|
|
97
|
+
CARD_QUERY_CLASS,
|
|
83
98
|
CARD_TARGET_STYLE_HREF,
|
|
84
99
|
CardTarget,
|
|
85
100
|
cardTargetCss,
|
|
86
101
|
} from "./ListingCard.js";
|
|
87
102
|
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
88
|
-
import {
|
|
103
|
+
import { ListingPhotoStrip } from "./ListingPhoto.js";
|
|
89
104
|
import { ListingPrice } from "./ListingPrice.js";
|
|
90
105
|
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
91
106
|
|
|
@@ -177,12 +192,6 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
177
192
|
const currency =
|
|
178
193
|
listing.currency !== undefined ? { currency: listing.currency } : {};
|
|
179
194
|
|
|
180
|
-
// A one-photo strip gets neither a peek nor dots: the sliver of a next slide
|
|
181
|
-
// is an affordance for something that is there, and on a single photo it is
|
|
182
|
-
// just a strip of dead space at the trailing edge (`SkinCarousel` says the
|
|
183
|
-
// same thing from its own side).
|
|
184
|
-
const many = photos.length > 1;
|
|
185
|
-
|
|
186
195
|
const rail =
|
|
187
196
|
props.actionsRail !== undefined || props.showFavorite !== false ? (
|
|
188
197
|
<Flex
|
|
@@ -221,144 +230,130 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
221
230
|
styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
|
|
222
231
|
style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
|
|
223
232
|
>
|
|
224
|
-
<
|
|
225
|
-
{
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
>
|
|
236
|
-
{photos.length === 0 ? (
|
|
237
|
-
<ListingPhoto
|
|
238
|
-
imageRef={undefined}
|
|
239
|
-
alt={title.length > 0 ? title : String(listing.id)}
|
|
233
|
+
<div className={CARD_QUERY_CLASS}>
|
|
234
|
+
<div className={CARD_FRAME_CLASS} style={{ gap: spacing[3] }}>
|
|
235
|
+
{/* The strip is a SIBLING of the anchor, never a child — see the
|
|
236
|
+
file header. A listing with no photos still gets one slide, so
|
|
237
|
+
the card's height does not depend on whether a seller uploaded
|
|
238
|
+
anything. */}
|
|
239
|
+
<div className={CARD_MEDIA_CLASS}>
|
|
240
|
+
<ListingPhotoStrip
|
|
241
|
+
images={photos}
|
|
242
|
+
title={title.length > 0 ? title : String(listing.id)}
|
|
243
|
+
testId="listings-serp-photos"
|
|
240
244
|
/>
|
|
241
|
-
|
|
242
|
-
photos.map((reference, index) => (
|
|
243
|
-
<ListingPhoto
|
|
244
|
-
key={reference}
|
|
245
|
-
imageRef={reference}
|
|
246
|
-
alt={t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
247
|
-
index: index + 1,
|
|
248
|
-
total: photos.length,
|
|
249
|
-
})}
|
|
250
|
-
/>
|
|
251
|
-
))
|
|
252
|
-
)}
|
|
253
|
-
</SkinCarousel>
|
|
254
|
-
|
|
255
|
-
<Flex gap={spacing[3]} align="flex-start">
|
|
256
|
-
<Flex vertical gap={spacing[1]} style={BODY}>
|
|
257
|
-
<CardTarget
|
|
258
|
-
{...openProps(props)}
|
|
259
|
-
listingId={listing.id}
|
|
260
|
-
label={targetLabel}
|
|
261
|
-
testId="listings-serp-open"
|
|
262
|
-
bodyTestId="listings-serp-body"
|
|
263
|
-
>
|
|
264
|
-
{props.badge}
|
|
245
|
+
</div>
|
|
265
246
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
247
|
+
<div className={CARD_MAIN_CLASS}>
|
|
248
|
+
<Flex gap={spacing[3]} align="flex-start">
|
|
249
|
+
<Flex vertical gap={spacing[1]} style={BODY}>
|
|
250
|
+
<CardTarget
|
|
251
|
+
{...openProps(props)}
|
|
252
|
+
listingId={listing.id}
|
|
253
|
+
label={targetLabel}
|
|
254
|
+
testId="listings-serp-open"
|
|
255
|
+
bodyTestId="listings-serp-body"
|
|
275
256
|
>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
257
|
+
{props.badge}
|
|
258
|
+
|
|
259
|
+
{/* PRICE FIRST, and loud. `fontSize.xl` rather than an antd
|
|
260
|
+
heading: this is a price, not a section title, and it must
|
|
261
|
+
not enter the document outline of a page holding twenty of
|
|
262
|
+
them. */}
|
|
263
|
+
<Flex align="center" gap={spacing[2]} wrap>
|
|
264
|
+
<Typography.Text
|
|
265
|
+
strong
|
|
266
|
+
style={{ fontSize: fontSize.xl.fontSize }}
|
|
267
|
+
data-testid="listings-serp-price"
|
|
268
|
+
>
|
|
269
|
+
<ListingPrice amount={listing.price} {...currency} />
|
|
270
|
+
</Typography.Text>
|
|
271
|
+
{priceTrend !== undefined && (
|
|
272
|
+
<PriceTrendIcon
|
|
273
|
+
direction={priceTrend.direction}
|
|
274
|
+
label={t(
|
|
275
|
+
priceTrend.direction === "down"
|
|
276
|
+
? LISTINGS_I18N_KEYS.cardPriceDropped
|
|
277
|
+
: LISTINGS_I18N_KEYS.cardPriceRaised
|
|
278
|
+
)}
|
|
279
|
+
/>
|
|
285
280
|
)}
|
|
286
|
-
|
|
287
|
-
)}
|
|
288
|
-
</Flex>
|
|
281
|
+
</Flex>
|
|
289
282
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
283
|
+
{priceTrend !== undefined && (
|
|
284
|
+
<Flex
|
|
285
|
+
align="baseline"
|
|
286
|
+
gap={spacing[1]}
|
|
287
|
+
data-testid="listings-serp-old-price"
|
|
288
|
+
>
|
|
289
|
+
{/* The strike-through is what a sighted reader sees and
|
|
290
|
+
nothing a screen reader announces, so the word is on
|
|
291
|
+
the line too rather than left to the styling. */}
|
|
292
|
+
<Typography.Text type="secondary">
|
|
293
|
+
{t(LISTINGS_I18N_KEYS.cardPriceWas)}
|
|
294
|
+
</Typography.Text>
|
|
295
|
+
<Typography.Text type="secondary" delete>
|
|
296
|
+
<ListingPrice amount={priceTrend.oldPrice} {...currency} />
|
|
297
|
+
</Typography.Text>
|
|
298
|
+
</Flex>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
<Typography.Text data-testid="listings-serp-title">
|
|
302
|
+
{title}
|
|
304
303
|
</Typography.Text>
|
|
305
|
-
</Flex>
|
|
306
|
-
)}
|
|
307
304
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
305
|
+
{props.specsLine !== undefined && props.specsLine.length > 0 ? (
|
|
306
|
+
<Typography.Text
|
|
307
|
+
type="secondary"
|
|
308
|
+
ellipsis
|
|
309
|
+
data-testid="listings-serp-specs"
|
|
310
|
+
>
|
|
311
|
+
{props.specsLine}
|
|
312
|
+
</Typography.Text>
|
|
313
|
+
) : titleDaos.length > 0 ? (
|
|
314
|
+
<Typography.Text
|
|
315
|
+
type="secondary"
|
|
316
|
+
ellipsis
|
|
317
|
+
data-testid="listings-serp-specs"
|
|
318
|
+
>
|
|
319
|
+
<FeatureBadges
|
|
320
|
+
features={featuresFromDaoList(titleDaos, copy).map(
|
|
321
|
+
(view) => view.feature,
|
|
322
|
+
)}
|
|
323
|
+
values={featuresDtoFromDaoList(titleDaos)}
|
|
324
|
+
/>
|
|
325
|
+
</Typography.Text>
|
|
326
|
+
) : null}
|
|
311
327
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
</Typography.Text>
|
|
320
|
-
) : titleDaos.length > 0 ? (
|
|
321
|
-
<Typography.Text
|
|
322
|
-
type="secondary"
|
|
323
|
-
ellipsis
|
|
324
|
-
data-testid="listings-serp-specs"
|
|
325
|
-
>
|
|
326
|
-
<FeatureBadges
|
|
327
|
-
features={featuresFromDaoList(titleDaos, copy).map(
|
|
328
|
-
(view) => view.feature,
|
|
329
|
-
)}
|
|
330
|
-
values={featuresDtoFromDaoList(titleDaos)}
|
|
331
|
-
/>
|
|
332
|
-
</Typography.Text>
|
|
333
|
-
) : null}
|
|
328
|
+
{badgeFeatures.length > 0 ? (
|
|
329
|
+
<FeatureBadges
|
|
330
|
+
features={badgeFeatures.map((view) => view.feature)}
|
|
331
|
+
values={badgeValues}
|
|
332
|
+
/>
|
|
333
|
+
) : null}
|
|
334
|
+
</CardTarget>
|
|
334
335
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
/>
|
|
340
|
-
) : null}
|
|
341
|
-
</CardTarget>
|
|
336
|
+
{/* Outside the anchor, both of them: a seller line usually holds
|
|
337
|
+
a link to the seller, and the place is the last thing read
|
|
338
|
+
rather than part of what the card is called. */}
|
|
339
|
+
{props.sellerSlot ?? null}
|
|
342
340
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
>
|
|
354
|
-
{listing.location_label}
|
|
355
|
-
</Typography.Text>
|
|
356
|
-
) : null}
|
|
357
|
-
</Flex>
|
|
341
|
+
{listing.location_label !== undefined &&
|
|
342
|
+
listing.location_label.length > 0 ? (
|
|
343
|
+
<Typography.Text
|
|
344
|
+
type="secondary"
|
|
345
|
+
data-testid="listings-serp-location"
|
|
346
|
+
>
|
|
347
|
+
{listing.location_label}
|
|
348
|
+
</Typography.Text>
|
|
349
|
+
) : null}
|
|
350
|
+
</Flex>
|
|
358
351
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
352
|
+
{rail}
|
|
353
|
+
</Flex>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
362
357
|
</Card>
|
|
363
358
|
</SkinTheme>
|
|
364
359
|
);
|
package/src/default/index.ts
CHANGED
|
@@ -33,7 +33,13 @@
|
|
|
33
33
|
* that wrapped its own composition in `<ListingsSkinTheme>` imports
|
|
34
34
|
* `<SkinTheme>` from the substrate instead — same props, plus a `surface`.
|
|
35
35
|
*/
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
ListingCard,
|
|
38
|
+
// The card's row arm, for a container that wants to lay out against the same
|
|
39
|
+
// threshold (and for a test that measures it). See `<ListingCard>`'s header.
|
|
40
|
+
LISTING_CARD_ROW_MIN,
|
|
41
|
+
LISTING_CARD_ROW_MEDIA,
|
|
42
|
+
} from "./ListingCard.js";
|
|
37
43
|
export type {
|
|
38
44
|
ListingCardProps,
|
|
39
45
|
ListingCardBaseProps,
|
|
@@ -57,8 +63,8 @@ export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDe
|
|
|
57
63
|
export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
|
|
58
64
|
export {
|
|
59
65
|
ListingComposerPage,
|
|
66
|
+
COMPOSER_DETAILS_PLACEMENT,
|
|
60
67
|
COMPOSER_MEASURE,
|
|
61
|
-
COMPOSER_STACKED_BELOW,
|
|
62
68
|
composerFieldId,
|
|
63
69
|
} from "./ListingComposerPage.js";
|
|
64
70
|
export type {
|
|
@@ -79,7 +85,11 @@ export type {
|
|
|
79
85
|
|
|
80
86
|
export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
|
|
81
87
|
export type { ListingStatusProps } from "./StatusTags.js";
|
|
82
|
-
export {
|
|
88
|
+
export {
|
|
89
|
+
ListingPhoto,
|
|
90
|
+
ListingPhotoStrip,
|
|
91
|
+
LISTING_PHOTO_ASPECT,
|
|
92
|
+
} from "./ListingPhoto.js";
|
|
83
93
|
export type { ListingPhotoProps } from "./ListingPhoto.js";
|
|
84
94
|
export { SignInLink } from "./SignInLink.js";
|
|
85
95
|
export type { SignInLinkProps } from "./SignInLink.js";
|