@stapel/listings-react 0.26.0 → 0.28.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 (80) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/MODULE.md +3 -1
  3. package/README.md +102 -8
  4. package/dist/api/generated/schema.d.ts +20 -10
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/ListingActions.d.ts +8 -1
  7. package/dist/default/ListingActions.d.ts.map +1 -1
  8. package/dist/default/ListingActions.js +1 -1
  9. package/dist/default/ListingActions.js.map +1 -1
  10. package/dist/default/ListingDetailPane.d.ts +148 -9
  11. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  12. package/dist/default/ListingDetailPane.js +131 -19
  13. package/dist/default/ListingDetailPane.js.map +1 -1
  14. package/dist/default/MyListingsPane.d.ts.map +1 -1
  15. package/dist/default/MyListingsPane.js +10 -6
  16. package/dist/default/MyListingsPane.js.map +1 -1
  17. package/dist/default/ShareAction.d.ts +16 -1
  18. package/dist/default/ShareAction.d.ts.map +1 -1
  19. package/dist/default/ShareAction.js +16 -9
  20. package/dist/default/ShareAction.js.map +1 -1
  21. package/dist/default/actionRow.d.ts +24 -0
  22. package/dist/default/actionRow.d.ts.map +1 -1
  23. package/dist/default/actionRow.js +50 -7
  24. package/dist/default/actionRow.js.map +1 -1
  25. package/dist/default/detailGallery.d.ts +77 -0
  26. package/dist/default/detailGallery.d.ts.map +1 -0
  27. package/dist/default/detailGallery.js +91 -0
  28. package/dist/default/detailGallery.js.map +1 -0
  29. package/dist/default/index.d.ts +4 -2
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +5 -1
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/default/movableCluster.d.ts +18 -0
  34. package/dist/default/movableCluster.d.ts.map +1 -0
  35. package/dist/default/movableCluster.js +110 -0
  36. package/dist/default/movableCluster.js.map +1 -0
  37. package/dist/headless/MyListings.d.ts +31 -10
  38. package/dist/headless/MyListings.d.ts.map +1 -1
  39. package/dist/headless/MyListings.js +19 -2
  40. package/dist/headless/MyListings.js.map +1 -1
  41. package/dist/headless/Share.d.ts +60 -2
  42. package/dist/headless/Share.d.ts.map +1 -1
  43. package/dist/headless/Share.js +55 -3
  44. package/dist/headless/Share.js.map +1 -1
  45. package/dist/index.d.ts +4 -4
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -2
  48. package/dist/index.js.map +1 -1
  49. package/dist/model/cardBadges.d.ts +25 -0
  50. package/dist/model/cardBadges.d.ts.map +1 -1
  51. package/dist/model/cardBadges.js +41 -5
  52. package/dist/model/cardBadges.js.map +1 -1
  53. package/dist/model/draft.d.ts +42 -1
  54. package/dist/model/draft.d.ts.map +1 -1
  55. package/dist/model/draft.js +40 -3
  56. package/dist/model/draft.js.map +1 -1
  57. package/dist/model/status.d.ts +31 -16
  58. package/dist/model/status.d.ts.map +1 -1
  59. package/dist/model/status.js +30 -15
  60. package/dist/model/status.js.map +1 -1
  61. package/llms.txt +1 -1
  62. package/manifest.json +8 -1
  63. package/nav-manifest.json +1 -1
  64. package/package.json +3 -3
  65. package/src/analytics/generated/events.json +1 -1
  66. package/src/api/generated/schema.ts +20 -10
  67. package/src/default/ListingActions.tsx +9 -1
  68. package/src/default/ListingDetailPane.tsx +316 -18
  69. package/src/default/MyListingsPane.tsx +10 -6
  70. package/src/default/ShareAction.tsx +36 -10
  71. package/src/default/actionRow.ts +55 -7
  72. package/src/default/detailGallery.ts +97 -0
  73. package/src/default/index.ts +15 -1
  74. package/src/default/movableCluster.tsx +137 -0
  75. package/src/headless/MyListings.tsx +52 -12
  76. package/src/headless/Share.tsx +98 -5
  77. package/src/index.ts +7 -0
  78. package/src/model/cardBadges.ts +42 -5
  79. package/src/model/draft.ts +61 -3
  80. package/src/model/status.ts +31 -16
@@ -224,6 +224,28 @@ function toWireFeatures(
224
224
  return toFeaturesDto(features, values) as unknown as WireFeaturesDraft;
225
225
  }
226
226
 
227
+ /** One field of the `save-draft` body, by the name the wire uses. */
228
+ export type ListingDraftField = keyof ListingDraftPatch;
229
+
230
+ /** Options for {@link draftPatchFromValues}. */
231
+ export interface DraftPatchOptions {
232
+ /**
233
+ * NAME THE FIELDS THIS SAVE IS WRITING, and the body carries no others.
234
+ *
235
+ * `save-draft` REPLACES every field in the body — it does not merge — so a
236
+ * body is not "the values I have", it is "the fields I am claiming". A save
237
+ * fired by one control (a photo settling, a blurred title) previously
238
+ * claimed all fourteen, which is only harmless while every one of them is
239
+ * loaded and true.
240
+ *
241
+ * Given, only these keys are spelled; the omission rules below still apply
242
+ * inside the selection, so naming `features_draft` without a schema still
243
+ * writes nothing. Absent, the whole body is sent, which is right for the
244
+ * composer's own save — it holds every value on the form.
245
+ */
246
+ readonly fields?: readonly ListingDraftField[];
247
+ }
248
+
227
249
  /**
228
250
  * The composer's values → the `save-draft` body.
229
251
  *
@@ -234,12 +256,34 @@ function toWireFeatures(
234
256
  * (`error.400.listing_feature_not_allowed`), which is why
235
257
  * {@link retainKnownFeatureValues} prunes on the way in rather than letting
236
258
  * the server explain it.
259
+ *
260
+ * ── NO SCHEMA IS NOT AN EMPTY ANSWER SHEET ───────────────────────────────
261
+ *
262
+ * An absent or empty `features` used to produce `features_draft: {}`, and
263
+ * `save-draft` REPLACES that map rather than merging into it — so a save that
264
+ * left before the category's schema arrived DELETED every characteristic the
265
+ * row was holding, while the form on screen still showed them. Measured by a
266
+ * live container over two cold loads of one draft: a reopen's own
267
+ * settled-photo save fires in the first commit, before the row's category has
268
+ * even been adopted, and the row alternated between the draft's answers and
269
+ * none of them (a client storefront's README, "Named gaps").
270
+ *
271
+ * `{}` is a claim — "this listing has no characteristics" — and a caller with
272
+ * no schema is in no position to make it. So the key is OMITTED entirely
273
+ * whenever there is no schema to tag values with, and `save-draft` then
274
+ * leaves the stored map exactly as it was. A category that genuinely declares
275
+ * no features writes nothing either, which is the same answer arrived at
276
+ * honestly: there is nothing to say and the row already says it.
277
+ *
278
+ * {@link DraftPatchOptions.fields} is the general form of the same rule — a
279
+ * save that names what it is writing cannot erase what it is not.
237
280
  */
238
281
  export function draftPatchFromValues(
239
282
  values: ListingDraftValues,
240
- features: readonly FeatureDef[]
283
+ features: readonly FeatureDef[] | undefined,
284
+ options: DraftPatchOptions = {}
241
285
  ): ListingDraftPatch {
242
- return {
286
+ const full: ListingDraftPatch = {
243
287
  // Omitted while unchosen rather than sent as `""`: a draft is allowed to
244
288
  // have no category (0.21.4), and `""` is not "no category" on the wire —
245
289
  // it is an empty id the serializer refuses. The category is written by
@@ -258,7 +302,11 @@ export function draftPatchFromValues(
258
302
  // sent here is discarded. Sending one would be a claim the wire ignores.
259
303
  lat_draft: values.location.lat,
260
304
  lon_draft: values.location.lon,
261
- features_draft: toWireFeatures(features, values.features),
305
+ // Omitted, not emptied, when there is no schema to tag with — see the
306
+ // doc above. `{}` is a claim about the listing; silence is not.
307
+ ...(features !== undefined && features.length > 0
308
+ ? { features_draft: toWireFeatures(features, values.features) }
309
+ : {}),
262
310
  countable: values.countable,
263
311
  // The pair mirrors the model's cross-field rule rather than sending a
264
312
  // contradiction: a service carries no quantity, and `validate_countable
@@ -266,6 +314,16 @@ export function draftPatchFromValues(
266
314
  stock_quantity: values.countable ? values.stockQuantity : null,
267
315
  auto_republish: values.autoRepublish,
268
316
  };
317
+ const named = options.fields;
318
+ if (named === undefined) return full;
319
+ // A selection, not a second body: the field's VALUE is still whatever the
320
+ // rules above produced, so a named field the rules omit stays omitted.
321
+ const wanted = new Set<string>(named);
322
+ const out: Record<string, unknown> = {};
323
+ for (const [key, value] of Object.entries(full)) {
324
+ if (wanted.has(key)) out[key] = value;
325
+ }
326
+ return out as ListingDraftPatch;
269
327
  }
270
328
 
271
329
  /**
@@ -285,8 +285,8 @@ export function listingStatusView(
285
285
  }
286
286
 
287
287
  /**
288
- * The three tabs `my/counters` COUNTS, and the statuses each one folds
289
- * together.
288
+ * The three tabs whose ROWS come off a keyset page, and the statuses each one
289
+ * folds together.
290
290
  *
291
291
  * The grouping is the SERVER's (`views.my_counters`), copied here so a tab's
292
292
  * caption and its count cannot describe different sets: `active` includes
@@ -294,10 +294,17 @@ export function listingStatusView(
294
294
  * both because a listing in those states is something the owner is still
295
295
  * working on. A client that grouped them its own way would show "3 active"
296
296
  * over two rows.
297
+ *
298
+ * The name is about the SOURCE, not about the counter: `MyCountersResponse`
299
+ * carries four integers since stapel-listings 0.22.4 (`blocked` joined the
300
+ * three), and this is still the set a {@link MyListingsCountedTab} —
301
+ * `MyListingsSource`'s whole parameter type — may name. The removed tab reads
302
+ * its rows off `?status=blocked` directly and unpaged, so a host that
303
+ * implemented the seam before the fourth tab existed is never handed it.
297
304
  */
298
305
  export const MY_LISTINGS_COUNTED_TABS = ["active", "drafts", "archived"] as const;
299
306
 
300
- /** One of the three tabs `MyCountersResponse` carries a number for. */
307
+ /** One of the three tabs a `MyListingsSource` is asked for. */
301
308
  export type MyListingsCountedTab = (typeof MY_LISTINGS_COUNTED_TABS)[number];
302
309
 
303
310
  const COUNTED_TAB_STATUSES: Readonly<
@@ -308,8 +315,9 @@ const COUNTED_TAB_STATUSES: Readonly<
308
315
  archived: ["archived", "paused", "expired", "sold"],
309
316
  };
310
317
 
311
- /** Which of the three COUNTED tabs a status belongs to, or `undefined` for
312
- * one the server's own counter groups nowhere — `blocked`, today. */
318
+ /** Which of the three keyset-paged tabs a status belongs to, or `undefined`
319
+ * for one none of them folds in — `blocked`, today, which has a tab and a
320
+ * counter of its own and a different row source under it. */
313
321
  export function countedTabOf(
314
322
  status: ListingLifecycleStatus
315
323
  ): MyListingsCountedTab | undefined {
@@ -320,13 +328,16 @@ export function countedTabOf(
320
328
  }
321
329
 
322
330
  /**
323
- * The statuses the server's own counter groups nowhere — `blocked`, and
324
- * nothing else today.
331
+ * The statuses none of the three paged tabs folds in — `blocked`, and nothing
332
+ * else today.
325
333
  *
326
334
  * DERIVED, not written down a second time: a status added upstream and left
327
- * out of the counter groupings lands here automatically and gets shown, which
328
- * is the opposite of what a hardcoded `["blocked"]` would do the day it goes
329
- * stale.
335
+ * out of the tab groupings lands here automatically and gets shown, which is
336
+ * the opposite of what a hardcoded `["blocked"]` would do the day it goes
337
+ * stale. That the server now has a `blocked` COUNTER for the same set does not
338
+ * make the derivation redundant: the counter names one status and this names
339
+ * whatever the three groupings leave over, which is the set the fourth tab
340
+ * must ASK for.
330
341
  */
331
342
  export const MY_LISTINGS_UNTABBED_STATUSES: readonly ListingLifecycleStatus[] =
332
343
  LISTING_STATUSES.filter((status) => countedTabOf(status) === undefined);
@@ -350,12 +361,16 @@ export const MY_LISTINGS_UNTABBED_STATUSES: readonly ListingLifecycleStatus[] =
350
361
  * ── Why a fourth tab and not the archive ─────────────────────────────────
351
362
  *
352
363
  * Folding `blocked` into `archived` is the other shape this could take, and
353
- * it costs the count: `my/counters` has three integers and no fourth, so an
354
- * archive tab holding takedowns would read the server's `archived` number —
355
- * `0` — until the tab was opened and its rows could raise it. The fourth tab
356
- * is counted from its OWN read (`?status=blocked`, unpaged), so the number is
357
- * right while the seller is looking at a different tab, which is exactly the
358
- * moment D407 was measured at.
364
+ * it cost the count: when D407 was written `my/counters` had three integers
365
+ * and no fourth, so an archive tab holding takedowns would have read the
366
+ * server's `archived` number — `0` — until the tab was opened and its rows
367
+ * could raise it. The fourth tab was counted from its OWN read
368
+ * (`?status=blocked`, unpaged) so that the number was right while the seller
369
+ * was looking at a different tab, which is exactly the moment D407 was
370
+ * measured at. stapel-listings 0.22.4 puts `blocked` in the counter, so the
371
+ * number is the server's now and that read is only the tab's rows — but the
372
+ * property it was there to defend is the same one, and it is now defended by
373
+ * the wire instead of by a page.
359
374
  *
360
375
  * The archive keeps its meaning too, which is not nothing: "I put this away"
361
376
  * and "a moderator took this down" are not the same sentence and a tab that