@stapel/listings-react 0.23.0 → 0.25.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 +93 -0
- package/MODULE.md +4 -0
- package/README.md +75 -0
- package/dist/api/generated/schema.d.ts +126 -11
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +5 -3
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +51 -4
- 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/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/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 +6 -0
- 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 +20 -2
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +19 -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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- 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/draft.d.ts +10 -2
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +22 -4
- package/dist/model/draft.js.map +1 -1
- package/dist/model/featureText.d.ts +85 -0
- package/dist/model/featureText.d.ts.map +1 -0
- package/dist/model/featureText.js +153 -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/llms.txt +5 -2
- package/manifest.json +47 -1
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +126 -11
- package/src/api/listingsApi.ts +5 -3
- package/src/api/types.ts +60 -6
- package/src/default/CardBadges.tsx +105 -0
- 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/cardGallery.ts +287 -0
- package/src/default/index.ts +22 -0
- package/src/headless/ListingComposer.tsx +39 -5
- 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 +15 -0
- package/src/model/cardBadges.ts +237 -0
- package/src/model/draft.ts +23 -5
- package/src/model/featureText.ts +251 -0
- package/src/model/features.ts +60 -0
package/src/api/types.ts
CHANGED
|
@@ -123,8 +123,62 @@ export function engagementIds(ids: readonly number[]): readonly number[] {
|
|
|
123
123
|
return [...seen].sort((a, b) => a - b).slice(0, LISTINGS_ENGAGEMENT_BATCH_LIMIT);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The stored-feature columns, restated as the runtime shape.
|
|
128
|
+
*
|
|
129
|
+
* `features`, `features_title` and `features_badges` are `JSONField`s (this
|
|
130
|
+
* file's header, note 2): what reaches the wire is what `build_features_list`
|
|
131
|
+
* stored, and the OpenAPI union beside them is a DESCRIPTION swapped in by an
|
|
132
|
+
* extension rather than a filter anything applies. stapel-listings 0.21.3
|
|
133
|
+
* makes that gap load-bearing: its union spells a badge element with `label`,
|
|
134
|
+
* `name` and `presentation` REQUIRED, so a row from a server that predates the
|
|
135
|
+
* contract — the fallback arm `<CardBadges>` exists for, and every row on
|
|
136
|
+
* every listing published before 0.21.3 — no longer satisfies the generated
|
|
137
|
+
* type although it is exactly what a card receives.
|
|
138
|
+
*
|
|
139
|
+
* So the columns are typed as {@link ListingFeatureDao}, the mirror that says
|
|
140
|
+
* what a stored row IS, and the contract keys are read off the generated
|
|
141
|
+
* element instead (`model/cardBadges.ts`) — a rename upstream still breaks the
|
|
142
|
+
* build, without the generated union refusing the older half of the wire.
|
|
143
|
+
*/
|
|
144
|
+
/**
|
|
145
|
+
* The badge element the CARD BADGE CONTRACT describes, as the generated schema
|
|
146
|
+
* spells it (stapel-listings 0.21.3).
|
|
147
|
+
*
|
|
148
|
+
* Taken off `Schemas` rather than the relaxed row above, because this is the
|
|
149
|
+
* one place the contract's own names and readings should still come from the
|
|
150
|
+
* wire: `model/cardBadges.ts` derives its keys from here, so a rename or a
|
|
151
|
+
* fifth `presentation` upstream turns this build red.
|
|
152
|
+
*/
|
|
153
|
+
export type ListingCardBadgeElement = Extract<
|
|
154
|
+
Schemas["ListingCard"]["features_badges"][number],
|
|
155
|
+
{ presentation: unknown }
|
|
156
|
+
>;
|
|
157
|
+
|
|
158
|
+
type StoredFeatureColumn = "features" | "features_title" | "features_badges";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A stored row AS IT ARRIVES: every key of the mirror, none of them promised.
|
|
162
|
+
*
|
|
163
|
+
* `slug` is required on {@link ListingFeatureDao} because a row without one is
|
|
164
|
+
* unusable — and a JSONField ships one anyway when the projection misses it,
|
|
165
|
+
* which `featureFromDao` reports rather than papering over with a synthesized
|
|
166
|
+
* index. `asFeatureDaoList` is the single narrowing point between the two.
|
|
167
|
+
*/
|
|
168
|
+
export type ListingFeatureRow = Partial<ListingFeatureDao>;
|
|
169
|
+
|
|
170
|
+
type WithStoredFeatures<Row> = Omit<Row, StoredFeatureColumn> & {
|
|
171
|
+
readonly [K in Extract<keyof Row, StoredFeatureColumn>]: readonly ListingFeatureRow[];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** `GET /listings/{pk}/` 200 — everything a detail page reads.
|
|
175
|
+
*
|
|
176
|
+
* `category_id` is nullable here because the CONTRACT says so as of
|
|
177
|
+
* stapel-listings 0.21.4 — a draft may exist before its category is chosen —
|
|
178
|
+
* so the pair reads the generated type rather than relaxing it by hand. */
|
|
179
|
+
export type ListingDetail = WithStoredFeatures<
|
|
180
|
+
WithOptionalEngagement<Schemas["ListingDetail"]>
|
|
181
|
+
>;
|
|
128
182
|
|
|
129
183
|
/**
|
|
130
184
|
* One row of a card list (`GET /listings/`, `GET /listings/my/favorites/`) —
|
|
@@ -158,8 +212,8 @@ type WithOptionalPrecision<Row extends { geo_precision_km?: number }> = Omit<
|
|
|
158
212
|
> &
|
|
159
213
|
Partial<Pick<Schemas["ListingCard"], "geo_precision_km">>;
|
|
160
214
|
|
|
161
|
-
export type ListingCard =
|
|
162
|
-
WithOptionalEngagement<Schemas["ListingCard"]
|
|
215
|
+
export type ListingCard = WithStoredFeatures<
|
|
216
|
+
WithOptionalPrecision<WithOptionalEngagement<Schemas["ListingCard"]>>
|
|
163
217
|
>;
|
|
164
218
|
|
|
165
219
|
/** `POST /listings/` request+response and `POST /{pk}/save-draft/` response —
|
|
@@ -216,8 +270,8 @@ export type PaginatedListingCards = Omit<
|
|
|
216
270
|
* tab keyed off them is a column of blank rows. `myListingTitle` /
|
|
217
271
|
* `myListingPrice` (`model/mine.ts`) are the one place the fallback lives.
|
|
218
272
|
*/
|
|
219
|
-
export type MyListingCard =
|
|
220
|
-
WithOptionalEngagement<Schemas["MyListingCard"]
|
|
273
|
+
export type MyListingCard = WithStoredFeatures<
|
|
274
|
+
WithOptionalPrecision<WithOptionalEngagement<Schemas["MyListingCard"]>>
|
|
221
275
|
>;
|
|
222
276
|
|
|
223
277
|
/** The keyset envelope `GET /listings/my/listings/` comes back in — the same
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A card's badge line, drawn against the CARD BADGE CONTRACT when the server
|
|
3
|
+
* speaks it and exactly as it draws today when it does not.
|
|
4
|
+
*
|
|
5
|
+
* The contract itself — the five keys, the four presentations, and the
|
|
6
|
+
* measured "Brick · 3 · 9" that earned it — is `model/cardBadges.ts`.
|
|
7
|
+
* This file is only the two arms:
|
|
8
|
+
*
|
|
9
|
+
* - **the contract arm.** Each element already carries everything a reader
|
|
10
|
+
* needs (its name, its unit, its resolved copy, and the server's decision
|
|
11
|
+
* about which of them to print), so the text comes from `cardBadgeText`
|
|
12
|
+
* and nothing here reaches for a category, a formatter or an option table;
|
|
13
|
+
* - **the fallback arm.** No element declares a `presentation`, which means
|
|
14
|
+
* a backend older than stapel-listings 0.21.3 — so the row goes through
|
|
15
|
+
* `@stapel/attributes-react`'s `<FeatureBadges>` off the stored DAO's own
|
|
16
|
+
* config, byte-identical to the release before this one.
|
|
17
|
+
*
|
|
18
|
+
* One component, called by all three card surfaces, for the same reason
|
|
19
|
+
* `CardTarget` is one function: three cards each deciding what a badge says
|
|
20
|
+
* is three places for the bare numbers to come back.
|
|
21
|
+
*/
|
|
22
|
+
import type { ReactElement } from "react";
|
|
23
|
+
import { Flex, Tag, Typography } from "antd";
|
|
24
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
25
|
+
import { useI18n } from "@stapel/core";
|
|
26
|
+
import { spacing } from "@stapel/tokens";
|
|
27
|
+
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
28
|
+
import type { ListingFeatureDao } from "../api/types.js";
|
|
29
|
+
import type { CardBadgeRow } from "../model/cardBadges.js";
|
|
30
|
+
import { cardBadgeTexts, hasCardBadgeContract } from "../model/cardBadges.js";
|
|
31
|
+
import { featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
|
|
32
|
+
import type { FeatureCopySource } from "../model/features.js";
|
|
33
|
+
|
|
34
|
+
export interface CardBadgesProps {
|
|
35
|
+
/** The stored projection — `features_badges` or `features_title`. */
|
|
36
|
+
readonly rows: readonly ListingFeatureDao[];
|
|
37
|
+
/** The category's option table, where the surface has one. Used by the
|
|
38
|
+
* FALLBACK arm only: a contract element carries its own resolved copy. */
|
|
39
|
+
readonly copy: FeatureCopySource;
|
|
40
|
+
/**
|
|
41
|
+
* `"badges"` draws tags (the card's badge strip); `"line"` draws one
|
|
42
|
+
* dot-separated run of secondary text (the seller's own spec line under
|
|
43
|
+
* the title, which is already a line on every surface).
|
|
44
|
+
*/
|
|
45
|
+
readonly variant: "badges" | "line";
|
|
46
|
+
readonly testId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The separator of a spec line — the one every classified uses. */
|
|
50
|
+
const LINE_SEPARATOR = " · ";
|
|
51
|
+
|
|
52
|
+
export function CardBadges(props: CardBadgesProps): ReactElement | null {
|
|
53
|
+
const { locale } = useI18n();
|
|
54
|
+
const rows = props.rows as readonly CardBadgeRow[];
|
|
55
|
+
|
|
56
|
+
if (rows.length === 0) return null;
|
|
57
|
+
|
|
58
|
+
if (hasCardBadgeContract(rows)) {
|
|
59
|
+
const printed = cardBadgeTexts(rows, locale);
|
|
60
|
+
if (printed.length === 0) return null;
|
|
61
|
+
if (props.variant === "line") {
|
|
62
|
+
return (
|
|
63
|
+
<span data-testid={props.testId ?? "listings-card-badges"}>
|
|
64
|
+
{printed.map((one) => one.text).join(LINE_SEPARATOR)}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return (
|
|
69
|
+
<SkinTheme surface="bare">
|
|
70
|
+
<Flex gap={spacing[1]} wrap data-testid={props.testId ?? "listings-card-badges"}>
|
|
71
|
+
{printed.map((one) => (
|
|
72
|
+
<Tag key={one.slug} data-testid={`listings-card-badge-${one.slug}`}>
|
|
73
|
+
{one.text}
|
|
74
|
+
</Tag>
|
|
75
|
+
))}
|
|
76
|
+
</Flex>
|
|
77
|
+
</SkinTheme>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The older backend. Exactly what shipped before the contract existed.
|
|
82
|
+
const views = featuresFromDaoList(rows, props.copy);
|
|
83
|
+
if (views.length === 0) return null;
|
|
84
|
+
return (
|
|
85
|
+
<FeatureBadges
|
|
86
|
+
features={views.map((view) => view.feature)}
|
|
87
|
+
values={featuresDtoFromDaoList(rows)}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The spec line under a card's title, wrapped in the secondary, truncating
|
|
93
|
+
* text every surface already draws it in. */
|
|
94
|
+
export function CardSpecLine(props: {
|
|
95
|
+
readonly rows: readonly ListingFeatureDao[];
|
|
96
|
+
readonly copy: FeatureCopySource;
|
|
97
|
+
readonly testId: string;
|
|
98
|
+
}): ReactElement | null {
|
|
99
|
+
if (props.rows.length === 0) return null;
|
|
100
|
+
return (
|
|
101
|
+
<Typography.Text type="secondary" ellipsis data-testid={props.testId}>
|
|
102
|
+
<CardBadges rows={props.rows} copy={props.copy} variant="line" testId={`${props.testId}-text`} />
|
|
103
|
+
</Typography.Text>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -116,15 +116,15 @@ import {
|
|
|
116
116
|
import { useActionGate, useT } from "@stapel/core";
|
|
117
117
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
118
118
|
import { spacing } from "@stapel/tokens";
|
|
119
|
-
import { FeatureBadges } from "@stapel/attributes-react/default";
|
|
120
119
|
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
121
|
-
import { asFeatureDaoList
|
|
120
|
+
import { asFeatureDaoList } from "../model/features.js";
|
|
122
121
|
import type { FeatureCopySource } from "../model/features.js";
|
|
123
122
|
import { lifecycleCaption } from "../model/status.js";
|
|
124
123
|
import { isListingViewed } from "../model/engagement.js";
|
|
125
124
|
import { useEngagedListing } from "../headless/Engagement.js";
|
|
126
125
|
import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
127
126
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
127
|
+
import { CardBadges, CardSpecLine } from "./CardBadges.js";
|
|
128
128
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
129
129
|
import { HeartIcon } from "./icons.js";
|
|
130
130
|
import { SignInLink } from "./SignInLink.js";
|
|
@@ -539,13 +539,13 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
539
539
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
540
540
|
// The category's own option table, when the surface has it: a stored
|
|
541
541
|
// `select` carries no table of its own, so without this a badge prints the
|
|
542
|
-
// storage slug. Absent on a mixed grid, which is why it is optional.
|
|
542
|
+
// storage slug. Absent on a mixed grid, which is why it is optional. Read
|
|
543
|
+
// by the FALLBACK arm of `<CardBadges>` only — an element that speaks the
|
|
544
|
+
// card badge contract carries its own resolved copy.
|
|
543
545
|
const copy: FeatureCopySource =
|
|
544
546
|
props.categoryFeatures !== undefined
|
|
545
547
|
? { categoryFeatures: props.categoryFeatures }
|
|
546
548
|
: {};
|
|
547
|
-
const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
|
|
548
|
-
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
549
549
|
const titleDaos = asFeatureDaoList(listing.features_title);
|
|
550
550
|
|
|
551
551
|
const status = listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
@@ -610,23 +610,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
610
610
|
|
|
611
611
|
{/* The title features are a stored projection too — the seller's
|
|
612
612
|
"1.5 TB, black" line, already ordered by the server. */}
|
|
613
|
-
{titleDaos
|
|
614
|
-
<Typography.Text type="secondary" ellipsis>
|
|
615
|
-
<FeatureBadges
|
|
616
|
-
features={featuresFromDaoList(titleDaos, copy).map(
|
|
617
|
-
(view) => view.feature
|
|
618
|
-
)}
|
|
619
|
-
values={featuresDtoFromDaoList(titleDaos)}
|
|
620
|
-
/>
|
|
621
|
-
</Typography.Text>
|
|
622
|
-
) : null}
|
|
613
|
+
<CardSpecLine rows={titleDaos} copy={copy} testId="listings-card-specs" />
|
|
623
614
|
|
|
624
|
-
{
|
|
625
|
-
<FeatureBadges
|
|
626
|
-
features={badgeFeatures.map((view) => view.feature)}
|
|
627
|
-
values={badgeValues}
|
|
628
|
-
/>
|
|
629
|
-
) : null}
|
|
615
|
+
<CardBadges rows={badgeDaos} copy={copy} variant="badges" />
|
|
630
616
|
|
|
631
617
|
{/* ONE line, and it truncates (D185). Measured on a live 1440px feed:
|
|
632
618
|
a two-part city-and-district name wrapped, the text block grew
|
|
@@ -69,13 +69,14 @@ import {
|
|
|
69
69
|
} from "@stapel/core";
|
|
70
70
|
import type { SignInCta } from "@stapel/core";
|
|
71
71
|
import { spacing } from "@stapel/tokens";
|
|
72
|
-
import {
|
|
73
|
-
import { formatFeatureValue, isRedactedValue } from "@stapel/attributes-react";
|
|
72
|
+
import { isRedactedValue } from "@stapel/attributes-react";
|
|
74
73
|
import { useListingDetail } from "../headless/ListingDetail.js";
|
|
75
74
|
import { useListingActions } from "../headless/ListingActions.js";
|
|
76
75
|
import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
|
|
76
|
+
import { formatSpecValue } from "../model/featureText.js";
|
|
77
77
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
78
78
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
79
|
+
import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
|
|
79
80
|
import { SignInLink } from "./SignInLink.js";
|
|
80
81
|
import { HeartIcon } from "./icons.js";
|
|
81
82
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
@@ -338,7 +339,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
338
339
|
// leaked identifier would be read out loud.
|
|
339
340
|
.filter((view) => !isRedactedValue(view.value))
|
|
340
341
|
.map((view) =>
|
|
341
|
-
|
|
342
|
+
formatSpecValue(view.feature, view.value, { t, locale })
|
|
342
343
|
)
|
|
343
344
|
.filter((text): text is string => text !== undefined)
|
|
344
345
|
.join(" · ")}
|
|
@@ -548,47 +549,22 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
548
549
|
? { categoryFeatures: props.categoryFeatures }
|
|
549
550
|
: {}
|
|
550
551
|
);
|
|
551
|
-
/* Two spec columns in the split
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
552
|
+
/* Two spec columns in the split — a grid of whole ROWS, cut by
|
|
553
|
+
row count so the category's declaration order survives: the
|
|
554
|
+
first (larger) half fills the left list and the page reads
|
|
555
|
+
top-to-bottom, left column first, exactly as the one-column
|
|
556
|
+
list reads. The label is never a column of its own; see
|
|
557
|
+
`<ListingSpecList>` for the shape and why the table went. */
|
|
558
|
+
const specFeatures = bag.features.map((view) => view.feature);
|
|
557
559
|
const specs =
|
|
558
560
|
bag.features.length === 0 ? (
|
|
559
561
|
<Typography.Text type="secondary" data-testid="listings-detail-no-specs">
|
|
560
562
|
{t(LISTINGS_I18N_KEYS.detailNoSpecs)}
|
|
561
563
|
</Typography.Text>
|
|
562
564
|
) : split ? (
|
|
563
|
-
<
|
|
564
|
-
data-testid="listings-detail-specs-split"
|
|
565
|
-
style={{
|
|
566
|
-
display: "grid",
|
|
567
|
-
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
|
568
|
-
gap: spacing[4],
|
|
569
|
-
alignItems: "start",
|
|
570
|
-
}}
|
|
571
|
-
>
|
|
572
|
-
<FeatureValueList
|
|
573
|
-
features={bag.features
|
|
574
|
-
.slice(0, specHalf)
|
|
575
|
-
.map((view) => view.feature)}
|
|
576
|
-
values={specValues}
|
|
577
|
-
/>
|
|
578
|
-
{bag.features.length > specHalf ? (
|
|
579
|
-
<FeatureValueList
|
|
580
|
-
features={bag.features
|
|
581
|
-
.slice(specHalf)
|
|
582
|
-
.map((view) => view.feature)}
|
|
583
|
-
values={specValues}
|
|
584
|
-
/>
|
|
585
|
-
) : null}
|
|
586
|
-
</div>
|
|
565
|
+
<ListingSpecColumns features={specFeatures} values={specValues} />
|
|
587
566
|
) : (
|
|
588
|
-
<
|
|
589
|
-
features={bag.features.map((view) => view.feature)}
|
|
590
|
-
values={specValues}
|
|
591
|
-
/>
|
|
567
|
+
<ListingSpecList features={specFeatures} values={specValues} />
|
|
592
568
|
);
|
|
593
569
|
|
|
594
570
|
const specsSection = (
|
|
@@ -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
|