@stapel/listings-react 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +63 -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 +26 -9
  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 +4 -4
  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 +27 -9
  74. package/src/model/transitions.ts +80 -1
package/src/i18n/ru.ts CHANGED
@@ -53,8 +53,12 @@ export const listingsI18nBundleRu: I18nDictionary = {
53
53
  "error.403.listing_not_owner": "Это не ваше объявление",
54
54
  "error.404.listing_not_found": "Объявление не найдено",
55
55
  "error.409.already_favorited": "Объявление уже в избранном",
56
+ // No `{from_status}`: what landed in that slot was the wire value
57
+ // ('draft', 'archived'), and the status is already named in the reader's
58
+ // own words beside the control. See the note on this pair of keys in
59
+ // keys.ts.
56
60
  "error.409.invalid_listing_transition":
57
- "Объявление в статусе «{from_status}» так перевести нельзя",
61
+ "Из текущего состояния объявление так перевести нельзя",
58
62
  "error.409.listing_cannot_delete_active":
59
63
  "Сначала снимите объявление с публикации — активное удалить нельзя",
60
64
 
@@ -213,6 +217,12 @@ export const listingsI18nBundleRu: I18nDictionary = {
213
217
  "listings.mine.archive": "В архив",
214
218
  "listings.mine.complete": "Отметить проданным",
215
219
  "listings.mine.delete": "Удалить",
220
+ "listings.mine.move.published": "Опубликовать снова",
221
+ "listings.mine.move.pending": "Отправить на проверку",
222
+ "listings.mine.move.paused": "Снять с публикации",
223
+ "listings.mine.move.draft": "Вернуть в черновики",
224
+ "listings.mine.move.renew": "Продлить",
225
+ "listings.mine.view": "Посмотреть",
216
226
  "listings.mine.delete_confirm_title": "Удалить объявление?",
217
227
  "listings.mine.delete_confirm_body":
218
228
  "Оно исчезнет из кабинета, и вернуть его будет нельзя. Архив сохраняет его.",
@@ -240,7 +250,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
240
250
  "listings.blocked.mandate_unknown":
241
251
  "Не удалось проверить ваш аккаунт, поэтому мы не угадываем, можно ли вам это",
242
252
  "listings.blocked.transition":
243
- "Объявление в статусе «{from_status}» так перевести нельзя",
253
+ "Из текущего состояния объявление так перевести нельзя",
244
254
  "listings.blocked.delete_active":
245
255
  "Сначала уберите в архив — объявление в продаже удалить нельзя",
246
256
  "listings.blocked.in_flight": "Секунду — это уже выполняется",
package/src/index.ts CHANGED
@@ -91,6 +91,7 @@ export type {
91
91
  ListingFeatureView,
92
92
  ListingLifecycleStatus,
93
93
  ListingModerationStatus,
94
+ ListingOwnerTransition,
94
95
  ListingPageParams,
95
96
  ListingStatusInfo,
96
97
  MyCounters,
@@ -120,7 +121,13 @@ export type {
120
121
  ModerationNotice,
121
122
  MyListingsTab,
122
123
  } from "./model/status.js";
123
- export { LISTING_TRANSITIONS, canDelete, canTransition } from "./model/transitions.js";
124
+ export {
125
+ LISTING_TRANSITIONS,
126
+ OWNER_TRANSITIONS,
127
+ canDelete,
128
+ canTransition,
129
+ ownerMoves,
130
+ } from "./model/transitions.js";
124
131
 
125
132
  // ── model: the owner's own rows ──────────────────────────────────────────────
126
133
  export { defaultMyListingsSource } from "./model/mineSource.js";
@@ -219,8 +226,13 @@ export {
219
226
  useFavoriteListing,
220
227
  usePublishListing,
221
228
  useSaveDraft,
229
+ useTransitionListing,
230
+ } from "./model/mutations.js";
231
+ export type {
232
+ FavoriteInput,
233
+ ListingTransitionInput,
234
+ SaveDraftInput,
222
235
  } from "./model/mutations.js";
223
- export type { FavoriteInput, SaveDraftInput } from "./model/mutations.js";
224
236
 
225
237
  // ── flows (zero-flow shim — stapel-listings annotates none) ──────────────────
226
238
  export { LISTINGS_FLOWS, flowEndpoints } from "./flows/registry.js";
@@ -275,7 +287,11 @@ export type {
275
287
  UseFavoritesOptions,
276
288
  } from "./headless/Favorites.js";
277
289
  export { useListingActions } from "./headless/ListingActions.js";
278
- export type { ListingActionsBag } from "./headless/ListingActions.js";
290
+ export type {
291
+ ListingActionsBag,
292
+ ListingMove,
293
+ UseListingActionsOptions,
294
+ } from "./headless/ListingActions.js";
279
295
 
280
296
  // ── nav manifest (the pair's public surface declaration) ─────────────────────
281
297
  export { ACCOUNT_ROOT_ID, navEntries } from "./nav/manifest.js";
package/src/model/mine.ts CHANGED
@@ -68,3 +68,26 @@ export function myListingImages(row: MyListingCard): readonly string[] {
68
68
  export function showsDraft(row: MyListingCard): boolean {
69
69
  return empty(row.title) && !empty(row.title_draft);
70
70
  }
71
+
72
+ /**
73
+ * Has this listing ever been in front of anybody?
74
+ *
75
+ * The server's own predicate, and it is the one that decides whether a row
76
+ * has a page to link to: `moderation_status` defaults to `NOT_SUBMITTED`
77
+ * (stapel-listings 0.20.0) and `publish_listing` sets `PENDING`
78
+ * unconditionally, so DRAFT + NOT_SUBMITTED means "nobody has ever pressed
79
+ * publish on this" and every other combination means somebody has.
80
+ *
81
+ * NOT {@link showsDraft}: that asks which HALF of the twin a row is showing,
82
+ * which is a different question and answers `false` for an empty draft that
83
+ * has no `title_draft` either — a row with no title at all would have been
84
+ * given a link to a page that does not exist.
85
+ *
86
+ * A row from a server older than 0.20.0 has no `moderation_status` of
87
+ * `not_submitted` to report, so it falls on the "has been submitted" side and
88
+ * keeps its link: an extra link to a draft its owner can read is a smaller
89
+ * harm than withholding one from a listing that is live.
90
+ */
91
+ export function neverSubmitted(row: MyListingCard): boolean {
92
+ return row.status === "draft" && row.moderation_status === "not_submitted";
93
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  ListingActionResponse,
8
8
  ListingDraft,
9
9
  ListingDraftPatch,
10
+ ListingOwnerTransition,
10
11
  PublishResponse,
11
12
  } from "../api/types.js";
12
13
  import { useListingsApi } from "./context.js";
@@ -162,6 +163,46 @@ export function useCompleteListing(): UseMutationResult<
162
163
  });
163
164
  }
164
165
 
166
+ /** What a lifecycle move carries: the row, and where it is going. */
167
+ export interface ListingTransitionInput {
168
+ readonly id: number;
169
+ readonly to: ListingOwnerTransition;
170
+ }
171
+
172
+ /**
173
+ * Move a listing along ONE edge of the seller's half of the state machine
174
+ * (`POST listings/{id}/transition/`, stapel-listings 0.20.0).
175
+ *
176
+ * The generalisation of {@link useArchiveListing} and
177
+ * {@link useCompleteListing}, and the reason it exists is that those two were
178
+ * the whole owner API for two releases: both are EXITS, so a listing that
179
+ * reached SOLD, ARCHIVED, PAUSED or EXPIRED had no call that would move it
180
+ * again and a cabinet honestly had nothing to offer but Delete. The way back
181
+ * — SOLD or PAUSED to PUBLISHED, ARCHIVED to DRAFT, EXPIRED renewed — was in
182
+ * `LISTING_TRANSITIONS` the entire time and simply had no route.
183
+ *
184
+ * Same 409 as the two named exits (`error.409.invalid_listing_transition`
185
+ * with `params.from_status`), and the same invalidation: a move changes which
186
+ * tab the row belongs to, so the counters and every `my/listings` page go
187
+ * with it.
188
+ */
189
+ export function useTransitionListing(): UseMutationResult<
190
+ ListingActionResponse,
191
+ StapelApiError,
192
+ ListingTransitionInput
193
+ > {
194
+ const api = useListingsApi();
195
+ const queryClient = useQueryClient();
196
+ return useMutation({
197
+ mutationFn: (input: ListingTransitionInput) =>
198
+ api.transition(input.id, input.to),
199
+ retry: false,
200
+ onSuccess: (_data, input) => {
201
+ invalidateListing(queryClient, input.id);
202
+ },
203
+ });
204
+ }
205
+
165
206
  /** Soft-delete. Refused with `error.409.listing_cannot_delete_active` while
166
207
  * the listing is PUBLISHED or PENDING. */
167
208
  export function useDeleteListing(): UseMutationResult<
@@ -168,6 +168,11 @@ export function moderationNotice(
168
168
  return undefined;
169
169
  }
170
170
 
171
+ // NOTHING HAS BEEN SUBMITTED, so there is nothing for moderation to say.
172
+ // The lifecycle already calls this row a draft, and a second line beside
173
+ // that word can only add a claim the data does not support.
174
+ if (moderationStatus === "not_submitted") return undefined;
175
+
171
176
  if (moderationStatus === "pending") {
172
177
  if (live) {
173
178
  return {
@@ -207,15 +212,28 @@ export function moderationNotice(
207
212
  };
208
213
  }
209
214
 
210
- // rejected and it matters whether the lifecycle followed the verdict.
211
- return {
212
- moderationStatus,
213
- messageKey: live
214
- ? LISTINGS_I18N_KEYS.moderationRejectedStillLive
215
- : LISTINGS_I18N_KEYS.moderationRejected,
216
- tone: "warning",
217
- liveDuringReview: live,
218
- };
215
+ if (moderationStatus === "rejected") {
216
+ // …and it matters whether the lifecycle followed the verdict.
217
+ return {
218
+ moderationStatus,
219
+ messageKey: live
220
+ ? LISTINGS_I18N_KEYS.moderationRejectedStillLive
221
+ : LISTINGS_I18N_KEYS.moderationRejected,
222
+ tone: "warning",
223
+ liveDuringReview: live,
224
+ };
225
+ }
226
+
227
+ // AN UNKNOWN VALUE SAYS NOTHING. This used to be the `rejected` branch's
228
+ // fallthrough, which made "not one of the three I know" mean "a moderator
229
+ // turned this down" — and then stapel-listings 0.20.0 added a fourth value
230
+ // and made it the DEFAULT, so every freshly created draft in the cabinet
231
+ // was announced as "A moderator turned this listing down. Fix it and send
232
+ // it again." to a person who had submitted nothing (D166). A row read from a
233
+ // server newer than this pair must degrade to silence, never to a verdict:
234
+ // the lifecycle caption beside it is still true, and an accusation is the
235
+ // one thing a client must not invent.
236
+ return undefined;
219
237
  }
220
238
 
221
239
  /** Both halves at once — what a dashboard row and a detail header render. */
@@ -44,7 +44,13 @@ export const LISTING_TRANSITIONS: Readonly<
44
44
  };
45
45
 
46
46
  /** Would the server accept this move? A same-status move is a no-op the
47
- * server returns early on, so it counts as allowed. */
47
+ * server returns early on, so it counts as allowed.
48
+ *
49
+ * "Accept" and "offer" are NOT the same question, and conflating them is the
50
+ * whole of defect D182: `canTransition("sold", "sold")` is true, so a SOLD
51
+ * row's "Mark sold" button was enabled, clickable, and did nothing — and so
52
+ * was an ARCHIVED row's "Archive". Ask {@link ownerMoves} what to OFFER.
53
+ */
48
54
  export function canTransition(
49
55
  from: ListingLifecycleStatus,
50
56
  to: ListingLifecycleStatus
@@ -52,6 +58,79 @@ export function canTransition(
52
58
  return from === to || LISTING_TRANSITIONS[from].includes(to);
53
59
  }
54
60
 
61
+ /**
62
+ * The SELLER's half of the machine above, mirrored from
63
+ * `stapel_listings.models.OWNER_TRANSITIONS` (0.20.0).
64
+ *
65
+ * A subset of {@link LISTING_TRANSITIONS}, and what it leaves out is as
66
+ * load-bearing as what it keeps: `pending → published` and
67
+ * `blocked → published` are moderation's decisions, `published → blocked` is
68
+ * a takedown, and none of the three is a thing one does to oneself. Putting
69
+ * them here would make the cabinet a self-service publish gate.
70
+ *
71
+ * ── This is a FALLBACK, and the card's own field is the answer ─────────────
72
+ *
73
+ * `MyListingCard.available_transitions` is the server reporting this table
74
+ * for one row, and where it is present nothing here is consulted: the set a
75
+ * client offers and the set the route accepts are then one object rather than
76
+ * two that agree today. This copy is for a surface that holds only a `status`
77
+ * — a detail pane, a listing loaded before 0.20.0 — where the alternative is
78
+ * offering nothing at all.
79
+ */
80
+ export const OWNER_TRANSITIONS: Readonly<
81
+ Record<ListingLifecycleStatus, readonly ListingLifecycleStatus[]>
82
+ > = {
83
+ draft: ["pending", "archived"],
84
+ pending: ["draft", "archived"],
85
+ published: ["paused", "sold", "archived"],
86
+ paused: ["published", "archived"],
87
+ expired: ["pending", "archived"],
88
+ sold: ["published", "archived"],
89
+ rejected: ["draft", "archived"],
90
+ blocked: ["draft", "archived"],
91
+ archived: ["draft"],
92
+ };
93
+
94
+ /**
95
+ * The order the moves are DRAWN in, wherever they came from.
96
+ *
97
+ * A row's action set changes as the listing moves through its life, and a set
98
+ * that also reorders itself makes a person hunt for the button they used
99
+ * yesterday. Forward first (back on sale, then submitted, then paused), exits
100
+ * last — so "Archive" is never where "Publish again" was a moment ago.
101
+ */
102
+ const MOVE_ORDER: readonly ListingLifecycleStatus[] = [
103
+ "published",
104
+ "pending",
105
+ "paused",
106
+ "sold",
107
+ "draft",
108
+ "rejected",
109
+ "blocked",
110
+ "expired",
111
+ "archived",
112
+ ];
113
+
114
+ /**
115
+ * What to OFFER a seller looking at a row in *status*, in drawing order.
116
+ *
117
+ * Never the status the row is already in: a control that would leave the
118
+ * world exactly as it found it is not an action, and four of them down a
119
+ * dashboard taught a seller that half the buttons on this screen do nothing.
120
+ *
121
+ * Pass the card's own `available_transitions` when it has one — see
122
+ * {@link OWNER_TRANSITIONS} for why the mirror is the second-best answer.
123
+ */
124
+ export function ownerMoves(
125
+ status: ListingLifecycleStatus | undefined,
126
+ available?: readonly ListingLifecycleStatus[] | undefined
127
+ ): readonly ListingLifecycleStatus[] {
128
+ if (status === undefined) return [];
129
+ const offered = available ?? OWNER_TRANSITIONS[status];
130
+ const set = new Set(offered.filter((to) => to !== status));
131
+ return MOVE_ORDER.filter((to) => set.has(to));
132
+ }
133
+
55
134
  /**
56
135
  * Deleting is not a transition and has its own rule
57
136
  * (`views.destroy`): a PUBLISHED or PENDING listing is refused with