@stapel/listings-react 0.22.1 → 0.24.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 +109 -0
- package/README.md +73 -6
- package/dist/api/generated/schema.d.ts +119 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +25 -0
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +1 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +46 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/CardBadges.d.ts +47 -0
- package/dist/default/CardBadges.d.ts.map +1 -0
- package/dist/default/CardBadges.js +38 -0
- package/dist/default/CardBadges.js.map +1 -0
- package/dist/default/FavoritesPane.d.ts +18 -2
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +3 -3
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +6 -6
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +12 -19
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +12 -8
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +3 -5
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/ListingSpecList.d.ts +74 -0
- package/dist/default/ListingSpecList.d.ts.map +1 -0
- package/dist/default/ListingSpecList.js +90 -0
- package/dist/default/ListingSpecList.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +22 -2
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +1 -1
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/cardGallery.d.ts +81 -0
- package/dist/default/cardGallery.d.ts.map +1 -0
- package/dist/default/cardGallery.js +266 -0
- package/dist/default/cardGallery.js.map +1 -0
- package/dist/default/index.d.ts +8 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +5 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +16 -10
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +23 -4
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- 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/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +134 -0
- package/dist/model/cardBadges.d.ts.map +1 -0
- package/dist/model/cardBadges.js +131 -0
- package/dist/model/cardBadges.js.map +1 -0
- package/dist/model/featureText.d.ts +63 -0
- package/dist/model/featureText.d.ts.map +1 -0
- package/dist/model/featureText.js +133 -0
- package/dist/model/featureText.js.map +1 -0
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +59 -0
- package/dist/model/features.js.map +1 -1
- package/dist/model/queries.d.ts +15 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +20 -0
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +4 -0
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +1 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/llms.txt +6 -2
- package/manifest.json +57 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +119 -6
- package/src/api/listingsApi.ts +30 -0
- package/src/api/types.ts +55 -5
- package/src/default/CardBadges.tsx +105 -0
- package/src/default/FavoritesPane.tsx +20 -5
- package/src/default/ListingCard.tsx +7 -21
- package/src/default/ListingDetailPane.tsx +13 -37
- package/src/default/ListingPhoto.tsx +54 -26
- package/src/default/ListingSerpCard.tsx +10 -28
- package/src/default/ListingSpecList.tsx +195 -0
- package/src/default/MyListingsPane.tsx +23 -5
- package/src/default/cardGallery.ts +287 -0
- package/src/default/index.ts +24 -1
- package/src/headless/ListingComposer.tsx +42 -17
- package/src/i18n/es.ts +3 -0
- 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 +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +16 -0
- package/src/model/cardBadges.ts +237 -0
- package/src/model/featureText.ts +199 -0
- package/src/model/features.ts +60 -0
- package/src/model/queries.ts +25 -0
- package/src/model/queryKeys.ts +5 -0
|
@@ -30,6 +30,12 @@ import { useT } from "@stapel/core";
|
|
|
30
30
|
import type { LinkComponent } from "@stapel/core";
|
|
31
31
|
import { radii, spacing } from "@stapel/tokens";
|
|
32
32
|
import { useListingsRuntime } from "../model/context.js";
|
|
33
|
+
import {
|
|
34
|
+
CARD_GALLERY_CLASS,
|
|
35
|
+
CARD_GALLERY_STYLE_HREF,
|
|
36
|
+
cardGalleryCss,
|
|
37
|
+
useCardGallery,
|
|
38
|
+
} from "./cardGallery.js";
|
|
33
39
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
34
40
|
|
|
35
41
|
|
|
@@ -210,6 +216,9 @@ export function ListingPhotoStrip(props: {
|
|
|
210
216
|
const t = useT();
|
|
211
217
|
const { images, title } = props;
|
|
212
218
|
const many = images.length > 1;
|
|
219
|
+
// Hover-scrub and swipe. Inert for a one-photo strip; see `cardGallery.ts`
|
|
220
|
+
// for the two gates and why the keyboard path is untouched.
|
|
221
|
+
const gallery = useCardGallery(images.length);
|
|
213
222
|
|
|
214
223
|
/**
|
|
215
224
|
* One slide, linked or not.
|
|
@@ -252,32 +261,51 @@ export function ListingPhotoStrip(props: {
|
|
|
252
261
|
};
|
|
253
262
|
|
|
254
263
|
return (
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
data-
|
|
264
|
+
<div
|
|
265
|
+
ref={gallery.ref}
|
|
266
|
+
className={CARD_GALLERY_CLASS}
|
|
267
|
+
data-testid={`${props.testId}-gallery`}
|
|
268
|
+
data-gallery-photos={String(images.length)}
|
|
269
|
+
data-gallery-active={String(gallery.active)}
|
|
270
|
+
data-scrubbing={String(gallery.scrubbing)}
|
|
271
|
+
data-analytics="none"
|
|
272
|
+
data-analytics-reason="a look, not an outcome — scrubbing photographs navigates nowhere and changes no record"
|
|
273
|
+
onPointerMove={gallery.onPointerMove}
|
|
274
|
+
onPointerDown={gallery.onPointerDown}
|
|
275
|
+
onPointerUp={gallery.onPointerUp}
|
|
276
|
+
onPointerCancel={gallery.onPointerCancel}
|
|
277
|
+
onPointerLeave={gallery.onPointerLeave}
|
|
261
278
|
>
|
|
262
|
-
{
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
<style href={CARD_GALLERY_STYLE_HREF} precedence="default">
|
|
280
|
+
{cardGalleryCss()}
|
|
281
|
+
</style>
|
|
282
|
+
<SkinCarousel
|
|
283
|
+
label={t(LISTINGS_I18N_KEYS.cardPhotos)}
|
|
284
|
+
aspectRatio={LISTING_PHOTO_ASPECT}
|
|
285
|
+
peek={many}
|
|
286
|
+
dots={many}
|
|
287
|
+
data-testid={props.testId}
|
|
288
|
+
>
|
|
289
|
+
{images.length === 0
|
|
290
|
+
? slide(<ListingPhoto imageRef={undefined} alt={title} />, "empty")
|
|
291
|
+
: images.map((reference, index) =>
|
|
292
|
+
slide(
|
|
293
|
+
<ListingPhoto
|
|
294
|
+
key={reference}
|
|
295
|
+
imageRef={reference}
|
|
296
|
+
alt={
|
|
297
|
+
many
|
|
298
|
+
? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
299
|
+
index: index + 1,
|
|
300
|
+
total: images.length,
|
|
301
|
+
})
|
|
302
|
+
: title
|
|
303
|
+
}
|
|
304
|
+
/>,
|
|
305
|
+
reference
|
|
306
|
+
)
|
|
307
|
+
)}
|
|
308
|
+
</SkinCarousel>
|
|
309
|
+
</div>
|
|
282
310
|
);
|
|
283
311
|
}
|
|
@@ -79,13 +79,8 @@ import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
|
79
79
|
import type { SignInCta } from "@stapel/core";
|
|
80
80
|
import { useT } from "@stapel/core";
|
|
81
81
|
import { fontSize, spacing } from "@stapel/tokens";
|
|
82
|
-
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
83
82
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
84
|
-
import {
|
|
85
|
-
asFeatureDaoList,
|
|
86
|
-
featuresDtoFromDaoList,
|
|
87
|
-
featuresFromDaoList,
|
|
88
|
-
} from "../model/features.js";
|
|
83
|
+
import { asFeatureDaoList } from "../model/features.js";
|
|
89
84
|
import type { FeatureCopySource } from "../model/features.js";
|
|
90
85
|
import { lifecycleCaption } from "../model/status.js";
|
|
91
86
|
import { isListingViewed } from "../model/engagement.js";
|
|
@@ -93,6 +88,7 @@ import { useEngagedListing } from "../headless/Engagement.js";
|
|
|
93
88
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
94
89
|
import { PriceTrendIcon } from "./icons.js";
|
|
95
90
|
import { FavoriteHeart } from "./favorite.js";
|
|
91
|
+
import { CardBadges, CardSpecLine } from "./CardBadges.js";
|
|
96
92
|
import {
|
|
97
93
|
CARD_FRAME_CLASS,
|
|
98
94
|
CARD_MAIN_CLASS,
|
|
@@ -188,8 +184,6 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
188
184
|
props.categoryFeatures !== undefined
|
|
189
185
|
? { categoryFeatures: props.categoryFeatures }
|
|
190
186
|
: {};
|
|
191
|
-
const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
|
|
192
|
-
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
193
187
|
// The fallback spec line when the host derived none: the row's own
|
|
194
188
|
// `features_title` projection, exactly the line `ListingCard` draws.
|
|
195
189
|
const titleDaos =
|
|
@@ -346,27 +340,15 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
346
340
|
>
|
|
347
341
|
{props.specsLine}
|
|
348
342
|
</Typography.Text>
|
|
349
|
-
) :
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
>
|
|
355
|
-
<FeatureBadges
|
|
356
|
-
features={featuresFromDaoList(titleDaos, copy).map(
|
|
357
|
-
(view) => view.feature,
|
|
358
|
-
)}
|
|
359
|
-
values={featuresDtoFromDaoList(titleDaos)}
|
|
360
|
-
/>
|
|
361
|
-
</Typography.Text>
|
|
362
|
-
) : null}
|
|
363
|
-
|
|
364
|
-
{badgeFeatures.length > 0 ? (
|
|
365
|
-
<FeatureBadges
|
|
366
|
-
features={badgeFeatures.map((view) => view.feature)}
|
|
367
|
-
values={badgeValues}
|
|
343
|
+
) : (
|
|
344
|
+
<CardSpecLine
|
|
345
|
+
rows={titleDaos}
|
|
346
|
+
copy={copy}
|
|
347
|
+
testId="listings-serp-specs"
|
|
368
348
|
/>
|
|
369
|
-
)
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
<CardBadges rows={badgeDaos} copy={copy} variant="badges" />
|
|
370
352
|
</CardTarget>
|
|
371
353
|
|
|
372
354
|
{/* Outside the anchor, both of them: a seller line usually holds
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listing page's spec rows ("Characteristics"), as SENTENCES.
|
|
3
|
+
*
|
|
4
|
+
* ── What was on screen, and why a table was the wrong shape ───────────────
|
|
5
|
+
*
|
|
6
|
+
* The list was `<Descriptions column={1}>`: a real two-column table, label
|
|
7
|
+
* cell beside value cell. On a phone — and in the split layout's half-width
|
|
8
|
+
* left column — the value cell is narrow, so a long answer ("Trim level",
|
|
9
|
+
* a full trim name, a two-line address) wrapped INSIDE it and stacked under
|
|
10
|
+
* itself in a column a third of the page wide, beside acres of empty label
|
|
11
|
+
* gutter. A spec row is not tabular data that a reader scans down one axis;
|
|
12
|
+
* it is a short question and its answer, and it reads as a line of text.
|
|
13
|
+
*
|
|
14
|
+
* So the label is an inline `<span>`, muted, and the value follows it in the
|
|
15
|
+
* SAME text flow. A long value now wraps as a paragraph — full measure,
|
|
16
|
+
* hanging under the label — and a short one costs one line instead of a row
|
|
17
|
+
* of a table.
|
|
18
|
+
*
|
|
19
|
+
* ── The two-column grid that survives is a grid of ROWS ───────────────────
|
|
20
|
+
*
|
|
21
|
+
* The split layout still puts two columns of spec rows side by side on a wide
|
|
22
|
+
* screen, because a forty-row list under a photograph is a scroll nobody
|
|
23
|
+
* finishes. What is gone is the label being its own COLUMN: the columns hold
|
|
24
|
+
* whole rows, cut by row count so the category's declaration order still
|
|
25
|
+
* reads top-to-bottom, left column first. `<ListingSpecColumns>` is that cut,
|
|
26
|
+
* kept here beside the row it cuts.
|
|
27
|
+
*
|
|
28
|
+
* ── Units and digits ─────────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* Every value goes through `formatSpecValue` rather than the shared
|
|
31
|
+
* `formatFeatureValue`: same answer, typeset — the unit appended and the
|
|
32
|
+
* digits grouped by the reader's locale. `model/featureText.ts` says where
|
|
33
|
+
* the unit comes from and why there is no `unit` key to read it from.
|
|
34
|
+
*/
|
|
35
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
36
|
+
import { Typography, theme as antdTheme } from "antd";
|
|
37
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
38
|
+
import { useI18n, useT } from "@stapel/core";
|
|
39
|
+
import { spacing } from "@stapel/tokens";
|
|
40
|
+
import {
|
|
41
|
+
ATTRIBUTES_I18N_KEYS,
|
|
42
|
+
featureName,
|
|
43
|
+
featureType,
|
|
44
|
+
isRedactedValue,
|
|
45
|
+
isValuePresent,
|
|
46
|
+
isValueVerified,
|
|
47
|
+
} from "@stapel/attributes-react";
|
|
48
|
+
import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
|
|
49
|
+
import { formatSpecValue } from "../model/featureText.js";
|
|
50
|
+
|
|
51
|
+
/** The class one spec row carries. */
|
|
52
|
+
export const SPEC_ROW_CLASS = "stapel-listing-spec-row";
|
|
53
|
+
/** The class the row's inline label carries. */
|
|
54
|
+
export const SPEC_LABEL_CLASS = "stapel-listing-spec-label";
|
|
55
|
+
/** The `href` the hoisted spec stylesheet is deduplicated by. */
|
|
56
|
+
export const SPEC_STYLE_HREF = "stapel-listings-spec";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The rules an inline style cannot express — the label's own colour is set
|
|
60
|
+
* per-instance as a custom property so ONE hoisted copy serves either theme.
|
|
61
|
+
*
|
|
62
|
+
* `display: inline` on the label is the whole fix and is stated rather than
|
|
63
|
+
* inherited: antd's `<Text>` renders a `<span>`, but a skin that retunes it
|
|
64
|
+
* to a block would silently put the table back.
|
|
65
|
+
*/
|
|
66
|
+
export function specListCss(): string {
|
|
67
|
+
return [
|
|
68
|
+
// A paragraph, not a table row. `margin: 0` because the gap between rows
|
|
69
|
+
// is the list's, so a row can be lifted into a grid cell unchanged.
|
|
70
|
+
`.${SPEC_ROW_CLASS}{margin:0;min-inline-size:0;overflow-wrap:anywhere}`,
|
|
71
|
+
`.${SPEC_LABEL_CLASS}{display:inline;color:var(--listing-spec-label)}`,
|
|
72
|
+
// The one space between the question and the answer, owned by the label
|
|
73
|
+
// rather than written as a text node — a `{" "}` between two JSX elements
|
|
74
|
+
// is the kind of whitespace a formatter deletes.
|
|
75
|
+
`.${SPEC_LABEL_CLASS}::after{content:"\\00a0"}`,
|
|
76
|
+
].join("");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ListingSpecListProps {
|
|
80
|
+
/** The category's features, in the order they are declared. */
|
|
81
|
+
readonly features: readonly FeatureDef[];
|
|
82
|
+
/** The DISPLAY envelope — redacted stubs included, which is what makes a
|
|
83
|
+
* withheld row keep its place. */
|
|
84
|
+
readonly values: Readonly<Record<string, FeatureValueDto>>;
|
|
85
|
+
/** The surface's own test id, so a split page holding two columns of these
|
|
86
|
+
* does not hand a test two elements under one name. */
|
|
87
|
+
readonly testId?: string;
|
|
88
|
+
readonly style?: CSSProperties;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A withheld value's row: what the system OBSERVED, and nothing more — the
|
|
93
|
+
* same three states `@stapel/attributes-react` prints, said with its own
|
|
94
|
+
* copy keys so the two lists cannot drift into two different sentences.
|
|
95
|
+
*/
|
|
96
|
+
function redactedText(dto: FeatureValueDto | undefined, t: (key: string) => string): string {
|
|
97
|
+
if (!isValuePresent(dto)) return t(ATTRIBUTES_I18N_KEYS.valueNotSet);
|
|
98
|
+
if (isValueVerified(dto)) return t(ATTRIBUTES_I18N_KEYS.valueVerified);
|
|
99
|
+
return t(ATTRIBUTES_I18N_KEYS.valueProvided);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function ListingSpecList(props: ListingSpecListProps): ReactElement {
|
|
103
|
+
const t = useT();
|
|
104
|
+
const { locale } = useI18n();
|
|
105
|
+
const { token } = antdTheme.useToken();
|
|
106
|
+
|
|
107
|
+
// A `header` is a section caption in a FORM, not a value: it has none, and
|
|
108
|
+
// a spec table that printed one would print "not specified" under it.
|
|
109
|
+
const rows = props.features.filter((feature) => featureType(feature) !== "header");
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<SkinTheme surface="bare">
|
|
113
|
+
<style href={SPEC_STYLE_HREF} precedence="default">
|
|
114
|
+
{specListCss()}
|
|
115
|
+
</style>
|
|
116
|
+
<div
|
|
117
|
+
data-testid={props.testId ?? "listings-spec-list"}
|
|
118
|
+
style={{
|
|
119
|
+
display: "flex",
|
|
120
|
+
flexDirection: "column",
|
|
121
|
+
gap: spacing[1],
|
|
122
|
+
minWidth: 0,
|
|
123
|
+
["--listing-spec-label" as string]: token.colorTextSecondary,
|
|
124
|
+
...props.style,
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{rows.map((feature) => {
|
|
128
|
+
const dto = props.values[feature.slug];
|
|
129
|
+
const redacted = isRedactedValue(dto);
|
|
130
|
+
const text = redacted
|
|
131
|
+
? redactedText(dto, t)
|
|
132
|
+
: (formatSpecValue(feature, dto, { t, locale }) ??
|
|
133
|
+
t(
|
|
134
|
+
dto === undefined || featureType(feature) === undefined
|
|
135
|
+
? ATTRIBUTES_I18N_KEYS.valueNotSet
|
|
136
|
+
: ATTRIBUTES_I18N_KEYS.valueUnreadable
|
|
137
|
+
));
|
|
138
|
+
return (
|
|
139
|
+
<p
|
|
140
|
+
key={feature.slug}
|
|
141
|
+
className={SPEC_ROW_CLASS}
|
|
142
|
+
data-testid={`listings-spec-row-${feature.slug}`}
|
|
143
|
+
>
|
|
144
|
+
<Typography.Text
|
|
145
|
+
type="secondary"
|
|
146
|
+
className={SPEC_LABEL_CLASS}
|
|
147
|
+
data-testid={`listings-spec-label-${feature.slug}`}
|
|
148
|
+
>
|
|
149
|
+
{featureName(feature)}
|
|
150
|
+
</Typography.Text>
|
|
151
|
+
<span data-testid={`listings-spec-value-${feature.slug}`}>{text}</span>
|
|
152
|
+
</p>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</div>
|
|
156
|
+
</SkinTheme>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The same rows in TWO columns on a wide screen — a grid of whole rows, cut
|
|
162
|
+
* by row count so the category's declaration order still reads top-to-bottom,
|
|
163
|
+
* left column first.
|
|
164
|
+
*
|
|
165
|
+
* The cut is here rather than inside the list because a CSS `columns` rule
|
|
166
|
+
* would break a wrapped paragraph across the column boundary, which is
|
|
167
|
+
* exactly the defect the paragraph shape was adopted to avoid.
|
|
168
|
+
*/
|
|
169
|
+
export function ListingSpecColumns(props: ListingSpecListProps): ReactElement {
|
|
170
|
+
const half = Math.ceil(props.features.length / 2);
|
|
171
|
+
return (
|
|
172
|
+
<div
|
|
173
|
+
data-testid={props.testId ?? "listings-detail-specs-split"}
|
|
174
|
+
style={{
|
|
175
|
+
display: "grid",
|
|
176
|
+
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
|
177
|
+
gap: spacing[4],
|
|
178
|
+
alignItems: "start",
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
<ListingSpecList
|
|
182
|
+
features={props.features.slice(0, half)}
|
|
183
|
+
values={props.values}
|
|
184
|
+
testId="listings-spec-list"
|
|
185
|
+
/>
|
|
186
|
+
{props.features.length > half ? (
|
|
187
|
+
<ListingSpecList
|
|
188
|
+
features={props.features.slice(half)}
|
|
189
|
+
values={props.values}
|
|
190
|
+
testId="listings-spec-list-second"
|
|
191
|
+
/>
|
|
192
|
+
) : null}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -119,6 +119,19 @@ function myListingsCss(): string {
|
|
|
119
119
|
].join("");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The row `listingHref` is called with as its second argument — the same
|
|
124
|
+
* object the row renders from, with `title` guaranteed present.
|
|
125
|
+
*
|
|
126
|
+
* `MyListingCard.title` is the PUBLISHED field and is `""`/absent on a
|
|
127
|
+
* listing nobody has ever submitted (`model/mine.ts`'s header) — but
|
|
128
|
+
* {@link RowLink} never calls `href` for such a row (`neverSubmitted` bails
|
|
129
|
+
* out first), so every row that reaches a `listingHref` call has a real
|
|
130
|
+
* title. The cast below is that guarantee made structural rather than
|
|
131
|
+
* re-checked at each call site.
|
|
132
|
+
*/
|
|
133
|
+
export type MyListingHrefRow = MyListingCard & { title: string };
|
|
134
|
+
|
|
122
135
|
/**
|
|
123
136
|
* The row's title and thumbnail, as a link to the listing's own page when the
|
|
124
137
|
* host has one.
|
|
@@ -141,7 +154,7 @@ function myListingsCss(): string {
|
|
|
141
154
|
*/
|
|
142
155
|
function RowLink(props: {
|
|
143
156
|
listing: MyListingCard;
|
|
144
|
-
href: ((id: number) => string) | undefined;
|
|
157
|
+
href: ((id: number, row: MyListingHrefRow) => string) | undefined;
|
|
145
158
|
linkComponent: LinkComponent | undefined;
|
|
146
159
|
children: ReactNode;
|
|
147
160
|
testId: string;
|
|
@@ -149,7 +162,7 @@ function RowLink(props: {
|
|
|
149
162
|
}): ReactElement {
|
|
150
163
|
const { href, listing } = props;
|
|
151
164
|
if (href === undefined || neverSubmitted(listing)) return <>{props.children}</>;
|
|
152
|
-
const target = href(listing.id);
|
|
165
|
+
const target = href(listing.id, listing as MyListingHrefRow);
|
|
153
166
|
const Link = props.linkComponent;
|
|
154
167
|
const shared = {
|
|
155
168
|
href: target,
|
|
@@ -171,7 +184,7 @@ function MyListingRow(props: {
|
|
|
171
184
|
listing: MyListingCard;
|
|
172
185
|
onEdit?: ((id: number) => void) | undefined;
|
|
173
186
|
onAskRemove: (id: number) => void;
|
|
174
|
-
listingHref?: ((id: number) => string) | undefined;
|
|
187
|
+
listingHref?: ((id: number, row: MyListingHrefRow) => string) | undefined;
|
|
175
188
|
linkComponent?: LinkComponent | undefined;
|
|
176
189
|
}): ReactElement {
|
|
177
190
|
const t = useT();
|
|
@@ -353,7 +366,12 @@ export interface MyListingsPaneProps extends ThemeModeProp, SignInCtaProp {
|
|
|
353
366
|
readonly onEdit?: (id: number) => void;
|
|
354
367
|
/**
|
|
355
368
|
* Where a listing's own page lives, given its id — `(id) => \`/l/${id}\``
|
|
356
|
-
* for a storefront
|
|
369
|
+
* for a storefront that only needs the id, or
|
|
370
|
+
* `(id, row) => \`/l/${id}-${slugify(row.title)}\`` for one whose routes
|
|
371
|
+
* carry a slug. The second argument is the same {@link MyListingCard} the
|
|
372
|
+
* row renders from, narrowed to {@link MyListingHrefRow} (`title`
|
|
373
|
+
* guaranteed present) — a host that ignores it keeps the id-only form
|
|
374
|
+
* working exactly as before.
|
|
357
375
|
*
|
|
358
376
|
* Absent, the row's title and thumbnail are plain text and a picture, which
|
|
359
377
|
* is what this pane shipped: a live cabinet held zero links to a listing,
|
|
@@ -362,7 +380,7 @@ export interface MyListingsPaneProps extends ThemeModeProp, SignInCtaProp {
|
|
|
362
380
|
* have no public page has nothing to link TO — but it is now a decision the
|
|
363
381
|
* host makes rather than one this file made for everyone.
|
|
364
382
|
*/
|
|
365
|
-
readonly listingHref?: (id: number) => string;
|
|
383
|
+
readonly listingHref?: (id: number, row: MyListingHrefRow) => string;
|
|
366
384
|
/**
|
|
367
385
|
* The router's link, so those two targets are client-side navigations. A
|
|
368
386
|
* plain `<a href>` otherwise — right-clickable and correct, just a full page
|