@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/api/generated/schema.d.ts +125 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/listingsApi.d.ts +20 -1
  5. package/dist/api/listingsApi.d.ts.map +1 -1
  6. package/dist/api/listingsApi.js +1 -0
  7. package/dist/api/listingsApi.js.map +1 -1
  8. package/dist/api/types.d.ts +32 -2
  9. package/dist/api/types.d.ts.map +1 -1
  10. package/dist/api/types.js +7 -0
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +54 -5
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  16. package/dist/default/ListingSerpCard.js +14 -4
  17. package/dist/default/ListingSerpCard.js.map +1 -1
  18. package/dist/default/MyListingsPane.d.ts +19 -1
  19. package/dist/default/MyListingsPane.d.ts.map +1 -1
  20. package/dist/default/MyListingsPane.js +99 -12
  21. package/dist/default/MyListingsPane.js.map +1 -1
  22. package/dist/headless/ListingActions.d.ts +64 -13
  23. package/dist/headless/ListingActions.d.ts.map +1 -1
  24. package/dist/headless/ListingActions.js +60 -5
  25. package/dist/headless/ListingActions.js.map +1 -1
  26. package/dist/i18n/es.d.ts.map +1 -1
  27. package/dist/i18n/es.js +8 -2
  28. package/dist/i18n/es.js.map +1 -1
  29. package/dist/i18n/keys.d.ts +20 -0
  30. package/dist/i18n/keys.d.ts.map +1 -1
  31. package/dist/i18n/keys.js +52 -1
  32. package/dist/i18n/keys.js.map +1 -1
  33. package/dist/i18n/ru.d.ts.map +1 -1
  34. package/dist/i18n/ru.js +12 -2
  35. package/dist/i18n/ru.js.map +1 -1
  36. package/dist/index.d.ts +5 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -2
  39. package/dist/index.js.map +1 -1
  40. package/dist/model/mine.d.ts +20 -0
  41. package/dist/model/mine.d.ts.map +1 -1
  42. package/dist/model/mine.js +22 -0
  43. package/dist/model/mine.js.map +1 -1
  44. package/dist/model/mutations.d.ts +24 -1
  45. package/dist/model/mutations.d.ts.map +1 -1
  46. package/dist/model/mutations.js +28 -0
  47. package/dist/model/mutations.js.map +1 -1
  48. package/dist/model/status.d.ts.map +1 -1
  49. package/dist/model/status.js +50 -12
  50. package/dist/model/status.js.map +1 -1
  51. package/dist/model/transitions.d.ts +38 -1
  52. package/dist/model/transitions.d.ts.map +1 -1
  53. package/dist/model/transitions.js +73 -1
  54. package/dist/model/transitions.js.map +1 -1
  55. package/llms.txt +4 -2
  56. package/manifest.json +26 -2
  57. package/nav-manifest.json +1 -1
  58. package/package.json +6 -6
  59. package/src/analytics/generated/events.json +1 -1
  60. package/src/api/generated/schema.ts +125 -2
  61. package/src/api/listingsApi.ts +27 -0
  62. package/src/api/types.ts +47 -2
  63. package/src/default/ListingCard.tsx +73 -5
  64. package/src/default/ListingSerpCard.tsx +17 -4
  65. package/src/default/MyListingsPane.tsx +205 -39
  66. package/src/headless/ListingActions.tsx +137 -16
  67. package/src/i18n/es.ts +8 -2
  68. package/src/i18n/keys.ts +52 -1
  69. package/src/i18n/ru.ts +12 -2
  70. package/src/index.ts +19 -3
  71. package/src/model/mine.ts +23 -0
  72. package/src/model/mutations.ts +41 -0
  73. package/src/model/status.ts +51 -12
  74. 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
- <ListingPhoto
136
- imageRef={cover}
137
- alt={heading ?? `#${String(props.listing.id)}`}
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
- <Typography.Text strong ellipsis data-testid="listings-mine-title">
145
- {heading ?? `#${String(props.listing.id)}`}
146
- </Typography.Text>
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: four actions do not fit across 390px and a
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
- <GatedButton
178
- gate={actions.complete}
179
- size="small"
180
- testId="listings-mine-complete"
181
- data-analytics="none"
182
- data-analytics-reason="business action host app wraps with its own tracked()"
183
- onClick={actions.doComplete}
184
- >
185
- {t(LISTINGS_I18N_KEYS.mineComplete)}
186
- </GatedButton>
187
- <GatedButton
188
- gate={actions.archive}
189
- size="small"
190
- testId="listings-mine-archive"
191
- data-analytics="none"
192
- data-analytics-reason="business action — host app wraps with its own tracked()"
193
- onClick={actions.doArchive}
194
- >
195
- {t(LISTINGS_I18N_KEYS.mineArchive)}
196
- </GatedButton>
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
- <List
396
- dataSource={[...rows]}
397
- rowKey={(row) => row.id}
398
- renderItem={renderRow}
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 { ListingLifecycleStatus } from "../api/types.js";
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 three lifecycle moves an owner can actually request, each behind a gate
19
- * that states its reason.
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
- * `LISTING_TRANSITIONS` (`model/transitions.ts`) is a copy of the server's
24
- * whitelist, and it exists so a control can be switched off WITH a sentence:
25
- * "a listing that is sold cannot be archived that way" beats a toast after
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 the three, in the one error dialect. */
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 = archive.isPending || complete.isPending || remove.isPending;
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
- "Un anuncio en estado «{from_status}» no se puede mover así",
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
- "Un anuncio en estado «{from_status}» no se puede mover así",
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
- "A listing that is {from_status} cannot be moved that way",
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",