@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +73 -6
  3. package/dist/api/generated/schema.d.ts +119 -6
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/api/listingsApi.d.ts +25 -0
  6. package/dist/api/listingsApi.d.ts.map +1 -1
  7. package/dist/api/listingsApi.js +1 -0
  8. package/dist/api/listingsApi.js.map +1 -1
  9. package/dist/api/types.d.ts +46 -3
  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/FavoritesPane.d.ts +18 -2
  17. package/dist/default/FavoritesPane.d.ts.map +1 -1
  18. package/dist/default/FavoritesPane.js +3 -3
  19. package/dist/default/FavoritesPane.js.map +1 -1
  20. package/dist/default/ListingCard.d.ts.map +1 -1
  21. package/dist/default/ListingCard.js +6 -6
  22. package/dist/default/ListingCard.js.map +1 -1
  23. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  24. package/dist/default/ListingDetailPane.js +12 -19
  25. package/dist/default/ListingDetailPane.js.map +1 -1
  26. package/dist/default/ListingPhoto.d.ts.map +1 -1
  27. package/dist/default/ListingPhoto.js +12 -8
  28. package/dist/default/ListingPhoto.js.map +1 -1
  29. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  30. package/dist/default/ListingSerpCard.js +3 -5
  31. package/dist/default/ListingSerpCard.js.map +1 -1
  32. package/dist/default/ListingSpecList.d.ts +74 -0
  33. package/dist/default/ListingSpecList.d.ts.map +1 -0
  34. package/dist/default/ListingSpecList.js +90 -0
  35. package/dist/default/ListingSpecList.js.map +1 -0
  36. package/dist/default/MyListingsPane.d.ts +22 -2
  37. package/dist/default/MyListingsPane.d.ts.map +1 -1
  38. package/dist/default/MyListingsPane.js +1 -1
  39. package/dist/default/MyListingsPane.js.map +1 -1
  40. package/dist/default/cardGallery.d.ts +81 -0
  41. package/dist/default/cardGallery.d.ts.map +1 -0
  42. package/dist/default/cardGallery.js +266 -0
  43. package/dist/default/cardGallery.js.map +1 -0
  44. package/dist/default/index.d.ts +8 -2
  45. package/dist/default/index.d.ts.map +1 -1
  46. package/dist/default/index.js +5 -0
  47. package/dist/default/index.js.map +1 -1
  48. package/dist/headless/ListingComposer.d.ts +16 -10
  49. package/dist/headless/ListingComposer.d.ts.map +1 -1
  50. package/dist/headless/ListingComposer.js +23 -4
  51. package/dist/headless/ListingComposer.js.map +1 -1
  52. package/dist/i18n/es.d.ts.map +1 -1
  53. package/dist/i18n/es.js +1 -0
  54. package/dist/i18n/es.js.map +1 -1
  55. package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
  56. package/dist/i18n/generated/errors.es.gen.js +1 -1
  57. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  58. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  59. package/dist/i18n/generated/errors.gen.js +3 -0
  60. package/dist/i18n/generated/errors.gen.js.map +1 -1
  61. package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
  62. package/dist/i18n/generated/errors.ru.gen.js +1 -1
  63. package/dist/i18n/ru.d.ts.map +1 -1
  64. package/dist/i18n/ru.js +3 -0
  65. package/dist/i18n/ru.js.map +1 -1
  66. package/dist/index.d.ts +4 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/model/cardBadges.d.ts +134 -0
  71. package/dist/model/cardBadges.d.ts.map +1 -0
  72. package/dist/model/cardBadges.js +131 -0
  73. package/dist/model/cardBadges.js.map +1 -0
  74. package/dist/model/featureText.d.ts +63 -0
  75. package/dist/model/featureText.d.ts.map +1 -0
  76. package/dist/model/featureText.js +133 -0
  77. package/dist/model/featureText.js.map +1 -0
  78. package/dist/model/features.d.ts.map +1 -1
  79. package/dist/model/features.js +59 -0
  80. package/dist/model/features.js.map +1 -1
  81. package/dist/model/queries.d.ts +15 -1
  82. package/dist/model/queries.d.ts.map +1 -1
  83. package/dist/model/queries.js +20 -0
  84. package/dist/model/queries.js.map +1 -1
  85. package/dist/model/queryKeys.d.ts +4 -0
  86. package/dist/model/queryKeys.d.ts.map +1 -1
  87. package/dist/model/queryKeys.js +1 -0
  88. package/dist/model/queryKeys.js.map +1 -1
  89. package/llms.txt +6 -2
  90. package/manifest.json +57 -1
  91. package/nav-manifest.json +1 -1
  92. package/package.json +5 -5
  93. package/src/analytics/generated/events.json +1 -1
  94. package/src/api/generated/schema.ts +119 -6
  95. package/src/api/listingsApi.ts +30 -0
  96. package/src/api/types.ts +55 -5
  97. package/src/default/CardBadges.tsx +105 -0
  98. package/src/default/FavoritesPane.tsx +20 -5
  99. package/src/default/ListingCard.tsx +7 -21
  100. package/src/default/ListingDetailPane.tsx +13 -37
  101. package/src/default/ListingPhoto.tsx +54 -26
  102. package/src/default/ListingSerpCard.tsx +10 -28
  103. package/src/default/ListingSpecList.tsx +195 -0
  104. package/src/default/MyListingsPane.tsx +23 -5
  105. package/src/default/cardGallery.ts +287 -0
  106. package/src/default/index.ts +24 -1
  107. package/src/headless/ListingComposer.tsx +42 -17
  108. package/src/i18n/es.ts +3 -0
  109. package/src/i18n/generated/errors.es.gen.ts +1 -1
  110. package/src/i18n/generated/errors.gen.ts +3 -0
  111. package/src/i18n/generated/errors.json +9 -0
  112. package/src/i18n/generated/errors.ru.gen.ts +1 -1
  113. package/src/i18n/ru.ts +5 -0
  114. package/src/index.ts +16 -0
  115. package/src/model/cardBadges.ts +237 -0
  116. package/src/model/featureText.ts +199 -0
  117. package/src/model/features.ts +60 -0
  118. package/src/model/queries.ts +25 -0
  119. package/src/model/queryKeys.ts +5 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.22.1",
4
+ "version": "0.24.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -159,6 +159,50 @@ export interface paths {
159
159
  patch?: never;
160
160
  trace?: never;
161
161
  };
162
+ "/listings/api/v1/listings/{id}/draft/": {
163
+ parameters: {
164
+ query?: never;
165
+ header?: never;
166
+ path?: never;
167
+ cookie?: never;
168
+ };
169
+ /**
170
+ * @description Read back the draft twins — the reopen half of ``save-draft``.
171
+ *
172
+ * Every user-editable field on this module is a ``*_draft`` column
173
+ * (``title_draft``, ``description_draft``, ``images_draft``,
174
+ * ``features_draft``, the location trio, ...) and until now the only
175
+ * call that ever returned one was the write itself: ``create``,
176
+ * ``update`` and ``save-draft`` echo the bag back, and no READ did. So a
177
+ * composer reopening a listing by id had exactly one source, the detail
178
+ * read, which serves the PUBLISHED fields — empty on everything that has
179
+ * never been published. The seller pressed «продолжить», got a blank
180
+ * form, and their text and photos were sitting in the row the whole
181
+ * time.
182
+ *
183
+ * Deliberately a separate route rather than draft columns bolted onto
184
+ * the detail read: the detail read is the PUBLIC one (a stranger and a
185
+ * crawler read it without a session), and a shape whose key set depends
186
+ * on who is asking is the shape a redaction bug hides in. Here the
187
+ * answer is structural — ``_get_own`` is the module's one ownership
188
+ * gate, 403 for someone else's listing, 404 for one that is absent or
189
+ * soft-deleted — so no draft field can reach a non-owner by omission.
190
+ *
191
+ * The payload is ``ListingDraftSerializer``, byte for byte what
192
+ * ``save-draft`` returns, so a client reopens with the mapper it already
193
+ * has for the write's response instead of a second one.
194
+ *
195
+ * **Permissions:** `IsAuthenticated`
196
+ */
197
+ get: operations["listings_api_v1_listings_draft_retrieve"];
198
+ put?: never;
199
+ post?: never;
200
+ delete?: never;
201
+ options?: never;
202
+ head?: never;
203
+ patch?: never;
204
+ trace?: never;
205
+ };
162
206
  "/listings/api/v1/listings/{id}/favorite/": {
163
207
  parameters: {
164
208
  query?: never;
@@ -285,6 +329,15 @@ export interface paths {
285
329
  * and the set the server accepts are one object, not two that agree
286
330
  * today.
287
331
  *
332
+ * One edge is not a lifecycle hop: ARCHIVED -> PUBLISHED is
333
+ * «опубликовать снова», and it runs the publish service (validate,
334
+ * promote, re-submit for review — see ``RESTORE_EDGES``). So it can
335
+ * answer 400 when the draft is not publishable, and the ``status`` it
336
+ * returns is where the deployment's moderation policy put the listing,
337
+ * which under the default pre-moderation gate is ``pending`` and not the
338
+ * ``published`` that was asked for. Read the status from the response,
339
+ * never from the request.
340
+ *
288
341
  * **Permissions:** `IsAuthenticated`
289
342
  */
290
343
  post: operations["listings_api_v1_listings_transition_create"];
@@ -815,7 +868,13 @@ export interface components {
815
868
  price_base?: string | null;
816
869
  currency?: string;
817
870
  readonly images: string[] | null;
818
- readonly features_title: (components["schemas"]["FeatureDao"] | {
871
+ readonly features_title: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
872
+ label: string;
873
+ unit?: string;
874
+ name: string;
875
+ /** @enum {unknown} */
876
+ presentation: "value" | "value_unit" | "name_value" | "name";
877
+ }) | {
819
878
  slug?: string;
820
879
  type?: string;
821
880
  name?: string | null;
@@ -830,7 +889,13 @@ export interface components {
830
889
  redacted: true;
831
890
  present: boolean;
832
891
  })[];
833
- readonly features_badges: (components["schemas"]["FeatureDao"] | {
892
+ readonly features_badges: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
893
+ label: string;
894
+ unit?: string;
895
+ name: string;
896
+ /** @enum {unknown} */
897
+ presentation: "value" | "value_unit" | "name_value" | "name";
898
+ }) | {
834
899
  slug?: string;
835
900
  type?: string;
836
901
  name?: string | null;
@@ -906,7 +971,13 @@ export interface components {
906
971
  redacted: true;
907
972
  present: boolean;
908
973
  })[];
909
- readonly features_title: (components["schemas"]["FeatureDao"] | {
974
+ readonly features_title: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
975
+ label: string;
976
+ unit?: string;
977
+ name: string;
978
+ /** @enum {unknown} */
979
+ presentation: "value" | "value_unit" | "name_value" | "name";
980
+ }) | {
910
981
  slug?: string;
911
982
  type?: string;
912
983
  name?: string | null;
@@ -921,7 +992,13 @@ export interface components {
921
992
  redacted: true;
922
993
  present: boolean;
923
994
  })[];
924
- readonly features_badges: (components["schemas"]["FeatureDao"] | {
995
+ readonly features_badges: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
996
+ label: string;
997
+ unit?: string;
998
+ name: string;
999
+ /** @enum {unknown} */
1000
+ presentation: "value" | "value_unit" | "name_value" | "name";
1001
+ }) | {
925
1002
  slug?: string;
926
1003
  type?: string;
927
1004
  name?: string | null;
@@ -982,6 +1059,7 @@ export interface components {
982
1059
  features_draft?: {
983
1060
  [key: string]: components["schemas"]["FeatureDto"];
984
1061
  } | null;
1062
+ draft_meta?: unknown;
985
1063
  auto_republish?: boolean;
986
1064
  countable?: boolean;
987
1065
  /** Format: int64 */
@@ -1085,7 +1163,13 @@ export interface components {
1085
1163
  price_base?: string | null;
1086
1164
  currency?: string;
1087
1165
  readonly images: string[] | null;
1088
- readonly features_title: (components["schemas"]["FeatureDao"] | {
1166
+ readonly features_title: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
1167
+ label: string;
1168
+ unit?: string;
1169
+ name: string;
1170
+ /** @enum {unknown} */
1171
+ presentation: "value" | "value_unit" | "name_value" | "name";
1172
+ }) | {
1089
1173
  slug?: string;
1090
1174
  type?: string;
1091
1175
  name?: string | null;
@@ -1100,7 +1184,13 @@ export interface components {
1100
1184
  redacted: true;
1101
1185
  present: boolean;
1102
1186
  })[];
1103
- readonly features_badges: (components["schemas"]["FeatureDao"] | {
1187
+ readonly features_badges: ((Omit<components["schemas"]["FeatureDao"], "type"> & {
1188
+ label: string;
1189
+ unit?: string;
1190
+ name: string;
1191
+ /** @enum {unknown} */
1192
+ presentation: "value" | "value_unit" | "name_value" | "name";
1193
+ }) | {
1104
1194
  slug?: string;
1105
1195
  type?: string;
1106
1196
  name?: string | null;
@@ -1199,6 +1289,7 @@ export interface components {
1199
1289
  features_draft?: {
1200
1290
  [key: string]: components["schemas"]["FeatureDto"];
1201
1291
  } | null;
1292
+ draft_meta?: unknown;
1202
1293
  auto_republish?: boolean;
1203
1294
  countable?: boolean;
1204
1295
  /** Format: int64 */
@@ -1648,6 +1739,28 @@ export interface operations {
1648
1739
  };
1649
1740
  };
1650
1741
  };
1742
+ listings_api_v1_listings_draft_retrieve: {
1743
+ parameters: {
1744
+ query?: never;
1745
+ header?: never;
1746
+ path: {
1747
+ /** @description A unique integer value identifying this listing. */
1748
+ id: number;
1749
+ };
1750
+ cookie?: never;
1751
+ };
1752
+ requestBody?: never;
1753
+ responses: {
1754
+ 200: {
1755
+ headers: {
1756
+ [name: string]: unknown;
1757
+ };
1758
+ content: {
1759
+ "application/json": components["schemas"]["ListingDraft"];
1760
+ };
1761
+ };
1762
+ };
1763
+ };
1651
1764
  listings_api_v1_listings_favorite_create: {
1652
1765
  parameters: {
1653
1766
  query?: never;
@@ -169,6 +169,33 @@ export interface ListingsApi {
169
169
  */
170
170
  saveDraft(id: number, body: ListingDraftPatch): Promise<ListingDraft>;
171
171
 
172
+ /**
173
+ * The draft twin, read back — `GET {id}/draft/` (stapel-listings 0.21.1),
174
+ * owner-only. Answers the exact `ListingDraftSerializer` shape `save-draft`
175
+ * does, which is what finally lets a reopened listing seed from what was
176
+ * actually typed rather than from the published half (see
177
+ * `headless/ListingComposer.tsx`'s header on why that used to be
178
+ * impossible).
179
+ *
180
+ * A stranger's id 403s; an absent one 404s — same as every other owner
181
+ * read here. A build against an OLDER backend also 404s (the route did not
182
+ * exist yet), and a caller cannot tell the two apart from the response
183
+ * alone; `useListingComposer` treats either as "fall back to the detail
184
+ * seed" rather than surfacing a hard failure, because a reopened draft is
185
+ * strictly better served by the published half than by an error banner.
186
+ *
187
+ * Hand-authored: `stapel-listings` 0.21.1 has not been regenerated into
188
+ * this pair's pinned schema yet, so there is no `Schemas["..."]` for this
189
+ * operation to bind to. The response shape is declared by hand against
190
+ * {@link ListingDraft} — the same type `save-draft` already returns — the
191
+ * way `@stapel/categories-react` hand-declares `children_as` ahead of its
192
+ * own pin.
193
+ */
194
+ draft(
195
+ id: number,
196
+ options?: { readonly signal?: AbortSignal }
197
+ ): Promise<ListingDraft>;
198
+
172
199
  /**
173
200
  * Ask what publishing WOULD say, without publishing. Same validator as
174
201
  * `publish` (`services.publish.validate_draft`), so the two cannot disagree
@@ -321,6 +348,9 @@ export function createListingsApi(client: StapelClient): ListingsApi {
321
348
 
322
349
  saveDraft: (id, body) => client.post(`${listingPath(id)}save-draft/`, body),
323
350
 
351
+ draft: (id, options) =>
352
+ client.get(`${listingPath(id)}draft/`, signal(options)),
353
+
324
354
  validateDraft: (id, options) =>
325
355
  client.get(`${listingPath(id)}validate-draft/`, signal(options)),
326
356
 
package/src/api/types.ts CHANGED
@@ -123,8 +123,58 @@ 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
+ * 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
+
126
174
  /** `GET /listings/{pk}/` 200 — everything a detail page reads. */
127
- export type ListingDetail = WithOptionalEngagement<Schemas["ListingDetail"]>;
175
+ export type ListingDetail = WithStoredFeatures<
176
+ WithOptionalEngagement<Schemas["ListingDetail"]>
177
+ >;
128
178
 
129
179
  /**
130
180
  * One row of a card list (`GET /listings/`, `GET /listings/my/favorites/`) —
@@ -158,8 +208,8 @@ type WithOptionalPrecision<Row extends { geo_precision_km?: number }> = Omit<
158
208
  > &
159
209
  Partial<Pick<Schemas["ListingCard"], "geo_precision_km">>;
160
210
 
161
- export type ListingCard = WithOptionalPrecision<
162
- WithOptionalEngagement<Schemas["ListingCard"]>
211
+ export type ListingCard = WithStoredFeatures<
212
+ WithOptionalPrecision<WithOptionalEngagement<Schemas["ListingCard"]>>
163
213
  >;
164
214
 
165
215
  /** `POST /listings/` request+response and `POST /{pk}/save-draft/` response —
@@ -216,8 +266,8 @@ export type PaginatedListingCards = Omit<
216
266
  * tab keyed off them is a column of blank rows. `myListingTitle` /
217
267
  * `myListingPrice` (`model/mine.ts`) are the one place the fallback lives.
218
268
  */
219
- export type MyListingCard = WithOptionalPrecision<
220
- WithOptionalEngagement<Schemas["MyListingCard"]>
269
+ export type MyListingCard = WithStoredFeatures<
270
+ WithOptionalPrecision<WithOptionalEngagement<Schemas["MyListingCard"]>>
221
271
  >;
222
272
 
223
273
  /** 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
+ }
@@ -43,6 +43,16 @@ import { ListingCard } from "./ListingCard.js";
43
43
  import type { ListingCardOpenProps } from "./ListingCard.js";
44
44
  import { SignInLink } from "./SignInLink.js";
45
45
  import type { ThemeModeProp } from "./types.js";
46
+ import type { ListingCard as ListingCardRow } from "../api/types.js";
47
+
48
+ /**
49
+ * The row `hrefFor` is called with as its second argument — the same
50
+ * {@link ListingCardRow} the grid renders each card from. `title` is
51
+ * guaranteed present here: a favourite is always a PUBLISHED listing (a
52
+ * draft has nothing to favourite), so the public card's `title` is never the
53
+ * blank the wire allows for an unpublished one.
54
+ */
55
+ export type FavoritesHrefRow = ListingCardRow & { title: string };
46
56
 
47
57
  /** The narrowest a card may get before the grid drops a column — the same
48
58
  * element-relative rule the detail gallery uses. It was `width: 240`, which
@@ -59,8 +69,13 @@ export const FAVORITES_MEASURE = "72rem";
59
69
  * card no longer allows. */
60
70
  export type FavoritesPaneOpenProps =
61
71
  | {
62
- /** Where a card leads. The container owns routing. */
63
- readonly hrefFor: (id: number) => string;
72
+ /**
73
+ * Where a card leads. The container owns routing. Called with the
74
+ * row's id and the row itself ({@link FavoritesHrefRow}, `title`
75
+ * guaranteed present) — a container that only needs the id keeps
76
+ * working unchanged.
77
+ */
78
+ readonly hrefFor: (id: number, row: FavoritesHrefRow) => string;
64
79
  /** The host's `<Link>`, so a click stays inside the SPA. */
65
80
  readonly linkComponent?: LinkComponent;
66
81
  readonly onOpen?: undefined;
@@ -95,10 +110,10 @@ export type FavoritesPaneProps = ThemeModeProp &
95
110
  /** The card's own open props for one row. One arm, never two. */
96
111
  function cardOpenProps(
97
112
  props: FavoritesPaneOpenProps,
98
- id: number
113
+ row: FavoritesHrefRow
99
114
  ): ListingCardOpenProps {
100
115
  if (props.hrefFor !== undefined) {
101
- const href = props.hrefFor(id);
116
+ const href = props.hrefFor(row.id, row);
102
117
  return props.linkComponent !== undefined
103
118
  ? { href, linkComponent: props.linkComponent }
104
119
  : { href };
@@ -180,7 +195,7 @@ export function FavoritesPane(props: FavoritesPaneProps): ReactElement {
180
195
  listing={row}
181
196
  blockedReason="line"
182
197
  {...(props.signIn !== undefined ? { signIn: props.signIn } : {})}
183
- {...cardOpenProps(props, row.id)}
198
+ {...cardOpenProps(props, row as FavoritesHrefRow)}
184
199
  />
185
200
  ))}
186
201
  </div>
@@ -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 = (