@stapel/listings-react 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +73 -6
- package/dist/api/generated/schema.d.ts +119 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +25 -0
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +1 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +46 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/CardBadges.d.ts +47 -0
- package/dist/default/CardBadges.d.ts.map +1 -0
- package/dist/default/CardBadges.js +38 -0
- package/dist/default/CardBadges.js.map +1 -0
- package/dist/default/FavoritesPane.d.ts +18 -2
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +3 -3
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +6 -6
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +12 -19
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +12 -8
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +3 -5
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/ListingSpecList.d.ts +74 -0
- package/dist/default/ListingSpecList.d.ts.map +1 -0
- package/dist/default/ListingSpecList.js +90 -0
- package/dist/default/ListingSpecList.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +22 -2
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +1 -1
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/cardGallery.d.ts +81 -0
- package/dist/default/cardGallery.d.ts.map +1 -0
- package/dist/default/cardGallery.js +266 -0
- package/dist/default/cardGallery.js.map +1 -0
- package/dist/default/index.d.ts +8 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +5 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +16 -10
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +23 -4
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +134 -0
- package/dist/model/cardBadges.d.ts.map +1 -0
- package/dist/model/cardBadges.js +131 -0
- package/dist/model/cardBadges.js.map +1 -0
- package/dist/model/featureText.d.ts +63 -0
- package/dist/model/featureText.d.ts.map +1 -0
- package/dist/model/featureText.js +133 -0
- package/dist/model/featureText.js.map +1 -0
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +59 -0
- package/dist/model/features.js.map +1 -1
- package/dist/model/queries.d.ts +15 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +20 -0
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +4 -0
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +1 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/llms.txt +6 -2
- package/manifest.json +57 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +119 -6
- package/src/api/listingsApi.ts +30 -0
- package/src/api/types.ts +55 -5
- package/src/default/CardBadges.tsx +105 -0
- package/src/default/FavoritesPane.tsx +20 -5
- package/src/default/ListingCard.tsx +7 -21
- package/src/default/ListingDetailPane.tsx +13 -37
- package/src/default/ListingPhoto.tsx +54 -26
- package/src/default/ListingSerpCard.tsx +10 -28
- package/src/default/ListingSpecList.tsx +195 -0
- package/src/default/MyListingsPane.tsx +23 -5
- package/src/default/cardGallery.ts +287 -0
- package/src/default/index.ts +24 -1
- package/src/headless/ListingComposer.tsx +42 -17
- package/src/i18n/es.ts +3 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +16 -0
- package/src/model/cardBadges.ts +237 -0
- package/src/model/featureText.ts +199 -0
- package/src/model/features.ts +60 -0
- package/src/model/queries.ts +25 -0
- package/src/model/queryKeys.ts +5 -0
|
@@ -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
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -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
|
|
86
|
+
* ── Reopening a draft, before and after stapel-listings 0.21.1 ─────────────
|
|
86
87
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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:
|
|
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:
|
|
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,
|