@stapel/listings-react 0.21.0 → 0.22.1
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 +76 -0
- package/dist/api/generated/schema.d.ts +125 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +20 -1
- 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 +32 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +7 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +54 -5
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +14 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts +19 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +99 -12
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +64 -13
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +60 -5
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +8 -2
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +20 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +52 -1
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +12 -2
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/model/mine.d.ts +20 -0
- package/dist/model/mine.d.ts.map +1 -1
- package/dist/model/mine.js +22 -0
- package/dist/model/mine.js.map +1 -1
- package/dist/model/mutations.d.ts +24 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +28 -0
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +50 -12
- package/dist/model/status.js.map +1 -1
- package/dist/model/transitions.d.ts +38 -1
- package/dist/model/transitions.d.ts.map +1 -1
- package/dist/model/transitions.js +73 -1
- package/dist/model/transitions.js.map +1 -1
- package/llms.txt +4 -2
- package/manifest.json +26 -2
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +125 -2
- package/src/api/listingsApi.ts +27 -0
- package/src/api/types.ts +47 -2
- package/src/default/ListingCard.tsx +73 -5
- package/src/default/ListingSerpCard.tsx +17 -4
- package/src/default/MyListingsPane.tsx +205 -39
- package/src/headless/ListingActions.tsx +137 -16
- package/src/i18n/es.ts +8 -2
- package/src/i18n/keys.ts +52 -1
- package/src/i18n/ru.ts +12 -2
- package/src/index.ts +19 -3
- package/src/model/mine.ts +23 -0
- package/src/model/mutations.ts +41 -0
- package/src/model/status.ts +51 -12
- package/src/model/transitions.ts +80 -1
|
@@ -43,23 +43,24 @@
|
|
|
43
43
|
* phone. It used to fire the mutation on the first click.
|
|
44
44
|
*/
|
|
45
45
|
import { useState } from "react";
|
|
46
|
-
import type { ReactElement } from "react";
|
|
46
|
+
import type { ReactElement, ReactNode } from "react";
|
|
47
47
|
import { Flex, List, Tabs, Typography } from "antd";
|
|
48
48
|
import {
|
|
49
49
|
ErrorAlert,
|
|
50
50
|
EmptyState,
|
|
51
51
|
GatedButton,
|
|
52
|
+
PaneGate,
|
|
52
53
|
SkinConfirm,
|
|
53
54
|
SkinTheme,
|
|
54
55
|
} from "@stapel/tokens-antd/skin";
|
|
55
|
-
import { matchList, matchLoad, useT, useTPlural } from "@stapel/core";
|
|
56
|
-
import type { SignInCtaProp } from "@stapel/core";
|
|
56
|
+
import { actionAvailable, matchList, matchLoad, useT, useTPlural } from "@stapel/core";
|
|
57
|
+
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
57
58
|
import { spacing } from "@stapel/tokens";
|
|
58
59
|
import type { MyListingCard } from "../api/types.js";
|
|
59
60
|
import { useMyListings } from "../headless/MyListings.js";
|
|
60
61
|
import type { MyListingsSource } from "../model/mineSource.js";
|
|
61
62
|
import { useListingActions } from "../headless/ListingActions.js";
|
|
62
|
-
import { myListingTitle } from "../model/mine.js";
|
|
63
|
+
import { myListingTitle, neverSubmitted } from "../model/mine.js";
|
|
63
64
|
import { listingStatusView } from "../model/status.js";
|
|
64
65
|
import type { MyListingsTab } from "../model/status.js";
|
|
65
66
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -91,13 +92,95 @@ const THUMB_WIDTH = "4.5rem";
|
|
|
91
92
|
* text between them. */
|
|
92
93
|
const MINE_MEASURE = "60rem";
|
|
93
94
|
|
|
95
|
+
/** The class a row's link to its own listing carries. */
|
|
96
|
+
const MINE_LINK_CLASS = "stapel-listings-mine-link";
|
|
97
|
+
|
|
98
|
+
/** The `href` the hoisted dashboard stylesheet is deduplicated by. */
|
|
99
|
+
const MINE_STYLE_HREF = "stapel-listings-mine";
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The two rules an inline style cannot state: the focus ring
|
|
103
|
+
* (`:focus-visible`) and the fact that a row link is a HIT AREA rather than a
|
|
104
|
+
* link's look.
|
|
105
|
+
*
|
|
106
|
+
* `color: inherit` / `text-decoration: none`: the title is already a title and
|
|
107
|
+
* the thumbnail is already a picture — painting either link-blue would make a
|
|
108
|
+
* dashboard read as a list of hyperlinks instead of a list of listings. The
|
|
109
|
+
* ring is what keeps that honest for a keyboard: a target with no chrome must
|
|
110
|
+
* still say when it has focus.
|
|
111
|
+
*/
|
|
112
|
+
function myListingsCss(): string {
|
|
113
|
+
const link = `.${MINE_LINK_CLASS}`;
|
|
114
|
+
return [
|
|
115
|
+
`${link}{display:block;color:inherit;text-decoration:none;min-inline-size:0}`,
|
|
116
|
+
`${link}:hover{text-decoration:underline}`,
|
|
117
|
+
`${link}:focus-visible{outline:2px solid var(--stapel-focus-ring);` +
|
|
118
|
+
`outline-offset:2px;border-radius:4px}`,
|
|
119
|
+
].join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The row's title and thumbnail, as a link to the listing's own page when the
|
|
124
|
+
* host has one.
|
|
125
|
+
*
|
|
126
|
+
* The gap this closes (D183): a live cabinet held ZERO `a[href^="/l/"]`. The
|
|
127
|
+
* title was bold text and the thumbnail was an image, so a seller could see
|
|
128
|
+
* their listing in the list and had no way at all to open it — the one
|
|
129
|
+
* natural move after publishing ("did that come out right?") had to be made
|
|
130
|
+
* by typing a URL. `<ListingCard>` has had this seam since it existed; the
|
|
131
|
+
* dashboard simply never took it.
|
|
132
|
+
*
|
|
133
|
+
* A row NOBODY HAS EVER PUBLISHED gets no link, and that is not a
|
|
134
|
+
* limitation. `title`/`price`/`images` are the PUBLISHED fields, so such a
|
|
135
|
+
* row's page is a blank one — a link to nothing is worse than the absence of
|
|
136
|
+
* a link, and Edit is the move that row actually wants. The predicate is the
|
|
137
|
+
* server's own (`neverSubmitted`: DRAFT and NOT_SUBMITTED), not "is this row
|
|
138
|
+
* showing its draft twin": the second answers `false` for a draft with no
|
|
139
|
+
* title at all, and a row reading "#623 · Draft" was handed a link to a
|
|
140
|
+
* page that does not exist.
|
|
141
|
+
*/
|
|
142
|
+
function RowLink(props: {
|
|
143
|
+
listing: MyListingCard;
|
|
144
|
+
href: ((id: number) => string) | undefined;
|
|
145
|
+
linkComponent: LinkComponent | undefined;
|
|
146
|
+
children: ReactNode;
|
|
147
|
+
testId: string;
|
|
148
|
+
label?: string | undefined;
|
|
149
|
+
}): ReactElement {
|
|
150
|
+
const { href, listing } = props;
|
|
151
|
+
if (href === undefined || neverSubmitted(listing)) return <>{props.children}</>;
|
|
152
|
+
const target = href(listing.id);
|
|
153
|
+
const Link = props.linkComponent;
|
|
154
|
+
const shared = {
|
|
155
|
+
href: target,
|
|
156
|
+
className: MINE_LINK_CLASS,
|
|
157
|
+
"data-testid": props.testId,
|
|
158
|
+
"data-analytics": "none",
|
|
159
|
+
"data-analytics-reason":
|
|
160
|
+
"navigation to the listing's own page — the host app wraps this with its own tracked()",
|
|
161
|
+
...(props.label !== undefined ? { "aria-label": props.label } : {}),
|
|
162
|
+
};
|
|
163
|
+
return Link !== undefined ? (
|
|
164
|
+
<Link {...shared}>{props.children}</Link>
|
|
165
|
+
) : (
|
|
166
|
+
<a {...shared}>{props.children}</a>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
94
170
|
function MyListingRow(props: {
|
|
95
171
|
listing: MyListingCard;
|
|
96
172
|
onEdit?: ((id: number) => void) | undefined;
|
|
97
173
|
onAskRemove: (id: number) => void;
|
|
174
|
+
listingHref?: ((id: number) => string) | undefined;
|
|
175
|
+
linkComponent?: LinkComponent | undefined;
|
|
98
176
|
}): ReactElement {
|
|
99
177
|
const t = useT();
|
|
100
|
-
const actions = useListingActions(props.listing.id, props.listing.status
|
|
178
|
+
const actions = useListingActions(props.listing.id, props.listing.status, {
|
|
179
|
+
// THE answer to "what can I do with this row", from the module that owns
|
|
180
|
+
// the state machine. Absent (a row read before stapel-listings 0.20.0),
|
|
181
|
+
// `ownerMoves` falls back to the mirror rather than to nothing.
|
|
182
|
+
available: props.listing.available_transitions,
|
|
183
|
+
});
|
|
101
184
|
// Both axes, both real: `MyListingCardSerializer` puts `moderation_status`
|
|
102
185
|
// on the owner's card, so the row no longer has to pass "approved" as a
|
|
103
186
|
// stand-in and lose the one combination that matters — a LIVE listing whose
|
|
@@ -130,20 +213,38 @@ function MyListingRow(props: {
|
|
|
130
213
|
data-listing-id={props.listing.id}
|
|
131
214
|
style={{ alignItems: "flex-start" }}
|
|
132
215
|
>
|
|
216
|
+
<style href={MINE_STYLE_HREF} precedence="default">
|
|
217
|
+
{myListingsCss()}
|
|
218
|
+
</style>
|
|
133
219
|
<Flex gap={spacing[3]} style={{ width: "100%", minWidth: 0 }}>
|
|
134
220
|
<div style={{ flex: `0 0 ${THUMB_WIDTH}`, width: THUMB_WIDTH }}>
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
221
|
+
<RowLink
|
|
222
|
+
listing={props.listing}
|
|
223
|
+
href={props.listingHref}
|
|
224
|
+
linkComponent={props.linkComponent}
|
|
225
|
+
testId="listings-mine-thumb-link"
|
|
226
|
+
label={heading ?? `#${String(props.listing.id)}`}
|
|
227
|
+
>
|
|
228
|
+
<ListingPhoto
|
|
229
|
+
imageRef={cover}
|
|
230
|
+
alt={heading ?? `#${String(props.listing.id)}`}
|
|
231
|
+
/>
|
|
232
|
+
</RowLink>
|
|
139
233
|
</div>
|
|
140
234
|
|
|
141
235
|
{/* `min-width: 0` is what stops a long title from pushing the status
|
|
142
236
|
out of the row and splitting a word across two lines. */}
|
|
143
237
|
<Flex vertical gap={spacing[2]} style={{ flex: "1 1 auto", minWidth: 0 }}>
|
|
144
|
-
<
|
|
145
|
-
{
|
|
146
|
-
|
|
238
|
+
<RowLink
|
|
239
|
+
listing={props.listing}
|
|
240
|
+
href={props.listingHref}
|
|
241
|
+
linkComponent={props.linkComponent}
|
|
242
|
+
testId="listings-mine-open"
|
|
243
|
+
>
|
|
244
|
+
<Typography.Text strong ellipsis data-testid="listings-mine-title">
|
|
245
|
+
{heading ?? `#${String(props.listing.id)}`}
|
|
246
|
+
</Typography.Text>
|
|
247
|
+
</RowLink>
|
|
147
248
|
|
|
148
249
|
{/* ONE status treatment. The word "Draft" beside the title was a
|
|
149
250
|
second, redundant rendering of what this tag already says. */}
|
|
@@ -157,13 +258,23 @@ function MyListingRow(props: {
|
|
|
157
258
|
/>
|
|
158
259
|
) : null}
|
|
159
260
|
|
|
160
|
-
{/* Wrap, never overflow:
|
|
161
|
-
clipped "Delete" is a control that is not there.
|
|
261
|
+
{/* Wrap, never overflow: the actions do not fit across 390px and a
|
|
262
|
+
clipped "Delete" is a control that is not there.
|
|
263
|
+
|
|
264
|
+
`layout="inline"` on every one of them, and it is worth a line
|
|
265
|
+
(D168). A `<GatedControl>` defaults to `display: flex` — a
|
|
266
|
+
BLOCK — so each button became a full-width row of its own and a
|
|
267
|
+
published listing's action set stacked into a column: one
|
|
268
|
+
cabinet row measured 331px of an 844px phone, five listings to a
|
|
269
|
+
screen and a half. Inline makes them what they look like, a row
|
|
270
|
+
of small buttons that wraps, and puts each refusal beside its
|
|
271
|
+
control rather than under it. */}
|
|
162
272
|
<Flex wrap gap={spacing[2]} align="flex-start">
|
|
163
273
|
{hasEditor ? (
|
|
164
274
|
<GatedButton
|
|
165
275
|
gate={editGate}
|
|
166
276
|
size="small"
|
|
277
|
+
layout="inline"
|
|
167
278
|
testId="listings-mine-edit"
|
|
168
279
|
data-analytics="none"
|
|
169
280
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
@@ -174,29 +285,45 @@ function MyListingRow(props: {
|
|
|
174
285
|
{t(LISTINGS_I18N_KEYS.mineEdit)}
|
|
175
286
|
</GatedButton>
|
|
176
287
|
) : null}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
288
|
+
{/* The moves the SERVER says this row has, and only those.
|
|
289
|
+
`available_transitions` is on the owner's card since
|
|
290
|
+
stapel-listings 0.20.0, so the set drawn here and the set
|
|
291
|
+
`POST {id}/transition/` accepts are one object.
|
|
292
|
+
|
|
293
|
+
It used to be a fixed pair — "Mark sold" and "Archive" — on
|
|
294
|
+
every row in every status, gated against a local copy of the
|
|
295
|
+
table that answers "yes" to a same-status move. So an ARCHIVED
|
|
296
|
+
row offered "Archive" and a SOLD row offered "Mark sold": two
|
|
297
|
+
of its four controls enabled, clickable and inert. And the
|
|
298
|
+
edges that would have UNDONE either of those — SOLD back to
|
|
299
|
+
PUBLISHED, ARCHIVED back to DRAFT — were in the state machine
|
|
300
|
+
the whole time with no button and no route, which is what made
|
|
301
|
+
a misclick on "Mark sold" cost a seller the listing. */}
|
|
302
|
+
{actions.moves.map((move) => (
|
|
303
|
+
<GatedButton
|
|
304
|
+
key={move.to}
|
|
305
|
+
gate={move.gate}
|
|
306
|
+
size="small"
|
|
307
|
+
layout="inline"
|
|
308
|
+
testId={move.testId}
|
|
309
|
+
data-listing-move={move.to}
|
|
310
|
+
data-analytics="none"
|
|
311
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
312
|
+
onClick={move.run}
|
|
313
|
+
>
|
|
314
|
+
{t(move.labelKey)}
|
|
315
|
+
</GatedButton>
|
|
316
|
+
))}
|
|
197
317
|
<GatedButton
|
|
198
318
|
gate={actions.remove}
|
|
199
319
|
size="small"
|
|
320
|
+
// STACK, alone among the row's actions, and measured: this is
|
|
321
|
+
// the one control that carries a STANDING sentence ("archive it
|
|
322
|
+
// first — a listing on sale cannot be deleted"), and inline a
|
|
323
|
+
// button plus sixty characters is one flex item too wide for a
|
|
324
|
+
// 390px row, so it wrapped inside itself and took four lines.
|
|
325
|
+
// Under the button the same sentence takes two.
|
|
326
|
+
layout="stack"
|
|
200
327
|
danger
|
|
201
328
|
testId="listings-mine-delete"
|
|
202
329
|
data-analytics="none"
|
|
@@ -224,6 +351,24 @@ export interface MyListingsPaneProps extends ThemeModeProp, SignInCtaProp {
|
|
|
224
351
|
* offering a click that does nothing.
|
|
225
352
|
*/
|
|
226
353
|
readonly onEdit?: (id: number) => void;
|
|
354
|
+
/**
|
|
355
|
+
* Where a listing's own page lives, given its id — `(id) => \`/l/${id}\``
|
|
356
|
+
* for a storefront.
|
|
357
|
+
*
|
|
358
|
+
* Absent, the row's title and thumbnail are plain text and a picture, which
|
|
359
|
+
* is what this pane shipped: a live cabinet held zero links to a listing,
|
|
360
|
+
* so "publish it, then look at it" was a move a seller could only make by
|
|
361
|
+
* typing a URL. Absent is still a real answer — a deployment whose listings
|
|
362
|
+
* have no public page has nothing to link TO — but it is now a decision the
|
|
363
|
+
* host makes rather than one this file made for everyone.
|
|
364
|
+
*/
|
|
365
|
+
readonly listingHref?: (id: number) => string;
|
|
366
|
+
/**
|
|
367
|
+
* The router's link, so those two targets are client-side navigations. A
|
|
368
|
+
* plain `<a href>` otherwise — right-clickable and correct, just a full page
|
|
369
|
+
* load.
|
|
370
|
+
*/
|
|
371
|
+
readonly linkComponent?: LinkComponent;
|
|
227
372
|
}
|
|
228
373
|
|
|
229
374
|
export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
@@ -249,6 +394,8 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
|
249
394
|
listing={row}
|
|
250
395
|
onEdit={props.onEdit}
|
|
251
396
|
onAskRemove={setRemovingId}
|
|
397
|
+
listingHref={props.listingHref}
|
|
398
|
+
linkComponent={props.linkComponent}
|
|
252
399
|
/>
|
|
253
400
|
);
|
|
254
401
|
|
|
@@ -392,11 +539,30 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
|
392
539
|
</div>
|
|
393
540
|
),
|
|
394
541
|
ready: (rows) => (
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
542
|
+
// ONE COPY OF EACH REFUSAL FOR THE WHOLE LIST.
|
|
543
|
+
//
|
|
544
|
+
// `<PaneGate>` pools what `<GatedControl>` would otherwise print
|
|
545
|
+
// per control: a seller with six published listings read
|
|
546
|
+
// "archive it first — a listing on sale cannot be deleted" six
|
|
547
|
+
// times down one column, and on a 390px phone that paragraph is
|
|
548
|
+
// 63px of every row. Each control keeps its
|
|
549
|
+
// `aria-describedby` pointing at the pooled sentence, so a
|
|
550
|
+
// screen reader still reads the reason WITH the control it
|
|
551
|
+
// belongs to — the sentence moves, it does not disappear, which
|
|
552
|
+
// is the difference between pooling and hiding.
|
|
553
|
+
//
|
|
554
|
+
// The gate itself is open: this pane's own refusal is `bag.gate`
|
|
555
|
+
// and it is answered far above, as one state.
|
|
556
|
+
<PaneGate
|
|
557
|
+
gate={actionAvailable()}
|
|
558
|
+
testId="listings-mine-reasons"
|
|
559
|
+
>
|
|
560
|
+
<List
|
|
561
|
+
dataSource={[...rows]}
|
|
562
|
+
rowKey={(row) => row.id}
|
|
563
|
+
renderItem={renderRow}
|
|
564
|
+
/>
|
|
565
|
+
</PaneGate>
|
|
400
566
|
),
|
|
401
567
|
})}
|
|
402
568
|
|
|
@@ -4,32 +4,81 @@ import {
|
|
|
4
4
|
firstBlock,
|
|
5
5
|
} from "@stapel/core";
|
|
6
6
|
import type { ActionAvailability } from "@stapel/core";
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
ListingLifecycleStatus,
|
|
9
|
+
ListingOwnerTransition,
|
|
10
|
+
} from "../api/types.js";
|
|
8
11
|
import {
|
|
9
12
|
useArchiveListing,
|
|
10
13
|
useCompleteListing,
|
|
11
14
|
useDeleteListing,
|
|
15
|
+
useTransitionListing,
|
|
12
16
|
} from "../model/mutations.js";
|
|
13
|
-
import { canDelete, canTransition } from "../model/transitions.js";
|
|
17
|
+
import { canDelete, canTransition, ownerMoves } from "../model/transitions.js";
|
|
14
18
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
15
19
|
import { useMandateGate } from "./useMandateGate.js";
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
* The
|
|
19
|
-
*
|
|
22
|
+
* The lifecycle moves an owner can actually request, each behind a gate that
|
|
23
|
+
* states its reason.
|
|
24
|
+
*
|
|
25
|
+
* ── The server says what is offerable; nothing here re-derives it ──────────
|
|
26
|
+
*
|
|
27
|
+
* `MyListingCard.available_transitions` (stapel-listings 0.20.0) is the
|
|
28
|
+
* seller's half of the state machine, reported for ONE row by the module that
|
|
29
|
+
* owns the machine — and it is the same list `POST {id}/transition/`
|
|
30
|
+
* validates against, so the set a person is offered and the set the server
|
|
31
|
+
* takes are one object rather than two that agree today.
|
|
32
|
+
*
|
|
33
|
+
* Before it, this hook offered a FIXED three (archive, complete, delete) and
|
|
34
|
+
* gated them against a local copy of the table. That shipped two defects at
|
|
35
|
+
* once, and both were live:
|
|
36
|
+
*
|
|
37
|
+
* - **Buttons that did nothing.** `canTransition(from, to)` answers true for
|
|
38
|
+
* `from === to` — correctly, since the server returns early on a
|
|
39
|
+
* same-status move — so a SOLD row's "Mark sold" and an ARCHIVED row's
|
|
40
|
+
* "Archive" were enabled, clickable and inert. Two of four controls on
|
|
41
|
+
* every archived row.
|
|
42
|
+
* - **No way back.** The whole vocabulary was `archive` and `complete`, and
|
|
43
|
+
* both are EXITS. A seller who marked something sold by a misclick could
|
|
44
|
+
* not un-sell it: `SOLD → PUBLISHED` was in the machine and had no route,
|
|
45
|
+
* so the honest answer the cabinet had left was Delete and start again.
|
|
46
|
+
*
|
|
47
|
+
* {@link ListingActionsBag.moves} is now the whole answer for a dashboard:
|
|
48
|
+
* whatever it holds is drawn, and a control that would be a no-op is not in
|
|
49
|
+
* it. `ownerMoves` supplies the fallback for a surface that holds a bare
|
|
50
|
+
* `status` (a detail pane, a row read before 0.20.0) — never as a second
|
|
51
|
+
* opinion where the card carries the field.
|
|
20
52
|
*
|
|
21
53
|
* ── The mirror is UX, the 409 is the verdict ───────────────────────────────
|
|
22
54
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the click. The server still decides — `transition_to` raises and the view
|
|
27
|
-
* answers 409 `error.409.invalid_listing_transition` with
|
|
28
|
-
* `params.from_status` — and that refusal is rendered as the named thing it
|
|
29
|
-
* is. The mirror may never block what the server would allow, which is why it
|
|
30
|
-
* is a copy of the table and not a summary of it.
|
|
55
|
+
* The server still decides — `transition_to` raises and the view answers 409
|
|
56
|
+
* `error.409.invalid_listing_transition` with `params.from_status` — and that
|
|
57
|
+
* refusal is rendered as the named thing it is.
|
|
31
58
|
*/
|
|
59
|
+
|
|
60
|
+
/** One move a seller may make from where the listing is now. */
|
|
61
|
+
export interface ListingMove {
|
|
62
|
+
/** Where it lands. The vocabulary `available_transitions` speaks. */
|
|
63
|
+
readonly to: ListingOwnerTransition;
|
|
64
|
+
/** The caption's i18n KEY — never a literal; this is a headless hook. */
|
|
65
|
+
readonly labelKey: string;
|
|
66
|
+
/** Mandate and in-flight only: the move itself is offered, or it would not
|
|
67
|
+
* be in this list. */
|
|
68
|
+
readonly gate: ActionAvailability;
|
|
69
|
+
/** A stable hook for a skin's `data-testid`, so a test names the MOVE
|
|
70
|
+
* rather than the position of a button in a wrapping row. */
|
|
71
|
+
readonly testId: string;
|
|
72
|
+
run(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
32
75
|
export interface ListingActionsBag {
|
|
76
|
+
/**
|
|
77
|
+
* Every move this listing's owner may make, in drawing order, and NOTHING
|
|
78
|
+
* else. Empty is a real answer (a row mid-load, a status with no owner
|
|
79
|
+
* edges) and a skin draws no action row for it.
|
|
80
|
+
*/
|
|
81
|
+
readonly moves: readonly ListingMove[];
|
|
33
82
|
readonly archive: ActionAvailability;
|
|
34
83
|
readonly complete: ActionAvailability;
|
|
35
84
|
readonly remove: ActionAvailability;
|
|
@@ -49,20 +98,73 @@ export interface ListingActionsBag {
|
|
|
49
98
|
doComplete(): void;
|
|
50
99
|
doRemove(): void;
|
|
51
100
|
readonly inFlight: boolean;
|
|
52
|
-
/** The last refusal from any of
|
|
101
|
+
/** The last refusal from any of them, in the one error dialect. */
|
|
53
102
|
readonly error: unknown;
|
|
54
103
|
}
|
|
55
104
|
|
|
105
|
+
/** What a caption says, keyed by where the move lands. */
|
|
106
|
+
const MOVE_LABEL: Readonly<Record<ListingOwnerTransition, string>> = {
|
|
107
|
+
published: LISTINGS_I18N_KEYS.moveToPublished,
|
|
108
|
+
pending: LISTINGS_I18N_KEYS.moveToPending,
|
|
109
|
+
paused: LISTINGS_I18N_KEYS.moveToPaused,
|
|
110
|
+
draft: LISTINGS_I18N_KEYS.moveToDraft,
|
|
111
|
+
// These two edges have had buttons since this pane existed. They keep their
|
|
112
|
+
// captions: renaming them now would be a change of wording dressed as a
|
|
113
|
+
// change of capability.
|
|
114
|
+
sold: LISTINGS_I18N_KEYS.mineComplete,
|
|
115
|
+
archived: LISTINGS_I18N_KEYS.mineArchive,
|
|
116
|
+
// In the vocabulary because the enum is the whole lifecycle; not in any
|
|
117
|
+
// seller's offered set (`rejected` and `blocked` are moderation's verdicts,
|
|
118
|
+
// `expired` is the clock's). Named anyway rather than left to a lookup that
|
|
119
|
+
// could return undefined.
|
|
120
|
+
rejected: LISTINGS_I18N_KEYS.statusRejected,
|
|
121
|
+
blocked: LISTINGS_I18N_KEYS.statusBlocked,
|
|
122
|
+
expired: LISTINGS_I18N_KEYS.statusExpired,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The caption for moving from *from* to *to*.
|
|
127
|
+
*
|
|
128
|
+
* One edge earns a second sentence: `EXPIRED → PENDING` is the same move as
|
|
129
|
+
* "send for review" asked at a different moment — nothing is wrong with the
|
|
130
|
+
* listing, its time simply ran out — and "Renew" is what a person came to the
|
|
131
|
+
* dashboard to do.
|
|
132
|
+
*/
|
|
133
|
+
function moveLabelKey(
|
|
134
|
+
from: ListingLifecycleStatus,
|
|
135
|
+
to: ListingOwnerTransition
|
|
136
|
+
): string {
|
|
137
|
+
if (from === "expired" && to === "pending") {
|
|
138
|
+
return LISTINGS_I18N_KEYS.moveRenew;
|
|
139
|
+
}
|
|
140
|
+
return MOVE_LABEL[to];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface UseListingActionsOptions {
|
|
144
|
+
/**
|
|
145
|
+
* The row's own `available_transitions`, when the caller has a
|
|
146
|
+
* {@link MyListingCard} rather than a bare status. THE answer where it is
|
|
147
|
+
* present; see this file's header.
|
|
148
|
+
*/
|
|
149
|
+
readonly available?: readonly ListingOwnerTransition[] | undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
56
152
|
export function useListingActions(
|
|
57
153
|
id: number,
|
|
58
|
-
status: ListingLifecycleStatus | undefined
|
|
154
|
+
status: ListingLifecycleStatus | undefined,
|
|
155
|
+
options: UseListingActionsOptions = {}
|
|
59
156
|
): ListingActionsBag {
|
|
60
157
|
const mandate = useMandateGate();
|
|
61
158
|
const archive = useArchiveListing();
|
|
62
159
|
const complete = useCompleteListing();
|
|
63
160
|
const remove = useDeleteListing();
|
|
161
|
+
const move = useTransitionListing();
|
|
64
162
|
|
|
65
|
-
const inFlight =
|
|
163
|
+
const inFlight =
|
|
164
|
+
archive.isPending ||
|
|
165
|
+
complete.isPending ||
|
|
166
|
+
remove.isPending ||
|
|
167
|
+
move.isPending;
|
|
66
168
|
const busy: ActionAvailability = inFlight
|
|
67
169
|
? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
|
|
68
170
|
: actionAvailable();
|
|
@@ -94,7 +196,26 @@ export function useListingActions(
|
|
|
94
196
|
: actionAvailable()
|
|
95
197
|
);
|
|
96
198
|
|
|
199
|
+
// Mandate and in-flight, and nothing else: a move that is IN this list is
|
|
200
|
+
// one the server has said it will take, so there is no third gate to
|
|
201
|
+
// consult. The whole point of `available_transitions` is that "may I?" was
|
|
202
|
+
// answered before the button was drawn.
|
|
203
|
+
const moveGate = firstBlock(mandate, busy);
|
|
204
|
+
const moves: readonly ListingMove[] =
|
|
205
|
+
status === undefined
|
|
206
|
+
? []
|
|
207
|
+
: ownerMoves(status, options.available).map((to) => ({
|
|
208
|
+
to,
|
|
209
|
+
labelKey: moveLabelKey(status, to),
|
|
210
|
+
gate: moveGate,
|
|
211
|
+
testId: `listings-mine-move-${to}`,
|
|
212
|
+
run: () => {
|
|
213
|
+
if (moveGate.available) move.mutate({ id, to });
|
|
214
|
+
},
|
|
215
|
+
}));
|
|
216
|
+
|
|
97
217
|
return {
|
|
218
|
+
moves,
|
|
98
219
|
archive: archiveGate,
|
|
99
220
|
complete: completeGate,
|
|
100
221
|
remove: removeGate,
|
|
@@ -115,6 +236,6 @@ export function useListingActions(
|
|
|
115
236
|
if (removeGate.available) remove.mutate(id);
|
|
116
237
|
},
|
|
117
238
|
inFlight,
|
|
118
|
-
error: archive.error ?? complete.error ?? remove.error,
|
|
239
|
+
error: archive.error ?? complete.error ?? move.error ?? remove.error,
|
|
119
240
|
};
|
|
120
241
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -38,7 +38,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
38
38
|
"error.404.listing_not_found": "No se encontró el anuncio",
|
|
39
39
|
"error.409.already_favorited": "El anuncio ya está en favoritos",
|
|
40
40
|
"error.409.invalid_listing_transition":
|
|
41
|
-
"
|
|
41
|
+
"Desde su estado actual, el anuncio no se puede mover así",
|
|
42
42
|
"error.409.listing_cannot_delete_active":
|
|
43
43
|
"Archívalo primero — un anuncio a la venta no se puede borrar",
|
|
44
44
|
|
|
@@ -194,6 +194,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
194
194
|
"listings.mine.archive": "Archivar",
|
|
195
195
|
"listings.mine.complete": "Marcar como vendido",
|
|
196
196
|
"listings.mine.delete": "Borrar",
|
|
197
|
+
"listings.mine.move.published": "Publicar de nuevo",
|
|
198
|
+
"listings.mine.move.pending": "Enviar a revisión",
|
|
199
|
+
"listings.mine.move.paused": "Pausar",
|
|
200
|
+
"listings.mine.move.draft": "Volver a borradores",
|
|
201
|
+
"listings.mine.move.renew": "Renovar",
|
|
202
|
+
"listings.mine.view": "Ver",
|
|
197
203
|
"listings.mine.delete_confirm_title": "¿Borrar este anuncio?",
|
|
198
204
|
"listings.mine.delete_confirm_body":
|
|
199
205
|
"Desaparece de tu panel y no se puede recuperar. Archivarlo lo conserva.",
|
|
@@ -219,7 +225,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
219
225
|
"listings.blocked.mandate_unknown":
|
|
220
226
|
"No pudimos comprobar tu cuenta, así que no adivinamos si puedes hacerlo",
|
|
221
227
|
"listings.blocked.transition":
|
|
222
|
-
"
|
|
228
|
+
"Desde su estado actual, el anuncio no se puede mover así",
|
|
223
229
|
"listings.blocked.delete_active":
|
|
224
230
|
"Archívalo primero — un anuncio a la venta no se puede borrar",
|
|
225
231
|
"listings.blocked.in_flight": "Un momento — eso ya está en marcha",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -211,6 +211,26 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
211
211
|
mineArchive: "listings.mine.archive",
|
|
212
212
|
mineComplete: "listings.mine.complete",
|
|
213
213
|
mineDelete: "listings.mine.delete",
|
|
214
|
+
/**
|
|
215
|
+
* One caption per MOVE the seller may make, keyed by where the move lands
|
|
216
|
+
* — the vocabulary `available_transitions` speaks.
|
|
217
|
+
*
|
|
218
|
+
* `archived` and `sold` reuse the two captions above, because those two
|
|
219
|
+
* edges have had buttons since this pane existed and renaming them now
|
|
220
|
+
* would be a change of wording pretending to be a change of capability.
|
|
221
|
+
* The rest are the way BACK, which had no route until stapel-listings
|
|
222
|
+
* 0.20.0 and therefore no caption: a seller who marked something sold by
|
|
223
|
+
* mistake could only start again.
|
|
224
|
+
*/
|
|
225
|
+
moveToPublished: "listings.mine.move.published",
|
|
226
|
+
moveToPending: "listings.mine.move.pending",
|
|
227
|
+
moveToPaused: "listings.mine.move.paused",
|
|
228
|
+
moveToDraft: "listings.mine.move.draft",
|
|
229
|
+
/** EXPIRED → PENDING is the same edge as "submit", asked at a different
|
|
230
|
+
* moment: nothing is wrong with the listing, its time simply ran out. */
|
|
231
|
+
moveRenew: "listings.mine.move.renew",
|
|
232
|
+
/** Open this listing the way a buyer sees it. */
|
|
233
|
+
mineView: "listings.mine.view",
|
|
214
234
|
/** Deleting is irreversible, so it asks — through the shared SkinConfirm,
|
|
215
235
|
* which is a bottom sheet on a phone. */
|
|
216
236
|
mineDeleteConfirmTitle: "listings.mine.delete_confirm_title",
|
|
@@ -423,6 +443,12 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
423
443
|
"listings.mine.archive": "Archive",
|
|
424
444
|
"listings.mine.complete": "Mark sold",
|
|
425
445
|
"listings.mine.delete": "Delete",
|
|
446
|
+
"listings.mine.move.published": "Publish again",
|
|
447
|
+
"listings.mine.move.pending": "Send for review",
|
|
448
|
+
"listings.mine.move.paused": "Pause",
|
|
449
|
+
"listings.mine.move.draft": "Move back to drafts",
|
|
450
|
+
"listings.mine.move.renew": "Renew",
|
|
451
|
+
"listings.mine.view": "View",
|
|
426
452
|
"listings.mine.delete_confirm_title": "Delete this listing?",
|
|
427
453
|
"listings.mine.delete_confirm_body":
|
|
428
454
|
"It disappears from your dashboard and cannot be brought back. Archiving keeps it.",
|
|
@@ -441,8 +467,33 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
441
467
|
"Your account cannot do this yet — finish setting it up first",
|
|
442
468
|
"listings.blocked.mandate_unknown":
|
|
443
469
|
"We could not check your account, so we are not guessing whether you may do this",
|
|
470
|
+
/**
|
|
471
|
+
* THE REFUSAL DOES NOT QUOTE THE STATE'S CODE NAME.
|
|
472
|
+
*
|
|
473
|
+
* Both sentences below used to carry `{from_status}`, and what landed in
|
|
474
|
+
* that slot was the wire value: a live cabinet told a seller, in Russian,
|
|
475
|
+
* that "a listing in status 'draft' cannot be moved that way" — and then
|
|
476
|
+
* the same sentence with 'archived' — while the row's own status tag, two
|
|
477
|
+
* lines above, said "Draft" and "Archived" in the reader's own words.
|
|
478
|
+
* The translation existed; it simply was not the thing being interpolated.
|
|
479
|
+
*
|
|
480
|
+
* The placeholder is gone rather than fed a translated value, because it
|
|
481
|
+
* was never adding information the screen did not already carry: the status
|
|
482
|
+
* is named, in words, beside every control this refusal appears under. What
|
|
483
|
+
* the reader needs is which MOVE is impossible, and that is the control
|
|
484
|
+
* they are looking at.
|
|
485
|
+
*
|
|
486
|
+
* The SERVER's own 409 (`error.409.invalid_listing_transition`) carries the
|
|
487
|
+
* same placeholder and is reworded in the ru and es bundles beside this
|
|
488
|
+
* one, where a real reader met it. It is deliberately NOT overridden in the
|
|
489
|
+
* English bundle: that bundle spreads the generated backend texts whole,
|
|
490
|
+
* and a hand-written entry for one error code makes this pair's catalogue
|
|
491
|
+
* claim a namespace it does not own — `stapel/i18n-locale-parity` says so
|
|
492
|
+
* and is right. The refusal is in any case now close to unreachable: the
|
|
493
|
+
* dashboard offers only the moves the server declared.
|
|
494
|
+
*/
|
|
444
495
|
"listings.blocked.transition":
|
|
445
|
-
"
|
|
496
|
+
"This listing cannot be moved that way from where it is",
|
|
446
497
|
"listings.blocked.delete_active":
|
|
447
498
|
"Archive it first — a listing that is on sale cannot be deleted",
|
|
448
499
|
"listings.blocked.in_flight": "One moment — that is already under way",
|