@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
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The card gallery's two gestures — the ones a classified is expected to have
3
+ * and this pair did not.
4
+ *
5
+ * ── Hover scrub, on a device that has a pointer ──────────────────────────
6
+ *
7
+ * A card with six photographs showed one, and the only way to the other five
8
+ * was to open the listing. Every mature classified answers this the same way:
9
+ * the media box is divided into N equal segments, and the segment the cursor
10
+ * is over IS the photograph on screen. Six photos become six glances and no
11
+ * clicks, and the pointer leaving puts the first photo back — the card must
12
+ * be in the same state after a hover as before one, or a grid of forty tiles
13
+ * becomes forty different tiles depending on where a cursor happened to pass.
14
+ *
15
+ * It is gated on `(hover: hover) and (pointer: fine)` and on a `mouse`
16
+ * pointer type, both, and the two gates are not redundant: the media query
17
+ * says the DEVICE has a real pointer, the pointer type says THIS gesture came
18
+ * from it. A touch laptop answers `hover: hover` and still delivers finger
19
+ * events, and a scrub driven by a finger dragging across the box is the
20
+ * gesture below wearing the wrong costume.
21
+ *
22
+ * ── Swipe, on a device that does not ─────────────────────────────────────
23
+ *
24
+ * A finger gets the same six photographs by swiping horizontally, one photo
25
+ * per swipe, in either direction.
26
+ *
27
+ * The rule that matters is the one about the OTHER axis: a card is a small
28
+ * box inside a long scrolling feed, and a gesture layer that treats every
29
+ * touch as its own turns the page into something that will not scroll. So
30
+ * the strip declares `touch-action: pan-y` — the browser keeps the vertical
31
+ * axis, unconditionally, and can never be argued out of it — and this hook
32
+ * only acts on a drag that has declared horizontal INTENT: past
33
+ * {@link SWIPE_MIN_PX}, and further across than down by
34
+ * {@link SWIPE_AXIS_RATIO}. A diagonal thumb scrolling the feed changes no
35
+ * photograph.
36
+ *
37
+ * ── What neither gesture touches ─────────────────────────────────────────
38
+ *
39
+ * The keyboard, and the card's single link target. The strip underneath is
40
+ * still `<SkinCarousel>`: a real scroll container, a tab stop, arrow-key
41
+ * scrollable, with the slides in the document and its own dots reporting the
42
+ * position. Both gestures below work by SCROLLING it — they set no state the
43
+ * strip does not already publish — so what a keyboard reaches, what a screen
44
+ * reader reads and what the dots say are unchanged, and the card stays one
45
+ * anchor with one accessible name.
46
+ */
47
+ import { useCallback, useEffect, useRef, useState } from "react";
48
+ import type { PointerEvent as ReactPointerEvent, RefObject } from "react";
49
+
50
+ /** The class the gallery's own box carries — see {@link cardGalleryCss}. */
51
+ export const CARD_GALLERY_CLASS = "stapel-listing-gallery";
52
+ /** The `href` the hoisted gallery stylesheet is deduplicated by. */
53
+ export const CARD_GALLERY_STYLE_HREF = "stapel-listings-card-gallery";
54
+
55
+ /** The environment a hover scrub is allowed in, and the only one. */
56
+ export const SCRUB_MEDIA = "(hover: hover) and (pointer: fine)";
57
+
58
+ /**
59
+ * How far a finger travels before it has said "photo", in CSS pixels.
60
+ *
61
+ * Under this, a drag is a tap that wobbled — and a tap on a card is a
62
+ * navigation, so a low threshold does not change a photograph, it changes one
63
+ * and then leaves the listing.
64
+ */
65
+ export const SWIPE_MIN_PX = 32;
66
+
67
+ /**
68
+ * How much more horizontal than vertical a drag must be to count.
69
+ *
70
+ * 1.2 rather than 1: a thumb scrolling a feed travels a few degrees off
71
+ * vertical, and a bare `|dx| > |dy|` comparison hands the gesture to the
72
+ * gallery on the first pixel where the wobble wins.
73
+ */
74
+ export const SWIPE_AXIS_RATIO = 1.2;
75
+
76
+ /**
77
+ * Which of `count` equal segments the pointer is over.
78
+ *
79
+ * Clamped at both ends: `offsetX` can be reported one pixel past the box's
80
+ * own width, and an index of `count` is a photograph that does not exist.
81
+ */
82
+ export function segmentIndex(offsetX: number, width: number, count: number): number {
83
+ if (count <= 1 || width <= 0) return 0;
84
+ const raw = Math.floor((offsetX / width) * count);
85
+ return Math.min(count - 1, Math.max(0, raw));
86
+ }
87
+
88
+ /**
89
+ * A drag → the number of photographs it asks for: `1` forward, `-1` back, `0`
90
+ * for a drag that has not declared horizontal intent.
91
+ *
92
+ * Dragging LEFT advances, the direction the content moves under the finger —
93
+ * the same mapping the native scroller has.
94
+ */
95
+ export function swipeStep(dx: number, dy: number): -1 | 0 | 1 {
96
+ const across = Math.abs(dx);
97
+ if (across < SWIPE_MIN_PX) return 0;
98
+ if (across <= Math.abs(dy) * SWIPE_AXIS_RATIO) return 0;
99
+ return dx < 0 ? 1 : -1;
100
+ }
101
+
102
+ /** Does this environment have a real pointer? `false` where there is no
103
+ * `matchMedia` to ask (a server render, an old jsdom), which is the safe
104
+ * side: a scrub that does not happen costs a hover, a scrub on a phone is a
105
+ * photograph that changes when nobody touched it. */
106
+ export function hasFinePointer(): boolean {
107
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
108
+ try {
109
+ return window.matchMedia(SCRUB_MEDIA).matches;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * {@link hasFinePointer}, as state.
117
+ *
118
+ * It opens `false` and settles in an effect rather than reading the media
119
+ * query during render: a server render has no `matchMedia`, and a first
120
+ * client render that disagreed with it is a hydration mismatch on every card
121
+ * on the page. Nothing is drawn differently either way — the flag only gates
122
+ * a gesture — so the one frame it costs is invisible.
123
+ */
124
+ export function useFinePointer(): boolean {
125
+ const [fine, setFine] = useState(false);
126
+ useEffect(() => {
127
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
128
+ let query: MediaQueryList;
129
+ try {
130
+ query = window.matchMedia(SCRUB_MEDIA);
131
+ } catch {
132
+ return;
133
+ }
134
+ setFine(query.matches);
135
+ const onChange = (event: MediaQueryListEvent): void => {
136
+ setFine(event.matches);
137
+ };
138
+ // `addEventListener` is the modern spelling; Safari < 14 has only
139
+ // `addListener`, and a card grid that throws on mount there is worse than
140
+ // one that never notices a device changing its pointer.
141
+ if (typeof query.addEventListener === "function") {
142
+ query.addEventListener("change", onChange);
143
+ return () => {
144
+ query.removeEventListener("change", onChange);
145
+ };
146
+ }
147
+ return;
148
+ }, []);
149
+ return fine;
150
+ }
151
+
152
+ /** The handlers and the box reference a gallery hands its media well. */
153
+ export interface CardGallery {
154
+ readonly ref: RefObject<HTMLDivElement | null>;
155
+ /** The photograph currently on screen. */
156
+ readonly active: number;
157
+ /** True while a pointer is scrubbing — the box publishes it so the strip's
158
+ * smooth-scroll can be switched off for the duration. */
159
+ readonly scrubbing: boolean;
160
+ readonly onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
161
+ readonly onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
162
+ readonly onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
163
+ readonly onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
164
+ readonly onPointerLeave: (event: ReactPointerEvent<HTMLDivElement>) => void;
165
+ }
166
+
167
+ /** Scroll the strip inside `box` so that slide `index` is at its leading
168
+ * edge. Rectangles rather than `scrollLeft / slideWidth`, for the reason
169
+ * `SkinCarousel`'s own `nearestSlideIndex` gives: the arithmetic version has
170
+ * to know the gap, the peek and the writing direction. */
171
+ function showSlide(box: HTMLElement, index: number, instant: boolean): void {
172
+ const strip = box.querySelector<HTMLElement>("[data-stapel-carousel-strip]");
173
+ if (strip === null) return;
174
+ const slide = strip.children.item(index);
175
+ if (slide === null) return;
176
+ const left =
177
+ slide.getBoundingClientRect().left - strip.getBoundingClientRect().left + strip.scrollLeft;
178
+ if (typeof strip.scrollTo === "function") {
179
+ strip.scrollTo({ left, behavior: instant ? "auto" : "smooth" });
180
+ } else {
181
+ strip.scrollLeft = left;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The gallery gestures for a media well holding `count` photographs.
187
+ *
188
+ * A well with one photograph gets an inert bag: every handler returns
189
+ * immediately, so a grid of forty single-photo tiles pays for nothing.
190
+ */
191
+ export function useCardGallery(count: number): CardGallery {
192
+ const ref = useRef<HTMLDivElement | null>(null);
193
+ const [active, setActive] = useState(0);
194
+ const [scrubbing, setScrubbing] = useState(false);
195
+ // The origin of the drag in progress, or `null`. A ref rather than state:
196
+ // it changes on every move and no render depends on it.
197
+ const origin = useRef<{ x: number; y: number } | null>(null);
198
+ const fine = useFinePointer();
199
+ const many = count > 1;
200
+
201
+ // The one place the strip is driven. `active` is the whole state of both
202
+ // gestures, so neither handler talks to the DOM.
203
+ useEffect(() => {
204
+ const box = ref.current;
205
+ if (box === null || !many) return;
206
+ showSlide(box, active, scrubbing);
207
+ }, [active, scrubbing, many]);
208
+
209
+ const onPointerMove = useCallback(
210
+ (event: ReactPointerEvent<HTMLDivElement>): void => {
211
+ if (!many) return;
212
+ if (event.pointerType === "mouse") {
213
+ if (!fine) return;
214
+ const box = ref.current;
215
+ if (box === null) return;
216
+ const rect = box.getBoundingClientRect();
217
+ setScrubbing(true);
218
+ setActive(segmentIndex(event.clientX - rect.left, rect.width, count));
219
+ return;
220
+ }
221
+ const from = origin.current;
222
+ if (from === null) return;
223
+ const step = swipeStep(event.clientX - from.x, event.clientY - from.y);
224
+ if (step === 0) return;
225
+ // The origin moves with the commit, so a long drag walks the strip one
226
+ // photograph per threshold rather than one per gesture.
227
+ origin.current = { x: event.clientX, y: event.clientY };
228
+ setScrubbing(false);
229
+ setActive((current) => Math.min(count - 1, Math.max(0, current + step)));
230
+ },
231
+ [count, fine, many]
232
+ );
233
+
234
+ const onPointerDown = useCallback(
235
+ (event: ReactPointerEvent<HTMLDivElement>): void => {
236
+ if (!many || event.pointerType === "mouse") return;
237
+ origin.current = { x: event.clientX, y: event.clientY };
238
+ },
239
+ [many]
240
+ );
241
+
242
+ const endDrag = useCallback((): void => {
243
+ origin.current = null;
244
+ }, []);
245
+
246
+ const onPointerLeave = useCallback((): void => {
247
+ origin.current = null;
248
+ if (!many) return;
249
+ // The card goes back to the photograph it was drawn with. A hover is a
250
+ // look, not an edit.
251
+ setScrubbing(false);
252
+ setActive(0);
253
+ }, [many]);
254
+
255
+ return {
256
+ ref,
257
+ active,
258
+ scrubbing,
259
+ onPointerMove,
260
+ onPointerDown,
261
+ onPointerUp: endDrag,
262
+ onPointerCancel: endDrag,
263
+ onPointerLeave,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * The rules an inline style cannot reach: they apply to the STRIP inside the
269
+ * carousel, which this package renders through a component and does not hold
270
+ * a handle on.
271
+ */
272
+ export function cardGalleryCss(): string {
273
+ const box = `.${CARD_GALLERY_CLASS}`;
274
+ return [
275
+ // THE VERTICAL AXIS IS THE BROWSER'S, and is not negotiable: a card is a
276
+ // small box in a long feed, and the one unacceptable outcome of a gallery
277
+ // gesture is a page that will not scroll. `pan-y` says so at the platform
278
+ // level, where no handler can argue with it, and takes the horizontal
279
+ // axis for the swipe above.
280
+ `${box} [data-stapel-carousel-strip]{touch-action:pan-y}`,
281
+ // A per-pixel scrub must not animate: the strip's own `scroll-behavior:
282
+ // smooth` is right for a swipe committing one photograph and turns a
283
+ // cursor sweep into a queue of easing curves finishing after the pointer
284
+ // has left.
285
+ `${box}[data-scrubbing="true"] [data-stapel-carousel-strip]{scroll-behavior:auto}`,
286
+ ].join("");
287
+ }
@@ -88,11 +88,12 @@ export type {
88
88
  ComposerLocationPickerProps,
89
89
  } from "./ListingComposerPage.js";
90
90
  export { MyListingsPane } from "./MyListingsPane.js";
91
- export type { MyListingsPaneProps } from "./MyListingsPane.js";
91
+ export type { MyListingsPaneProps, MyListingHrefRow } from "./MyListingsPane.js";
92
92
  export { FavoritesPane, FAVORITES_CARD_MIN } from "./FavoritesPane.js";
93
93
  export type {
94
94
  FavoritesPaneProps,
95
95
  FavoritesPaneOpenProps,
96
+ FavoritesHrefRow,
96
97
  } from "./FavoritesPane.js";
97
98
 
98
99
  export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
@@ -103,6 +104,28 @@ export {
103
104
  LISTING_PHOTO_ASPECT,
104
105
  } from "./ListingPhoto.js";
105
106
  export type { ListingPhotoProps } from "./ListingPhoto.js";
107
+
108
+ // ── the listing page's characteristics, and a card's badge line ──────────────
109
+ export {
110
+ ListingSpecColumns,
111
+ ListingSpecList,
112
+ specListCss,
113
+ } from "./ListingSpecList.js";
114
+ export type { ListingSpecListProps } from "./ListingSpecList.js";
115
+ export { CardBadges, CardSpecLine } from "./CardBadges.js";
116
+ export type { CardBadgesProps } from "./CardBadges.js";
117
+
118
+ // ── the card gallery's two gestures ─────────────────────────────────────────
119
+ export {
120
+ SWIPE_AXIS_RATIO,
121
+ SWIPE_MIN_PX,
122
+ cardGalleryCss,
123
+ hasFinePointer,
124
+ segmentIndex,
125
+ swipeStep,
126
+ useCardGallery,
127
+ } from "./cardGallery.js";
128
+ export type { CardGallery } from "./cardGallery.js";
106
129
  export { SignInLink } from "./SignInLink.js";
107
130
  export type { SignInLinkProps } from "./SignInLink.js";
108
131
  export type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
@@ -22,7 +22,7 @@ import type {
22
22
  PublishResponse,
23
23
  } from "../api/types.js";
24
24
  import { useListingsRuntime } from "../model/context.js";
25
- import { useListing } from "../model/queries.js";
25
+ import { useListing, useListingDraft } from "../model/queries.js";
26
26
  import {
27
27
  useCreateDraft,
28
28
  usePublishListing,
@@ -32,6 +32,7 @@ import {
32
32
  createDraftBody,
33
33
  draftPatchFromValues,
34
34
  draftValuesFromDetail,
35
+ draftValuesFromWire,
35
36
  droppedFeatureSlugs,
36
37
  emptyDraftValues,
37
38
  retainKnownFeatureValues,
@@ -82,17 +83,23 @@ import { useMandateGate } from "./useMandateGate.js";
82
83
  * the headless half must judge renderability without importing a skin, so
83
84
  * the skin's `BUILTIN_VALUE_EDITOR_TYPES` is passed IN.
84
85
  *
85
- * ── Reopening a draft is NOT possible on stapel-listings 0.6.0 ─────────────
86
+ * ── Reopening a draft, before and after stapel-listings 0.21.1 ─────────────
86
87
  *
87
- * No read returns the `*_draft` twin: `GET /{pk}/` serializes the PUBLISHED
88
- * fields (`ListingDetailSerializer`), and `ListingDraftSerializer` appears
89
- * only as the RESPONSE of create / save-draft. So editing a live listing
90
- * works completely the composer seeds from the published half, which is
91
- * exactly what the person sees on the page — while a draft abandoned and
92
- * reopened later comes back empty. The composer says so
93
- * (`draftNotReadable`) instead of showing a blank form as if nothing had been
94
- * typed. Upstream ask, recorded in MODULE.md: put the draft twin on the
95
- * retrieve, or add `GET /{pk}/draft/`.
88
+ * `GET /{pk}/` serializes the PUBLISHED fields only (`ListingDetailSerializer`)
89
+ * and, before 0.21.1, `ListingDraftSerializer` appeared only as the RESPONSE
90
+ * of create / save-draft — no read returned the `*_draft` twin at all. So a
91
+ * draft abandoned and reopened came back empty, and a live listing's edit
92
+ * seeded from the published half instead (`draftValuesFromDetail`), which is
93
+ * what the person could actually see but not necessarily what they had last
94
+ * typed and not yet republished.
95
+ *
96
+ * 0.21.1 added the owner-only `GET /{pk}/draft/`, the exact `save-draft`
97
+ * response shape. The seeding effect now tries it first
98
+ * (`draftValuesFromWire`) for every reopened listing, live or not, and falls
99
+ * back to the published-half seed only when that read 404s — either nothing
100
+ * was ever saved, or the backend predates the route. `draftNotReadable`
101
+ * narrows to exactly that fallback case: the draft read failed AND the
102
+ * published half is empty too, so there is truly nothing to show.
96
103
  */
97
104
 
98
105
  /** The two members of `@stapel/cdn-react`'s upload bag this composer needs.
@@ -264,6 +271,7 @@ export function useListingComposer(
264
271
  const [saveRequest, setSaveRequest] = useState(0);
265
272
 
266
273
  const existing = useListing(options.listingId);
274
+ const listingDraft = useListingDraft(options.listingId);
267
275
  const createDraft = useCreateDraft();
268
276
  const saveDraft = useSaveDraft();
269
277
  const publishListing = usePublishListing();
@@ -272,21 +280,33 @@ export function useListingComposer(
272
280
  // dependency guard because re-seeding on a refetch would throw away
273
281
  // everything typed since — a background invalidation must never rewrite a
274
282
  // form somebody is in the middle of.
283
+ //
284
+ // The draft-twin read (`GET {id}/draft/`, 0.21.1) is tried FIRST
285
+ // (`draftValuesFromWire`); the published-half seed (`draftValuesFromDetail`)
286
+ // is the fallback, taken once that read has settled to anything other than
287
+ // success — a 404 because nothing was ever saved, a 404 because this
288
+ // backend predates the route, or any other failure a composer must not
289
+ // hang on. Waiting for `listingDraft` to settle too (not only `existing`)
290
+ // is what stops a live listing with unpublished edits from flashing its
291
+ // published content before the true draft lands.
275
292
  const seeded = useRef(false);
276
293
  useEffect(() => {
277
294
  if (seeded.current) return;
278
295
  const detail = existing.data;
279
296
  if (detail === undefined) return;
297
+ if (listingDraft.status === "pending") return;
280
298
  seeded.current = true;
281
299
  setListingId(detail.id);
282
300
  setValues(
283
- draftValuesFromDetail(
284
- detail,
285
- featuresDtoFromDaoList(asFeatureDaoList(detail.features)) as FeaturesDto,
286
- { currency: runtime.currency }
287
- )
301
+ listingDraft.status === "success"
302
+ ? draftValuesFromWire(listingDraft.data, { currency: runtime.currency })
303
+ : draftValuesFromDetail(
304
+ detail,
305
+ featuresDtoFromDaoList(asFeatureDaoList(detail.features)) as FeaturesDto,
306
+ { currency: runtime.currency }
307
+ )
288
308
  );
289
- }, [existing.data, runtime.currency]);
309
+ }, [existing.data, listingDraft.status, listingDraft.data, runtime.currency]);
290
310
 
291
311
  // Pruning and SEEDING both run on the SCHEMA arriving, one render after the
292
312
  // category changed: `setCategory` cannot prune against features it has not
@@ -368,9 +388,14 @@ export function useListingComposer(
368
388
  );
369
389
 
370
390
  const isLiveEdit = existing.data?.status === "published";
391
+ // Narrowed to the fallback case: the draft-twin read did not land (see the
392
+ // seeding effect above), and the published half is empty too, so there is
393
+ // truly nothing to show. A build on stapel-listings 0.21.1+ only reaches
394
+ // this when the row has genuinely never been saved into.
371
395
  const draftNotReadable =
372
396
  existing.data !== undefined &&
373
397
  existing.data.status !== "published" &&
398
+ listingDraft.status !== "success" &&
374
399
  (existing.data.title ?? "").length === 0 &&
375
400
  (existing.data.description ?? "").length === 0;
376
401
 
package/src/i18n/es.ts CHANGED
@@ -219,6 +219,9 @@ export const listingsI18nBundleEs: I18nDictionary = {
219
219
  "error.400.listing_zero_price_not_allowed":
220
220
  "En esta categoría no se permite un precio de 0. Deja el precio vacío para «precio no indicado».",
221
221
 
222
+ "error.400.listing_draft_meta_too_large":
223
+ "El borrador es demasiado grande (máximo {max_bytes} bytes). Quita parte de los datos y guarda de nuevo.",
224
+
222
225
  "listings.blocked.sign_in": "Inicia sesión para hacer esto",
223
226
  "listings.blocked.guest":
224
227
  "Esta cuenta todavía no puede hacerlo — termina de configurarla primero",
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
8
8
  /**
9
9
  * `es` texts for the backend error codes this catalog carries.
10
10
  *
11
- * PARTIAL, and deliberately typed to say so: 26 key(s) owned by
11
+ * PARTIAL, and deliberately typed to say so: 27 key(s) owned by
12
12
  * stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
13
13
  * (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
14
14
  * artifact via the en bundle; the pair layers its own authored `es`
@@ -66,6 +66,7 @@ export const LISTINGS_ERRORS = {
66
66
  "error.400.field.unique": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} must be unique" },
67
67
  "error.400.image_required": { status: 400, params: [], remediation: "fix_input", en: "At least one image is required to publish" },
68
68
  "error.400.invalid_ad_id": { status: 400, params: [], remediation: "fix_input", en: "Invalid advertisement ID" },
69
+ "error.400.listing_draft_meta_too_large": { status: 400, params: ["max_bytes"], remediation: "fix_input", en: "draft_meta is too large ({max_bytes} bytes max)" },
69
70
  "error.400.listing_feature_not_allowed": { status: 400, params: ["feature"], remediation: "fix_input", en: "Feature '{feature}' is not allowed for this category" },
70
71
  "error.400.listing_invalid_status_filter": { status: 400, params: ["status"], remediation: "fix_input", en: "Unknown listing status '{status}'" },
71
72
  "error.400.listing_location_required": { status: 400, params: [], remediation: "fix_input", en: "Choose where the item is before publishing" },
@@ -140,6 +141,7 @@ export const LISTINGS_ERROR_CODES: readonly ListingsErrorCode[] = [
140
141
  "error.400.field.unique",
141
142
  "error.400.image_required",
142
143
  "error.400.invalid_ad_id",
144
+ "error.400.listing_draft_meta_too_large",
143
145
  "error.400.listing_feature_not_allowed",
144
146
  "error.400.listing_invalid_status_filter",
145
147
  "error.400.listing_location_required",
@@ -216,6 +218,7 @@ export const listingsErrorBundleEn: Record<ListingsErrorCode, string> = {
216
218
  "error.400.field.unique": "{field} must be unique",
217
219
  "error.400.image_required": "At least one image is required to publish",
218
220
  "error.400.invalid_ad_id": "Invalid advertisement ID",
221
+ "error.400.listing_draft_meta_too_large": "draft_meta is too large ({max_bytes} bytes max)",
219
222
  "error.400.listing_feature_not_allowed": "Feature '{feature}' is not allowed for this category",
220
223
  "error.400.listing_invalid_status_filter": "Unknown listing status '{status}'",
221
224
  "error.400.listing_location_required": "Choose where the item is before publishing",
@@ -268,6 +268,15 @@
268
268
  "remediation": "fix_input",
269
269
  "en": "Invalid advertisement ID"
270
270
  },
271
+ {
272
+ "code": "error.400.listing_draft_meta_too_large",
273
+ "status": 400,
274
+ "params": [
275
+ "max_bytes"
276
+ ],
277
+ "remediation": "fix_input",
278
+ "en": "draft_meta is too large ({max_bytes} bytes max)"
279
+ },
271
280
  {
272
281
  "code": "error.400.listing_feature_not_allowed",
273
282
  "status": 400,
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
8
8
  /**
9
9
  * `ru` texts for the backend error codes this catalog carries.
10
10
  *
11
- * PARTIAL, and deliberately typed to say so: 26 key(s) owned by
11
+ * PARTIAL, and deliberately typed to say so: 27 key(s) owned by
12
12
  * stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
13
13
  * (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
14
14
  * artifact via the en bundle; the pair layers its own authored `ru`
package/src/i18n/ru.ts CHANGED
@@ -244,6 +244,11 @@ export const listingsI18nBundleRu: I18nDictionary = {
244
244
  "error.400.listing_zero_price_not_allowed":
245
245
  "В этой категории цена 0 недопустима. Оставьте поле пустым — это и есть «цена не указана».",
246
246
 
247
+ // stapel-listings 0.21.2: the composer's per-field provenance sidecar has a
248
+ // size ceiling, and the number is the server's own — never restated here.
249
+ "error.400.listing_draft_meta_too_large":
250
+ "Черновик слишком большой (не больше {max_bytes} байт). Уберите часть данных и сохраните ещё раз.",
251
+
247
252
  "listings.blocked.sign_in": "Войдите, чтобы сделать это",
248
253
  "listings.blocked.guest":
249
254
  "Этот аккаунт пока так не может — сначала завершите настройку",
package/src/index.ts CHANGED
@@ -166,6 +166,21 @@ export {
166
166
  unreadableFeatureCount,
167
167
  } from "./model/features.js";
168
168
  export type { FeatureCopySource } from "./model/features.js";
169
+ export { featureUnit, formatSpecValue } from "./model/featureText.js";
170
+
171
+ // ── model: the card badge contract (stapel-listings 0.21.3) ──────────────────
172
+ export {
173
+ badgePresentation,
174
+ badgeValueText,
175
+ cardBadgeText,
176
+ cardBadgeTexts,
177
+ hasCardBadgeContract,
178
+ } from "./model/cardBadges.js";
179
+ export type {
180
+ CardBadgeContract,
181
+ CardBadgePresentation,
182
+ CardBadgeRow,
183
+ } from "./model/cardBadges.js";
169
184
 
170
185
  // ── model: the engagement axis (already-seen / view count / the overlay) ─────
171
186
  export {
@@ -212,6 +227,7 @@ export type { ListingPageKey } from "./model/queryKeys.js";
212
227
  export {
213
228
  useListing,
214
229
  useListingCards,
230
+ useListingDraft,
215
231
  useListingEngagement,
216
232
  useListingStatus,
217
233
  useMyCounters,