@stapel/listings-react 0.22.0 → 0.23.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 (50) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +17 -6
  3. package/dist/api/listingsApi.d.ts +25 -0
  4. package/dist/api/listingsApi.d.ts.map +1 -1
  5. package/dist/api/listingsApi.js +1 -0
  6. package/dist/api/listingsApi.js.map +1 -1
  7. package/dist/default/FavoritesPane.d.ts +18 -2
  8. package/dist/default/FavoritesPane.d.ts.map +1 -1
  9. package/dist/default/FavoritesPane.js +3 -3
  10. package/dist/default/FavoritesPane.js.map +1 -1
  11. package/dist/default/MyListingsPane.d.ts +22 -2
  12. package/dist/default/MyListingsPane.d.ts.map +1 -1
  13. package/dist/default/MyListingsPane.js +1 -1
  14. package/dist/default/MyListingsPane.js.map +1 -1
  15. package/dist/default/index.d.ts +2 -2
  16. package/dist/default/index.d.ts.map +1 -1
  17. package/dist/default/index.js.map +1 -1
  18. package/dist/headless/ListingComposer.d.ts +16 -10
  19. package/dist/headless/ListingComposer.d.ts.map +1 -1
  20. package/dist/headless/ListingComposer.js +23 -4
  21. package/dist/headless/ListingComposer.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/model/queries.d.ts +15 -1
  27. package/dist/model/queries.d.ts.map +1 -1
  28. package/dist/model/queries.js +20 -0
  29. package/dist/model/queries.js.map +1 -1
  30. package/dist/model/queryKeys.d.ts +4 -0
  31. package/dist/model/queryKeys.d.ts.map +1 -1
  32. package/dist/model/queryKeys.js +1 -0
  33. package/dist/model/queryKeys.js.map +1 -1
  34. package/dist/model/status.d.ts.map +1 -1
  35. package/dist/model/status.js +26 -5
  36. package/dist/model/status.js.map +1 -1
  37. package/llms.txt +2 -1
  38. package/manifest.json +11 -1
  39. package/nav-manifest.json +1 -1
  40. package/package.json +3 -3
  41. package/src/analytics/generated/events.json +1 -1
  42. package/src/api/listingsApi.ts +30 -0
  43. package/src/default/FavoritesPane.tsx +20 -5
  44. package/src/default/MyListingsPane.tsx +23 -5
  45. package/src/default/index.ts +2 -1
  46. package/src/headless/ListingComposer.tsx +42 -17
  47. package/src/index.ts +1 -0
  48. package/src/model/queries.ts +25 -0
  49. package/src/model/queryKeys.ts +5 -0
  50. package/src/model/status.ts +26 -5
@@ -22,7 +22,7 @@ import type {
22
22
  PublishResponse,
23
23
  } from "../api/types.js";
24
24
  import { useListingsRuntime } from "../model/context.js";
25
- import { useListing } from "../model/queries.js";
25
+ import { useListing, useListingDraft } from "../model/queries.js";
26
26
  import {
27
27
  useCreateDraft,
28
28
  usePublishListing,
@@ -32,6 +32,7 @@ import {
32
32
  createDraftBody,
33
33
  draftPatchFromValues,
34
34
  draftValuesFromDetail,
35
+ draftValuesFromWire,
35
36
  droppedFeatureSlugs,
36
37
  emptyDraftValues,
37
38
  retainKnownFeatureValues,
@@ -82,17 +83,23 @@ import { useMandateGate } from "./useMandateGate.js";
82
83
  * the headless half must judge renderability without importing a skin, so
83
84
  * the skin's `BUILTIN_VALUE_EDITOR_TYPES` is passed IN.
84
85
  *
85
- * ── Reopening a draft is NOT possible on stapel-listings 0.6.0 ─────────────
86
+ * ── Reopening a draft, before and after stapel-listings 0.21.1 ─────────────
86
87
  *
87
- * No read returns the `*_draft` twin: `GET /{pk}/` serializes the PUBLISHED
88
- * fields (`ListingDetailSerializer`), and `ListingDraftSerializer` appears
89
- * only as the RESPONSE of create / save-draft. So editing a live listing
90
- * works completely the composer seeds from the published half, which is
91
- * exactly what the person sees on the page — while a draft abandoned and
92
- * reopened later comes back empty. The composer says so
93
- * (`draftNotReadable`) instead of showing a blank form as if nothing had been
94
- * typed. Upstream ask, recorded in MODULE.md: put the draft twin on the
95
- * retrieve, or add `GET /{pk}/draft/`.
88
+ * `GET /{pk}/` serializes the PUBLISHED fields only (`ListingDetailSerializer`)
89
+ * and, before 0.21.1, `ListingDraftSerializer` appeared only as the RESPONSE
90
+ * of create / save-draft — no read returned the `*_draft` twin at all. So a
91
+ * draft abandoned and reopened came back empty, and a live listing's edit
92
+ * seeded from the published half instead (`draftValuesFromDetail`), which is
93
+ * what the person could actually see but not necessarily what they had last
94
+ * typed and not yet republished.
95
+ *
96
+ * 0.21.1 added the owner-only `GET /{pk}/draft/`, the exact `save-draft`
97
+ * response shape. The seeding effect now tries it first
98
+ * (`draftValuesFromWire`) for every reopened listing, live or not, and falls
99
+ * back to the published-half seed only when that read 404s — either nothing
100
+ * was ever saved, or the backend predates the route. `draftNotReadable`
101
+ * narrows to exactly that fallback case: the draft read failed AND the
102
+ * published half is empty too, so there is truly nothing to show.
96
103
  */
97
104
 
98
105
  /** The two members of `@stapel/cdn-react`'s upload bag this composer needs.
@@ -264,6 +271,7 @@ export function useListingComposer(
264
271
  const [saveRequest, setSaveRequest] = useState(0);
265
272
 
266
273
  const existing = useListing(options.listingId);
274
+ const listingDraft = useListingDraft(options.listingId);
267
275
  const createDraft = useCreateDraft();
268
276
  const saveDraft = useSaveDraft();
269
277
  const publishListing = usePublishListing();
@@ -272,21 +280,33 @@ export function useListingComposer(
272
280
  // dependency guard because re-seeding on a refetch would throw away
273
281
  // everything typed since — a background invalidation must never rewrite a
274
282
  // form somebody is in the middle of.
283
+ //
284
+ // The draft-twin read (`GET {id}/draft/`, 0.21.1) is tried FIRST
285
+ // (`draftValuesFromWire`); the published-half seed (`draftValuesFromDetail`)
286
+ // is the fallback, taken once that read has settled to anything other than
287
+ // success — a 404 because nothing was ever saved, a 404 because this
288
+ // backend predates the route, or any other failure a composer must not
289
+ // hang on. Waiting for `listingDraft` to settle too (not only `existing`)
290
+ // is what stops a live listing with unpublished edits from flashing its
291
+ // published content before the true draft lands.
275
292
  const seeded = useRef(false);
276
293
  useEffect(() => {
277
294
  if (seeded.current) return;
278
295
  const detail = existing.data;
279
296
  if (detail === undefined) return;
297
+ if (listingDraft.status === "pending") return;
280
298
  seeded.current = true;
281
299
  setListingId(detail.id);
282
300
  setValues(
283
- draftValuesFromDetail(
284
- detail,
285
- featuresDtoFromDaoList(asFeatureDaoList(detail.features)) as FeaturesDto,
286
- { currency: runtime.currency }
287
- )
301
+ listingDraft.status === "success"
302
+ ? draftValuesFromWire(listingDraft.data, { currency: runtime.currency })
303
+ : draftValuesFromDetail(
304
+ detail,
305
+ featuresDtoFromDaoList(asFeatureDaoList(detail.features)) as FeaturesDto,
306
+ { currency: runtime.currency }
307
+ )
288
308
  );
289
- }, [existing.data, runtime.currency]);
309
+ }, [existing.data, listingDraft.status, listingDraft.data, runtime.currency]);
290
310
 
291
311
  // Pruning and SEEDING both run on the SCHEMA arriving, one render after the
292
312
  // category changed: `setCategory` cannot prune against features it has not
@@ -368,9 +388,14 @@ export function useListingComposer(
368
388
  );
369
389
 
370
390
  const isLiveEdit = existing.data?.status === "published";
391
+ // Narrowed to the fallback case: the draft-twin read did not land (see the
392
+ // seeding effect above), and the published half is empty too, so there is
393
+ // truly nothing to show. A build on stapel-listings 0.21.1+ only reaches
394
+ // this when the row has genuinely never been saved into.
371
395
  const draftNotReadable =
372
396
  existing.data !== undefined &&
373
397
  existing.data.status !== "published" &&
398
+ listingDraft.status !== "success" &&
374
399
  (existing.data.title ?? "").length === 0 &&
375
400
  (existing.data.description ?? "").length === 0;
376
401
 
package/src/index.ts CHANGED
@@ -212,6 +212,7 @@ export type { ListingPageKey } from "./model/queryKeys.js";
212
212
  export {
213
213
  useListing,
214
214
  useListingCards,
215
+ useListingDraft,
215
216
  useListingEngagement,
216
217
  useListingStatus,
217
218
  useMyCounters,
@@ -5,6 +5,7 @@ import type { StapelApiError } from "@stapel/core";
5
5
  import type { ValidationBatchResult } from "@stapel/attributes-react";
6
6
  import type {
7
7
  ListingDetail,
8
+ ListingDraft,
8
9
  ListingEngagementBatch,
9
10
  ListingPageParams,
10
11
  ListingStatusInfo,
@@ -55,6 +56,30 @@ export function useListing(
55
56
  });
56
57
  }
57
58
 
59
+ /**
60
+ * The draft twin, read back (`GET /{pk}/draft/`, stapel-listings 0.21.1) —
61
+ * what `useListingComposer` seeds a reopened listing from.
62
+ *
63
+ * `retry: false` for the same reason as {@link useListing}: a 404 here is
64
+ * either "no draft was ever saved" or "this build predates the route", and
65
+ * three retries only delay the moment the composer can fall back to the
66
+ * detail seed. It is a separate query from `useListing` (own cache key),
67
+ * because a host that only reads the detail must not pay for this round
68
+ * trip, and a write to one must not evict the other.
69
+ */
70
+ export function useListingDraft(
71
+ id: number | undefined,
72
+ options?: { readonly enabled?: boolean }
73
+ ): UseQueryResult<ListingDraft, StapelApiError> {
74
+ const api = useListingsApi();
75
+ return useQuery({
76
+ queryKey: listingsQueryKeys.draft(id ?? -1),
77
+ queryFn: ({ signal }) => api.draft(id as number, { signal }),
78
+ enabled: (options?.enabled ?? true) && id !== undefined,
79
+ retry: false,
80
+ });
81
+ }
82
+
58
83
  /**
59
84
  * The status probe — both axes plus `is_deleted`, for ANY listing id.
60
85
  *
@@ -63,6 +63,10 @@ export const listingsQueryKeys: {
63
63
  allMine(): readonly ["listings", "my", "listings"];
64
64
  /** `GET /{pk}/validate-draft/` — the dry run of a publish. */
65
65
  validateDraft(id: number): readonly ["listings", "validate-draft", number];
66
+ /** `GET /{pk}/draft/` — the draft twin read back (stapel-listings 0.21.1).
67
+ * A separate entry from `detail`: the two answer different questions about
68
+ * the same id and a write to one must not evict the other's cache. */
69
+ draft(id: number): readonly ["listings", "draft", number];
66
70
  /** Every favourites PAGE, for an invalidation after a toggle — the cursor
67
71
  * is not known at the write, so the prefix is what a write can target. */
68
72
  allFavorites(): readonly ["listings", "my", "favorites"];
@@ -91,6 +95,7 @@ export const listingsQueryKeys: {
91
95
  mineUntabbed: () => [ROOT, "my", "listings", "untabbed"],
92
96
  allMine: () => [ROOT, "my", "listings"],
93
97
  validateDraft: (id) => [ROOT, "validate-draft", id],
98
+ draft: (id) => [ROOT, "draft", id],
94
99
  allFavorites: () => [ROOT, "my", "favorites"],
95
100
  allLists: () => [ROOT, "list"],
96
101
  engagement: (ids) => [ROOT, "engagement", engagementIds(ids).join(",")],
@@ -202,13 +202,34 @@ export function moderationNotice(
202
202
  }
203
203
 
204
204
  if (moderationStatus === "needs_review") {
205
+ // Same three-way split as `pending`, and for the same reason (D225): a
206
+ // manual-review verdict is exactly as capable of being OVERTAKEN by a
207
+ // lifecycle move as an automated one is, and this branch used to ignore
208
+ // that — a sold/paused/archived/draft row carrying a stale
209
+ // `needs_review` printed "a moderator is looking at this by hand" to an
210
+ // owner whose listing had not been on offer for however long the review
211
+ // has been sitting open.
212
+ if (live) {
213
+ return {
214
+ moderationStatus,
215
+ messageKey: LISTINGS_I18N_KEYS.moderationLiveNeedsReview,
216
+ tone: "waiting",
217
+ liveDuringReview: true,
218
+ };
219
+ }
220
+ if (status === "pending") {
221
+ return {
222
+ moderationStatus,
223
+ messageKey: LISTINGS_I18N_KEYS.moderationNeedsReview,
224
+ tone: "waiting",
225
+ liveDuringReview: false,
226
+ };
227
+ }
205
228
  return {
206
229
  moderationStatus,
207
- messageKey: live
208
- ? LISTINGS_I18N_KEYS.moderationLiveNeedsReview
209
- : LISTINGS_I18N_KEYS.moderationNeedsReview,
210
- tone: "waiting",
211
- liveDuringReview: live,
230
+ messageKey: LISTINGS_I18N_KEYS.moderationPendingOffline,
231
+ tone: "neutral",
232
+ liveDuringReview: false,
212
233
  };
213
234
  }
214
235