@stapel/listings-react 0.25.10 → 0.26.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 +208 -0
- package/README.md +77 -9
- package/dist/api/generated/schema.d.ts +2 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +3 -1
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingActions.d.ts +108 -0
- package/dist/default/ListingActions.d.ts.map +1 -0
- package/dist/default/ListingActions.js +29 -0
- package/dist/default/ListingActions.js.map +1 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +15 -8
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +76 -56
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +164 -18
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts.map +1 -1
- package/dist/default/ListingFeedCard.js +2 -1
- package/dist/default/ListingFeedCard.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +5 -2
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +18 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +45 -21
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/ShareAction.d.ts +32 -0
- package/dist/default/ShareAction.d.ts.map +1 -0
- package/dist/default/ShareAction.js +116 -0
- package/dist/default/ShareAction.js.map +1 -0
- package/dist/default/actionRow.d.ts +58 -0
- package/dist/default/actionRow.d.ts.map +1 -0
- package/dist/default/actionRow.js +128 -0
- package/dist/default/actionRow.js.map +1 -0
- package/dist/default/cardGallery.d.ts +2 -0
- package/dist/default/cardGallery.d.ts.map +1 -1
- package/dist/default/cardGallery.js +31 -0
- package/dist/default/cardGallery.js.map +1 -1
- package/dist/default/favorite.d.ts +25 -0
- package/dist/default/favorite.d.ts.map +1 -1
- package/dist/default/favorite.js +29 -3
- package/dist/default/favorite.js.map +1 -1
- package/dist/default/icons.d.ts +19 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +23 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +8 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/notice.d.ts +14 -0
- package/dist/default/notice.d.ts.map +1 -0
- package/dist/default/notice.js +62 -0
- package/dist/default/notice.js.map +1 -0
- package/dist/headless/MyListings.d.ts +39 -18
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +41 -19
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/headless/Share.d.ts +103 -0
- package/dist/headless/Share.d.ts.map +1 -0
- package/dist/headless/Share.js +190 -0
- package/dist/headless/Share.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +21 -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 +18 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +9 -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/keys.d.ts +45 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +60 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +52 -3
- package/dist/model/cardBadges.d.ts.map +1 -1
- package/dist/model/cardBadges.js +74 -12
- package/dist/model/cardBadges.js.map +1 -1
- package/dist/model/mineSource.d.ts +9 -3
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/status.d.ts +62 -13
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +69 -15
- package/dist/model/status.js.map +1 -1
- package/llms.txt +8 -3
- package/manifest.json +96 -4
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +2 -2
- package/src/default/CardBadges.tsx +3 -1
- package/src/default/ListingActions.tsx +196 -0
- package/src/default/ListingCard.tsx +79 -43
- package/src/default/ListingDetailPane.tsx +268 -81
- package/src/default/ListingFeedCard.tsx +2 -0
- package/src/default/ListingPhoto.tsx +28 -0
- package/src/default/ListingSerpCard.tsx +39 -12
- package/src/default/MyListingsPane.tsx +77 -44
- package/src/default/ShareAction.tsx +260 -0
- package/src/default/actionRow.ts +131 -0
- package/src/default/cardGallery.ts +31 -0
- package/src/default/favorite.tsx +68 -1
- package/src/default/icons.tsx +60 -0
- package/src/default/index.ts +23 -0
- package/src/default/notice.ts +68 -0
- package/src/headless/MyListings.tsx +89 -38
- package/src/headless/Share.tsx +278 -0
- package/src/i18n/es.ts +28 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +9 -0
- package/src/i18n/generated/errors.json +31 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +28 -0
- package/src/index.ts +22 -0
- package/src/model/cardBadges.ts +124 -12
- package/src/model/mineSource.ts +9 -3
- package/src/model/status.ts +85 -18
|
@@ -46,9 +46,12 @@
|
|
|
46
46
|
*/
|
|
47
47
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
48
48
|
import type { PointerEvent as ReactPointerEvent, RefObject } from "react";
|
|
49
|
+
import { fontSize, radii, spacing } from "@stapel/tokens";
|
|
49
50
|
|
|
50
51
|
/** The class the gallery's own box carries — see {@link cardGalleryCss}. */
|
|
51
52
|
export const CARD_GALLERY_CLASS = "stapel-listing-gallery";
|
|
53
|
+
/** The class the "3 of 16" pill carries — see {@link cardGalleryCss}. */
|
|
54
|
+
export const CARD_GALLERY_COUNTER_CLASS = "stapel-listing-gallery-count";
|
|
52
55
|
/** The `href` the hoisted gallery stylesheet is deduplicated by. */
|
|
53
56
|
export const CARD_GALLERY_STYLE_HREF = "stapel-listings-card-gallery";
|
|
54
57
|
|
|
@@ -338,7 +341,35 @@ export function useCardGallery(count: number): CardGallery {
|
|
|
338
341
|
*/
|
|
339
342
|
export function cardGalleryCss(): string {
|
|
340
343
|
const box = `.${CARD_GALLERY_CLASS}`;
|
|
344
|
+
const counter = `.${CARD_GALLERY_COUNTER_CLASS}`;
|
|
341
345
|
return [
|
|
346
|
+
// The counter is placed against this box, so the box has to be the
|
|
347
|
+
// containing block. Nothing else about the gallery changes: a `relative`
|
|
348
|
+
// with no offsets moves no pixel of what is already in it.
|
|
349
|
+
`${box}{position:relative}`,
|
|
350
|
+
// "3 of 16", bottom-TRAILING corner — the dots own the bottom centre
|
|
351
|
+
// (`SkinCarousel`: `inset-inline:0; justify-content:center`) and two
|
|
352
|
+
// indicators fighting for one spot is worse than neither.
|
|
353
|
+
//
|
|
354
|
+
// `pointer-events:none` is load-bearing rather than tidy: this box owns
|
|
355
|
+
// the hover-scrub and the swipe, and an element that swallowed a pointer
|
|
356
|
+
// would make one corner of every card's photograph dead to both gestures.
|
|
357
|
+
//
|
|
358
|
+
// A FIXED scrim and white text, not a theme role, and this is the one
|
|
359
|
+
// place in the package where that is the right answer: the pill sits on
|
|
360
|
+
// an arbitrary PHOTOGRAPH, which is neither light nor dark, so a value
|
|
361
|
+
// that followed the page's theme would be unreadable on half the photos
|
|
362
|
+
// in either one.
|
|
363
|
+
`${counter}{position:absolute;inset-block-end:${String(spacing[2])}px;` +
|
|
364
|
+
`inset-inline-end:${String(spacing[2])}px;z-index:1;pointer-events:none;` +
|
|
365
|
+
`padding:${String(spacing[1] / 2)}px ${String(spacing[2])}px;` +
|
|
366
|
+
`border-radius:${String(radii.full)}px;` +
|
|
367
|
+
`background:rgba(0,0,0,0.55);color:#fff;` +
|
|
368
|
+
`font-size:${String(fontSize.xs.fontSize)}px;` +
|
|
369
|
+
`line-height:${String(fontSize.xs.lineHeight)}px;` +
|
|
370
|
+
// A counter whose digits change width makes the pill twitch on every
|
|
371
|
+
// swipe.
|
|
372
|
+
`font-variant-numeric:tabular-nums}`,
|
|
342
373
|
// THE VERTICAL AXIS IS THE BROWSER'S, and is not negotiable: a card is a
|
|
343
374
|
// small box in a long feed, and the one unacceptable outcome of a gallery
|
|
344
375
|
// gesture is a page that will not scroll. `pan-y` says so at the platform
|
package/src/default/favorite.tsx
CHANGED
|
@@ -35,6 +35,8 @@ import { useFavoriteToggle } from "../headless/Favorites.js";
|
|
|
35
35
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
36
36
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
37
37
|
import { HeartIcon } from "./icons.js";
|
|
38
|
+
import { useNotice } from "./notice.js";
|
|
39
|
+
import { LISTING_ACTIONS_STYLE_HREF, actionRowCss } from "./actionRow.js";
|
|
38
40
|
|
|
39
41
|
export interface FavoriteHeartProps {
|
|
40
42
|
readonly listingId: number;
|
|
@@ -83,6 +85,31 @@ export interface FavoriteHeartProps {
|
|
|
83
85
|
* its reason, which is all a surface with no sign-in route can offer.
|
|
84
86
|
*/
|
|
85
87
|
readonly signIn?: SignInCta;
|
|
88
|
+
/**
|
|
89
|
+
* The hit-target class this surface wants — `LISTING_CARD_ACTION_CLASS` on
|
|
90
|
+
* a card (36px with a cursor, 44px with a thumb),
|
|
91
|
+
* `LISTING_ACTION_CLASS` on the listing page (44px everywhere).
|
|
92
|
+
*
|
|
93
|
+
* A CLASS rather than a size prop: the tiers are a media query, and a
|
|
94
|
+
* component that took a number would have to be told the viewport — which
|
|
95
|
+
* is the thing a leaf must never ask. See `actionRow.ts`.
|
|
96
|
+
*/
|
|
97
|
+
readonly className?: string;
|
|
98
|
+
/**
|
|
99
|
+
* SAY THE OUTCOME OUT LOUD — a short toast on each successful press.
|
|
100
|
+
*
|
|
101
|
+
* Off by default and ON for the listing page: one heart on a screen the
|
|
102
|
+
* person came to act on earns a sentence, forty hearts on a grid do not.
|
|
103
|
+
* The icon fill remains the state in both cases; this is the acknowledgement
|
|
104
|
+
* that the press was received, which is the half a small glyph pinned to
|
|
105
|
+
* the corner of a photograph cannot carry on its own.
|
|
106
|
+
*
|
|
107
|
+
* It is raised on the GESTURE, from the predicted next state, for the same
|
|
108
|
+
* reason `useFavoriteToggle` draws that state optimistically: the toast has
|
|
109
|
+
* to arrive with the press. A write that then fails rolls the icon back and
|
|
110
|
+
* states the failure through the heart's own `ErrorAlert`.
|
|
111
|
+
*/
|
|
112
|
+
readonly announce?: boolean;
|
|
86
113
|
readonly style?: CSSProperties;
|
|
87
114
|
}
|
|
88
115
|
|
|
@@ -134,6 +161,7 @@ export interface FavoriteHeartProps {
|
|
|
134
161
|
*/
|
|
135
162
|
export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
136
163
|
const t = useT();
|
|
164
|
+
const notice = useNotice();
|
|
137
165
|
const { token } = antdTheme.useToken();
|
|
138
166
|
const favorite = useFavoriteToggle(props.listingId, props.favorited);
|
|
139
167
|
const label = t(
|
|
@@ -154,6 +182,22 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
154
182
|
// place a blocked reason becomes words in this fleet.
|
|
155
183
|
const gate = useActionGate(favorite.gate);
|
|
156
184
|
const reason = gate.reason;
|
|
185
|
+
// The press, plus the sentence the surface asked for. The next state is the
|
|
186
|
+
// one the icon is about to draw, so the toast and the fill say the same
|
|
187
|
+
// thing at the same moment — see `announce`.
|
|
188
|
+
const announce = props.announce === true;
|
|
189
|
+
const press = (): void => {
|
|
190
|
+
const next = !favorite.favorited;
|
|
191
|
+
favorite.toggle();
|
|
192
|
+
if (!announce || !favorite.gate.available) return;
|
|
193
|
+
notice(
|
|
194
|
+
t(
|
|
195
|
+
next
|
|
196
|
+
? LISTINGS_I18N_KEYS.favoriteAdded
|
|
197
|
+
: LISTINGS_I18N_KEYS.favoriteRemoved
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
};
|
|
157
201
|
// D431. A blocked gate plus a door the surface handed in: the press is not
|
|
158
202
|
// refused, it is ROUTED — through the door, keeping whatever `?next=` the
|
|
159
203
|
// container put in the href. With no door there is nothing better for the
|
|
@@ -175,9 +219,10 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
175
219
|
...doorPress,
|
|
176
220
|
...(door?.href !== undefined ? {} : { "aria-pressed": favorite.favorited }),
|
|
177
221
|
}
|
|
178
|
-
: { "aria-pressed": favorite.favorited, onClick:
|
|
222
|
+
: { "aria-pressed": favorite.favorited, onClick: press };
|
|
179
223
|
// A failed save, stated where the heart is. The rollback already put the
|
|
180
224
|
// icon back; this says why it went back.
|
|
225
|
+
// (see `press` above for the toast that rides the successful half)
|
|
181
226
|
const failure = (
|
|
182
227
|
<ErrorAlert
|
|
183
228
|
testId={`${props.testId}-error`}
|
|
@@ -185,8 +230,20 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
185
230
|
variant="inline"
|
|
186
231
|
/>
|
|
187
232
|
);
|
|
233
|
+
// The hit-target rules the surfaces address by class. Hoisted here rather
|
|
234
|
+
// than by each card, because the heart is what carries the class and a
|
|
235
|
+
// stylesheet that arrived only on the surfaces that remembered it is a
|
|
236
|
+
// 32px target on the one that forgot.
|
|
237
|
+
const sheet = (
|
|
238
|
+
<style href={LISTING_ACTIONS_STYLE_HREF} precedence="default">
|
|
239
|
+
{actionRowCss()}
|
|
240
|
+
</style>
|
|
241
|
+
);
|
|
242
|
+
|
|
188
243
|
if (props.blockedReason === "popover" && reason !== undefined) {
|
|
189
244
|
return (
|
|
245
|
+
<>
|
|
246
|
+
{sheet}
|
|
190
247
|
<GateReasonPopover
|
|
191
248
|
reason={reason}
|
|
192
249
|
cta={props.signIn}
|
|
@@ -201,6 +258,9 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
201
258
|
// control, and the disclosure stays as the hint beside it.
|
|
202
259
|
{...(opensDoor ? {} : { "aria-disabled": true })}
|
|
203
260
|
{...bind}
|
|
261
|
+
{...(props.className !== undefined
|
|
262
|
+
? { className: props.className }
|
|
263
|
+
: {})}
|
|
204
264
|
aria-label={label}
|
|
205
265
|
data-testid={props.testId}
|
|
206
266
|
data-favorited={String(favorite.favorited)}
|
|
@@ -212,9 +272,12 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
212
272
|
/>
|
|
213
273
|
)}
|
|
214
274
|
</GateReasonPopover>
|
|
275
|
+
</>
|
|
215
276
|
);
|
|
216
277
|
}
|
|
217
278
|
return (
|
|
279
|
+
<>
|
|
280
|
+
{sheet}
|
|
218
281
|
<GatedControl
|
|
219
282
|
gate={favorite.gate}
|
|
220
283
|
testId={props.gateTestId ?? `${props.testId}-gate`}
|
|
@@ -233,6 +296,9 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
233
296
|
shape="circle"
|
|
234
297
|
// See `<ListingCard>`: the substrate's binding, spread whole.
|
|
235
298
|
{...bind}
|
|
299
|
+
{...(props.className !== undefined
|
|
300
|
+
? { className: props.className }
|
|
301
|
+
: {})}
|
|
236
302
|
aria-label={label}
|
|
237
303
|
data-testid={props.testId}
|
|
238
304
|
data-favorited={String(favorite.favorited)}
|
|
@@ -245,5 +311,6 @@ export function FavoriteHeart(props: FavoriteHeartProps): ReactElement {
|
|
|
245
311
|
</>
|
|
246
312
|
)}
|
|
247
313
|
</GatedControl>
|
|
314
|
+
</>
|
|
248
315
|
);
|
|
249
316
|
}
|
package/src/default/icons.tsx
CHANGED
|
@@ -50,6 +50,66 @@ export function HeartIcon(props: HeartIconProps): ReactElement {
|
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The share glyph — three nodes and two edges, the platform-neutral shape
|
|
55
|
+
* both Android and the web draw for "send this somewhere else".
|
|
56
|
+
*
|
|
57
|
+
* `aria-hidden`, like the heart: the control it sits in carries the verb as
|
|
58
|
+
* its accessible name in every arm, including the phone arm where the word
|
|
59
|
+
* itself is not painted, and a glyph announced beside its own label reads the
|
|
60
|
+
* action twice.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately not the iOS "box with an arrow": that shape means "share" to
|
|
63
|
+
* an iPhone user and "upload" or "export" to everybody else, and this control
|
|
64
|
+
* is drawn on a browser, not inside an app the platform styled.
|
|
65
|
+
*/
|
|
66
|
+
export function ShareIcon(): ReactElement {
|
|
67
|
+
return (
|
|
68
|
+
<svg
|
|
69
|
+
width="16"
|
|
70
|
+
height="16"
|
|
71
|
+
viewBox="0 0 24 24"
|
|
72
|
+
fill="none"
|
|
73
|
+
stroke="currentColor"
|
|
74
|
+
strokeWidth="2"
|
|
75
|
+
strokeLinecap="round"
|
|
76
|
+
strokeLinejoin="round"
|
|
77
|
+
role="img"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
>
|
|
80
|
+
<circle cx="18" cy="5" r="3" />
|
|
81
|
+
<circle cx="6" cy="12" r="3" />
|
|
82
|
+
<circle cx="18" cy="19" r="3" />
|
|
83
|
+
<path d="M8.6 10.5 15.4 6.6" />
|
|
84
|
+
<path d="M8.6 13.5 15.4 17.4" />
|
|
85
|
+
</svg>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A chain link — the copy row's glyph, and the one thing on the menu that is
|
|
91
|
+
* not a network's own brand.
|
|
92
|
+
*/
|
|
93
|
+
export function LinkIcon(): ReactElement {
|
|
94
|
+
return (
|
|
95
|
+
<svg
|
|
96
|
+
width="16"
|
|
97
|
+
height="16"
|
|
98
|
+
viewBox="0 0 24 24"
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke="currentColor"
|
|
101
|
+
strokeWidth="2"
|
|
102
|
+
strokeLinecap="round"
|
|
103
|
+
strokeLinejoin="round"
|
|
104
|
+
role="img"
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
>
|
|
107
|
+
<path d="M10 13a5 5 0 0 0 7.5.6l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
|
|
108
|
+
<path d="M14 11a5 5 0 0 0-7.5-.6l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
|
|
109
|
+
</svg>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
53
113
|
/**
|
|
54
114
|
* Which way the asking price moved — an arrow, and a NAME for it.
|
|
55
115
|
*
|
package/src/default/index.ts
CHANGED
|
@@ -71,6 +71,7 @@ export {
|
|
|
71
71
|
DETAIL_SPLIT_MEASURE,
|
|
72
72
|
DETAIL_SPLIT_ASIDE,
|
|
73
73
|
DETAIL_PHOTO_MIN,
|
|
74
|
+
DETAIL_GALLERY_GUTTER,
|
|
74
75
|
} from "./ListingDetailPane.js";
|
|
75
76
|
export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
|
|
76
77
|
export {
|
|
@@ -126,6 +127,28 @@ export {
|
|
|
126
127
|
useCardGallery,
|
|
127
128
|
} from "./cardGallery.js";
|
|
128
129
|
export type { CardGallery } from "./cardGallery.js";
|
|
130
|
+
// ── the reader's two actions: save it, or send it to somebody ───────────────
|
|
131
|
+
export { ListingActions } from "./ListingActions.js";
|
|
132
|
+
export type {
|
|
133
|
+
ListingActionsConfig,
|
|
134
|
+
ListingActionsProps,
|
|
135
|
+
} from "./ListingActions.js";
|
|
136
|
+
export { ShareAction } from "./ShareAction.js";
|
|
137
|
+
export type { ShareActionProps } from "./ShareAction.js";
|
|
138
|
+
export {
|
|
139
|
+
LISTING_ACTIONS_CLASS,
|
|
140
|
+
LISTING_ACTIONS_OVERLAY_CLASS,
|
|
141
|
+
LISTING_ACTIONS_STYLE_HREF,
|
|
142
|
+
LISTING_ACTION_CLASS,
|
|
143
|
+
LISTING_ACTION_HIT,
|
|
144
|
+
LISTING_ACTION_LABEL_CLASS,
|
|
145
|
+
LISTING_CARD_ACTION_CLASS,
|
|
146
|
+
LISTING_CARD_ACTION_HIT,
|
|
147
|
+
actionRowCss,
|
|
148
|
+
} from "./actionRow.js";
|
|
149
|
+
export { NOTICE_SECONDS, useNotice } from "./notice.js";
|
|
150
|
+
export type { Notice } from "./notice.js";
|
|
151
|
+
|
|
129
152
|
export { SignInLink } from "./SignInLink.js";
|
|
130
153
|
export type { SignInLinkProps } from "./SignInLink.js";
|
|
131
154
|
export type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A one-line confirmation, spoken once — the message seam this skin uses and
|
|
3
|
+
* the reason it is not a new dependency.
|
|
4
|
+
*
|
|
5
|
+
* ── There is no fleet-wide toast primitive, and this does not invent one ──
|
|
6
|
+
*
|
|
7
|
+
* `@stapel/tokens-antd/skin` carries `ErrorAlert`, `EmptyState`,
|
|
8
|
+
* `GatedControl`, `SkinDialog` and `SkinConfirm` — every way a pair states a
|
|
9
|
+
* REFUSAL or asks a question — and nothing at all for "that worked". So the
|
|
10
|
+
* seam here is antd's own, which this package already renders through:
|
|
11
|
+
*
|
|
12
|
+
* 1. `App.useApp().message` when the host mounts antd's `<App>`, which is
|
|
13
|
+
* the arrangement antd itself asks for — the notice then inherits the
|
|
14
|
+
* host's `ConfigProvider` theme, its locale and its container;
|
|
15
|
+
* 2. antd's STATIC `message` otherwise, so a host that never mounted `<App>`
|
|
16
|
+
* still gets the confirmation instead of silence.
|
|
17
|
+
*
|
|
18
|
+
* Outside an `<App>`, `App.useApp()` returns `{message: {}, …}` — antd's own
|
|
19
|
+
* default context — which is why the arm is chosen by asking whether the
|
|
20
|
+
* function is there rather than by asking whether a provider is. A version
|
|
21
|
+
* that assumed the context would have been a confirmation that worked in
|
|
22
|
+
* every test and on no deployment.
|
|
23
|
+
*
|
|
24
|
+
* ── And a toast is never the ONLY copy of a confirmation ──────────────────
|
|
25
|
+
*
|
|
26
|
+
* A toast is transient by construction: it appears for three seconds
|
|
27
|
+
* somewhere the person may not be looking, and on a phone it can land under a
|
|
28
|
+
* thumb. So every caller here also paints the same sentence where the gesture
|
|
29
|
+
* happened — `<ShareAction>` states "Link copied" inside the open menu, and
|
|
30
|
+
* the heart's state is on the heart. This is the AMPLIFIER, never the record.
|
|
31
|
+
* A future `SkinNotice` in the substrate replaces the body of this function
|
|
32
|
+
* and nothing else; the callers already speak in resolved sentences.
|
|
33
|
+
*/
|
|
34
|
+
import { useCallback } from "react";
|
|
35
|
+
import { App, message as staticMessage } from "antd";
|
|
36
|
+
|
|
37
|
+
/** Say one short sentence. Resolved copy — this is the skin, not a bag. */
|
|
38
|
+
export type Notice = (text: string) => void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How long a confirmation stands, in seconds.
|
|
42
|
+
*
|
|
43
|
+
* Two, not antd's default three. Every notice this package raises confirms
|
|
44
|
+
* something the person can already SEE — a filled heart, a sentence standing
|
|
45
|
+
* in the open share menu — so it is an acknowledgement, not information, and
|
|
46
|
+
* an acknowledgement that outstays the gesture is a strip of chrome sitting
|
|
47
|
+
* over the page a thumb was about to press next.
|
|
48
|
+
*/
|
|
49
|
+
export const NOTICE_SECONDS = 2;
|
|
50
|
+
|
|
51
|
+
export function useNotice(): Notice {
|
|
52
|
+
const app = App.useApp();
|
|
53
|
+
const contextual = app.message.success;
|
|
54
|
+
return useCallback(
|
|
55
|
+
(text: string): void => {
|
|
56
|
+
if (typeof contextual === "function") {
|
|
57
|
+
contextual(text, NOTICE_SECONDS);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// No `<App>` above us. antd's static entry renders its own holder into
|
|
61
|
+
// the document, which is exactly right for a host that never opted in,
|
|
62
|
+
// and is a no-op on a server where there is no document to render into.
|
|
63
|
+
if (typeof document === "undefined") return;
|
|
64
|
+
staticMessage.success(text, NOTICE_SECONDS);
|
|
65
|
+
},
|
|
66
|
+
[contextual]
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -20,8 +20,13 @@ import type { MyListingsSource } from "../model/mineSource.js";
|
|
|
20
20
|
import { useListingsApi } from "../model/context.js";
|
|
21
21
|
import { useMyCounters } from "../model/queries.js";
|
|
22
22
|
import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
|
|
23
|
-
import {
|
|
24
|
-
|
|
23
|
+
import {
|
|
24
|
+
MY_LISTINGS_COUNTED_TABS,
|
|
25
|
+
MY_LISTINGS_REMOVED_TAB,
|
|
26
|
+
MY_LISTINGS_TABS,
|
|
27
|
+
MY_LISTINGS_UNTABBED_STATUSES,
|
|
28
|
+
} from "../model/status.js";
|
|
29
|
+
import type { MyListingsCountedTab, MyListingsTab } from "../model/status.js";
|
|
25
30
|
import { browserAddress, tabFromSearch } from "../model/tabAddress.js";
|
|
26
31
|
import type { MyListingsAddress } from "../model/tabAddress.js";
|
|
27
32
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -37,45 +42,66 @@ import { useMandateGate } from "./useMandateGate.js";
|
|
|
37
42
|
* keeps the argument and the seam that came out of it.
|
|
38
43
|
*
|
|
39
44
|
* TWO reads, not one, and the second one is the point of this file's shape:
|
|
40
|
-
* the three tabs are the SERVER's status groupings, and `blocked` — a
|
|
45
|
+
* the three counted tabs are the SERVER's status groupings, and `blocked` — a
|
|
41
46
|
* moderation takedown — is in none of them, because `my/counters` counts it
|
|
42
47
|
* in none of them. A dashboard that only ever asked for a tab's statuses
|
|
43
48
|
* would hide exactly the listing whose owner most needs to know. So
|
|
44
49
|
* `blockedRows` is fetched beside them, off the same route, narrowed to
|
|
45
50
|
* whatever `MY_LISTINGS_UNTABBED_STATUSES` derives.
|
|
51
|
+
*
|
|
52
|
+
* That second read is also the fourth tab's COUNT (D407). `my/counters` has
|
|
53
|
+
* three integers and no fourth, so the removed tab is counted from the rows it
|
|
54
|
+
* holds — which is honest exactly because that read is unpaged and runs
|
|
55
|
+
* whichever tab is open. The one thing it cannot describe is a page cap: a
|
|
56
|
+
* seller with more takedowns than one page of `my/listings` would see the page
|
|
57
|
+
* count and not the total, and there is no counter on the wire to check it
|
|
58
|
+
* against (see `MyListingsBag.tabCounts`).
|
|
46
59
|
*/
|
|
47
60
|
|
|
48
61
|
export interface MyListingsBag {
|
|
49
62
|
readonly tab: MyListingsTab;
|
|
63
|
+
/**
|
|
64
|
+
* The tabs to DRAW, in order.
|
|
65
|
+
*
|
|
66
|
+
* The server's three always; the removed one only where there is something
|
|
67
|
+
* in it or the person asked for it by address. An empty "Taken down" tab is
|
|
68
|
+
* a scare, and the seller it would scare is the one it has nothing to tell.
|
|
69
|
+
*/
|
|
50
70
|
readonly tabs: readonly MyListingsTab[];
|
|
51
71
|
setTab(tab: MyListingsTab): void;
|
|
52
72
|
/** The three real counts, as the server reports them. */
|
|
53
73
|
readonly counters: LoadState<MyCounters>;
|
|
54
74
|
/**
|
|
55
75
|
* The number to DRAW on each tab — the server's counter, raised to what is
|
|
56
|
-
* actually on screen.
|
|
76
|
+
* actually on screen, plus the fourth tab's own.
|
|
57
77
|
*
|
|
58
|
-
* D407: a moderator-rejected listing was on the
|
|
59
|
-
* reading `0`. The two sets are grouped in two
|
|
60
|
-
* aggregates server-side, `MY_LISTINGS_TAB_STATUSES`
|
|
61
|
-
* a tab ASKS for — and any disagreement between them
|
|
62
|
-
* an older counter, a status added upstream, a
|
|
63
|
-
* lands as a badge contradicting the rows
|
|
78
|
+
* D407, twice over. The first half: a moderator-rejected listing was on the
|
|
79
|
+
* Drafts tab under a badge reading `0`. The two sets are grouped in two
|
|
80
|
+
* places — `my/counters` aggregates server-side, `MY_LISTINGS_TAB_STATUSES`
|
|
81
|
+
* decides which statuses a tab ASKS for — and any disagreement between them
|
|
82
|
+
* (a deployment running an older counter, a status added upstream, a
|
|
83
|
+
* grouping changed on one side) lands as a badge contradicting the rows
|
|
84
|
+
* underneath it. A count smaller than what a person can see is not a count,
|
|
85
|
+
* so the loaded rows are treated as evidence: for the OPEN tab the number is
|
|
86
|
+
* never below `rows.length`. It is a floor and not a replacement — the rows
|
|
87
|
+
* are one keyset page and the counter is the whole set, so the counter still
|
|
88
|
+
* wins whenever it is the larger of the two.
|
|
64
89
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
68
|
-
*
|
|
69
|
-
* whenever it is the larger of the two.
|
|
90
|
+
* The second half: a takedown was in no tab and therefore in no number, so
|
|
91
|
+
* a cabinet holding one read "Active 0 · Drafts 0 · Archived 0" over it.
|
|
92
|
+
* `removed` is counted from `blockedRows` — there is no server counter to
|
|
93
|
+
* read, and the rows are the only evidence on the wire.
|
|
70
94
|
*/
|
|
71
95
|
readonly tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>>;
|
|
72
|
-
/** The rows for the current tab.
|
|
96
|
+
/** The rows for the current tab — {@link MyListingsBag.blockedRows} while
|
|
97
|
+
* the removed tab is open, and the tab's own keyset page otherwise. */
|
|
73
98
|
readonly rows: LoadState<readonly MyListingCard[]>;
|
|
74
99
|
/**
|
|
75
|
-
* The rows
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
100
|
+
* The rows the server's own counter folds into no tab — a moderation
|
|
101
|
+
* takedown, today; the removed tab's whole contents and its count. Empty
|
|
102
|
+
* for almost every seller; when it is not, it is the most important thing
|
|
103
|
+
* on the screen. Never `failed` in a way that hides the tabs: this read is
|
|
104
|
+
* independent of the tab's own and a skin renders it beside them.
|
|
79
105
|
*/
|
|
80
106
|
readonly blockedRows: LoadState<readonly MyListingCard[]>;
|
|
81
107
|
readonly page: MyListingsParams;
|
|
@@ -136,11 +162,18 @@ export function useMyListings(
|
|
|
136
162
|
[injected, api]
|
|
137
163
|
);
|
|
138
164
|
const ready = sessionReady && gate.available;
|
|
165
|
+
// The fourth tab is served by `blockedQuery` below, not by the host's
|
|
166
|
+
// source: `MyListingsSource` is typed for the three counted tabs and a host
|
|
167
|
+
// that implemented it before D407 has no answer for a fourth.
|
|
168
|
+
const removed = tab === MY_LISTINGS_REMOVED_TAB;
|
|
169
|
+
const countedTab: MyListingsCountedTab = removed
|
|
170
|
+
? MY_LISTINGS_COUNTED_TABS[0]
|
|
171
|
+
: (tab as MyListingsCountedTab);
|
|
139
172
|
|
|
140
173
|
const rowsQuery = useQuery({
|
|
141
|
-
queryKey: listingsQueryKeys.mine(
|
|
142
|
-
queryFn: ({ signal }) => source({ tab, page, signal }),
|
|
143
|
-
enabled: ready,
|
|
174
|
+
queryKey: listingsQueryKeys.mine(countedTab, pageKey(page)),
|
|
175
|
+
queryFn: ({ signal }) => source({ tab: countedTab, page, signal }),
|
|
176
|
+
enabled: ready && !removed,
|
|
144
177
|
retry: false,
|
|
145
178
|
});
|
|
146
179
|
|
|
@@ -155,12 +188,6 @@ export function useMyListings(
|
|
|
155
188
|
retry: false,
|
|
156
189
|
});
|
|
157
190
|
|
|
158
|
-
const rows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
159
|
-
if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
|
|
160
|
-
if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
|
|
161
|
-
return loadLoading();
|
|
162
|
-
}, [rowsQuery.status, rowsQuery.error, rowsQuery.data]);
|
|
163
|
-
|
|
164
191
|
const blockedRows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
165
192
|
if (MY_LISTINGS_UNTABBED_STATUSES.length === 0) return loadReady([]);
|
|
166
193
|
if (blockedQuery.status === "error") return loadFailed(blockedQuery.error);
|
|
@@ -168,9 +195,26 @@ export function useMyListings(
|
|
|
168
195
|
return loadLoading();
|
|
169
196
|
}, [blockedQuery.status, blockedQuery.error, blockedQuery.data]);
|
|
170
197
|
|
|
171
|
-
const
|
|
198
|
+
const tabRows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
199
|
+
if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
|
|
200
|
+
if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
|
|
201
|
+
return loadLoading();
|
|
202
|
+
}, [rowsQuery.status, rowsQuery.error, rowsQuery.data]);
|
|
172
203
|
|
|
173
|
-
|
|
204
|
+
const rows = removed ? blockedRows : tabRows;
|
|
205
|
+
|
|
206
|
+
// The takedown count, when it is known. Not a `0` while the read is in
|
|
207
|
+
// flight: the tab strip is drawn off this number and a tab that appeared a
|
|
208
|
+
// beat after the page settled would move the three beside it.
|
|
209
|
+
const blockedCount =
|
|
210
|
+
blockedRows.status === "ready" ? blockedRows.data.length : undefined;
|
|
211
|
+
|
|
212
|
+
// Paging belongs to the tab's own keyset read. The takedowns are fetched
|
|
213
|
+
// unpaged on purpose, so the removed tab has nowhere to go and says so.
|
|
214
|
+
const envelope = removed ? undefined : rowsQuery.data;
|
|
215
|
+
|
|
216
|
+
// D407, the floor: never a number smaller than the rows on screen — and a
|
|
217
|
+
// number for the fourth tab, which the server counts nowhere. See
|
|
174
218
|
// `MyListingsBag.tabCounts`.
|
|
175
219
|
const tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>> =
|
|
176
220
|
useMemo(() => {
|
|
@@ -178,19 +222,26 @@ export function useMyListings(
|
|
|
178
222
|
if (counters.data === undefined) return loadLoading();
|
|
179
223
|
const server = counters.data;
|
|
180
224
|
const visible = rows.status === "ready" ? rows.data.length : 0;
|
|
181
|
-
return loadReady(
|
|
182
|
-
Object.fromEntries(
|
|
183
|
-
|
|
225
|
+
return loadReady({
|
|
226
|
+
...(Object.fromEntries(
|
|
227
|
+
MY_LISTINGS_COUNTED_TABS.map((one) => [
|
|
184
228
|
one,
|
|
185
229
|
one === tab ? Math.max(server[one], visible) : server[one],
|
|
186
230
|
])
|
|
187
|
-
) as Readonly<Record<
|
|
188
|
-
|
|
189
|
-
|
|
231
|
+
) as Readonly<Record<MyListingsCountedTab, number>>),
|
|
232
|
+
[MY_LISTINGS_REMOVED_TAB]: blockedCount ?? 0,
|
|
233
|
+
});
|
|
234
|
+
}, [counters.status, counters.error, counters.data, rows, tab, blockedCount]);
|
|
235
|
+
|
|
236
|
+
// The removed tab is drawn where it has something to say — or where the
|
|
237
|
+
// address named it, so `?tab=removed` opens a real (if empty) tab rather
|
|
238
|
+
// than an activeKey pointing at nothing.
|
|
239
|
+
const tabs: readonly MyListingsTab[] =
|
|
240
|
+
removed || (blockedCount ?? 0) > 0 ? MY_LISTINGS_TABS : MY_LISTINGS_COUNTED_TABS;
|
|
190
241
|
|
|
191
242
|
return {
|
|
192
243
|
tab,
|
|
193
|
-
tabs
|
|
244
|
+
tabs,
|
|
194
245
|
setTab: (next) => {
|
|
195
246
|
// A cursor belongs to ONE ordered candidate set. Carried across a tab
|
|
196
247
|
// change it either bounces or honestly returns page four of a
|