@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/MODULE.md +4 -0
  3. package/README.md +75 -0
  4. package/dist/api/generated/schema.d.ts +126 -11
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/listingsApi.d.ts +5 -3
  7. package/dist/api/listingsApi.d.ts.map +1 -1
  8. package/dist/api/listingsApi.js.map +1 -1
  9. package/dist/api/types.d.ts +51 -4
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/CardBadges.d.ts +47 -0
  13. package/dist/default/CardBadges.d.ts.map +1 -0
  14. package/dist/default/CardBadges.js +38 -0
  15. package/dist/default/CardBadges.js.map +1 -0
  16. package/dist/default/ListingCard.d.ts.map +1 -1
  17. package/dist/default/ListingCard.js +6 -6
  18. package/dist/default/ListingCard.js.map +1 -1
  19. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  20. package/dist/default/ListingDetailPane.js +12 -19
  21. package/dist/default/ListingDetailPane.js.map +1 -1
  22. package/dist/default/ListingPhoto.d.ts.map +1 -1
  23. package/dist/default/ListingPhoto.js +12 -8
  24. package/dist/default/ListingPhoto.js.map +1 -1
  25. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  26. package/dist/default/ListingSerpCard.js +3 -5
  27. package/dist/default/ListingSerpCard.js.map +1 -1
  28. package/dist/default/ListingSpecList.d.ts +74 -0
  29. package/dist/default/ListingSpecList.d.ts.map +1 -0
  30. package/dist/default/ListingSpecList.js +90 -0
  31. package/dist/default/ListingSpecList.js.map +1 -0
  32. package/dist/default/cardGallery.d.ts +81 -0
  33. package/dist/default/cardGallery.d.ts.map +1 -0
  34. package/dist/default/cardGallery.js +266 -0
  35. package/dist/default/cardGallery.js.map +1 -0
  36. package/dist/default/index.d.ts +6 -0
  37. package/dist/default/index.d.ts.map +1 -1
  38. package/dist/default/index.js +5 -0
  39. package/dist/default/index.js.map +1 -1
  40. package/dist/headless/ListingComposer.d.ts +20 -2
  41. package/dist/headless/ListingComposer.d.ts.map +1 -1
  42. package/dist/headless/ListingComposer.js +19 -4
  43. package/dist/headless/ListingComposer.js.map +1 -1
  44. package/dist/i18n/es.d.ts.map +1 -1
  45. package/dist/i18n/es.js +1 -0
  46. package/dist/i18n/es.js.map +1 -1
  47. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  48. package/dist/i18n/generated/errors.es.gen.js +1 -1
  49. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  50. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  51. package/dist/i18n/generated/errors.gen.js +3 -0
  52. package/dist/i18n/generated/errors.gen.js.map +1 -1
  53. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  54. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  55. package/dist/i18n/ru.d.ts.map +1 -1
  56. package/dist/i18n/ru.js +3 -0
  57. package/dist/i18n/ru.js.map +1 -1
  58. package/dist/index.d.ts +3 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/model/cardBadges.d.ts +134 -0
  63. package/dist/model/cardBadges.d.ts.map +1 -0
  64. package/dist/model/cardBadges.js +131 -0
  65. package/dist/model/cardBadges.js.map +1 -0
  66. package/dist/model/draft.d.ts +10 -2
  67. package/dist/model/draft.d.ts.map +1 -1
  68. package/dist/model/draft.js +22 -4
  69. package/dist/model/draft.js.map +1 -1
  70. package/dist/model/featureText.d.ts +85 -0
  71. package/dist/model/featureText.d.ts.map +1 -0
  72. package/dist/model/featureText.js +153 -0
  73. package/dist/model/featureText.js.map +1 -0
  74. package/dist/model/features.d.ts.map +1 -1
  75. package/dist/model/features.js +59 -0
  76. package/dist/model/features.js.map +1 -1
  77. package/llms.txt +5 -2
  78. package/manifest.json +47 -1
  79. package/nav-manifest.json +1 -1
  80. package/package.json +10 -10
  81. package/src/analytics/generated/events.json +1 -1
  82. package/src/api/generated/schema.ts +126 -11
  83. package/src/api/listingsApi.ts +5 -3
  84. package/src/api/types.ts +60 -6
  85. package/src/default/CardBadges.tsx +105 -0
  86. package/src/default/ListingCard.tsx +7 -21
  87. package/src/default/ListingDetailPane.tsx +13 -37
  88. package/src/default/ListingPhoto.tsx +54 -26
  89. package/src/default/ListingSerpCard.tsx +10 -28
  90. package/src/default/ListingSpecList.tsx +195 -0
  91. package/src/default/cardGallery.ts +287 -0
  92. package/src/default/index.ts +22 -0
  93. package/src/headless/ListingComposer.tsx +39 -5
  94. package/src/i18n/es.ts +3 -0
  95. package/src/i18n/generated/errors.es.gen.ts +1 -1
  96. package/src/i18n/generated/errors.gen.ts +3 -0
  97. package/src/i18n/generated/errors.json +9 -0
  98. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  99. package/src/i18n/ru.ts +5 -0
  100. package/src/index.ts +15 -0
  101. package/src/model/cardBadges.ts +237 -0
  102. package/src/model/draft.ts +23 -5
  103. package/src/model/featureText.ts +251 -0
  104. 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
- /** `GET /listings/{pk}/` 200 — everything a detail page reads. */
127
- export type ListingDetail = WithOptionalEngagement<Schemas["ListingDetail"]>;
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 = WithOptionalPrecision<
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 = WithOptionalPrecision<
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, featuresDtoFromDaoList, featuresFromDaoList } from "../model/features.js";
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.length > 0 ? (
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
- {badgeFeatures.length > 0 ? (
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 { FeatureValueList } from "@stapel/attributes-react/default";
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
- formatFeatureValue(view.feature, view.value, { t, locale })
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, split by ROW COUNT and not by
552
- `<FeatureValueList>` the halves are cut HERE so the category's
553
- declaration order survives: the first (larger) half fills the
554
- left list, and the table reads top-to-bottom, left column
555
- first, exactly as the one-column table read. */
556
- const specHalf = Math.ceil(bag.features.length / 2);
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
- <div
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
- <FeatureValueList
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
- <SkinCarousel
256
- label={t(LISTINGS_I18N_KEYS.cardPhotos)}
257
- aspectRatio={LISTING_PHOTO_ASPECT}
258
- peek={many}
259
- dots={many}
260
- data-testid={props.testId}
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
- {images.length === 0
263
- ? slide(<ListingPhoto imageRef={undefined} alt={title} />, "empty")
264
- : images.map((reference, index) =>
265
- slide(
266
- <ListingPhoto
267
- key={reference}
268
- imageRef={reference}
269
- alt={
270
- many
271
- ? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
272
- index: index + 1,
273
- total: images.length,
274
- })
275
- : title
276
- }
277
- />,
278
- reference
279
- )
280
- )}
281
- </SkinCarousel>
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
- ) : titleDaos.length > 0 ? (
350
- <Typography.Text
351
- type="secondary"
352
- ellipsis
353
- data-testid="listings-serp-specs"
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
- ) : null}
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