@mohasinac/appkit 4.61.0 → 4.62.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 (96) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
  3. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  5. package/dist/_internal/server/jobs/core/paymentReconcile.js +87 -97
  6. package/dist/_internal/shared/actions/action-registry.js +149 -9
  7. package/dist/_internal/shared/media/limits.d.ts +10 -0
  8. package/dist/_internal/shared/media/limits.js +10 -0
  9. package/dist/_internal/shared/types/action-result.js +15 -1
  10. package/dist/client/api/surface-error.js +16 -2
  11. package/dist/client/api/useApiMutation.js +17 -1
  12. package/dist/client.d.ts +1 -1
  13. package/dist/client.js +1 -1
  14. package/dist/constants/api-endpoints.d.ts +3 -0
  15. package/dist/constants/api-endpoints.js +1 -0
  16. package/dist/constants/field-names.d.ts +17 -0
  17. package/dist/constants/field-names.js +23 -0
  18. package/dist/core/server-action.js +19 -3
  19. package/dist/errors/error-mapping.js +26 -1
  20. package/dist/features/account/components/UserOffersPanel.js +5 -3
  21. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  22. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  23. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  24. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  25. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  26. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  27. package/dist/features/admin/components/AdminMediaView.js +36 -3
  28. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  29. package/dist/features/admin/components/AdminOffersView.js +2 -1
  30. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  31. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  32. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  33. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  34. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  35. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  36. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  37. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  38. package/dist/features/admin/repository/notification.repository.js +32 -0
  39. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  40. package/dist/features/admin/schemas/action-index-form.js +115 -0
  41. package/dist/features/auth/components/RegisterForm.js +2 -1
  42. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  43. package/dist/features/events/components/AdminEventEditorView.js +8 -6
  44. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  45. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  46. package/dist/features/orders/components/RefundRequestView.js +4 -4
  47. package/dist/features/orders/schemas/index.d.ts +6 -6
  48. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  49. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  50. package/dist/features/products/components/ProductDetailActions.js +2 -1
  51. package/dist/features/seller/components/FulfillmentView.js +3 -2
  52. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  53. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  54. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  55. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  56. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  57. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  58. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  59. package/dist/features/seller/components/SellerProductsView.js +5 -1
  60. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  61. package/dist/features/seller/components/SellerShippingView.js +2 -1
  62. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  63. package/dist/features/seller/components/SellerSupportView.js +69 -2
  64. package/dist/features/shell/SectionForm.js +20 -16
  65. package/dist/features/shell/build-sections.js +14 -1
  66. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  67. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  68. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  69. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  70. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  71. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  72. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  73. package/dist/index.d.ts +5 -1
  74. package/dist/index.js +3 -1
  75. package/dist/next/routing/route-map.d.ts +4 -0
  76. package/dist/next/routing/route-map.js +9 -0
  77. package/dist/schemas/registry.d.ts +4 -4
  78. package/dist/schemas/sieve.d.ts +3 -3
  79. package/dist/server.d.ts +1 -1
  80. package/dist/server.js +1 -1
  81. package/dist/ui/components/Checkbox.js +10 -2
  82. package/dist/ui/components/DateInput.js +8 -1
  83. package/dist/ui/components/Input.js +22 -2
  84. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  85. package/dist/ui/components/PaginatedSelect.js +43 -9
  86. package/dist/ui/components/Radio.js +8 -1
  87. package/dist/ui/components/Select.js +10 -2
  88. package/dist/ui/components/TagInput.d.ts +8 -1
  89. package/dist/ui/components/TagInput.js +4 -3
  90. package/dist/ui/components/Textarea.js +10 -2
  91. package/dist/ui/forms/FieldCheckbox.js +1 -1
  92. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  93. package/dist/ui/forms/FormErrorList.js +34 -1
  94. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  95. package/dist/ui/forms/apply-zod-issues.js +23 -1
  96. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.61.0",
4
- "head": "7fd2f6fe0a6d6314241671329d51d11d008fca95",
3
+ "version": "4.62.0",
4
+ "head": "2ffd384e9122be6b68eba81be0eb152e5d1f6a3e",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-22T08:29:07.009Z"
7
+ "builtAt": "2026-10-22T11:28:37.098Z"
8
8
  }
@@ -96,6 +96,15 @@ export function LotteryAdminEditView({ eventId, initialData, onSubmit, }) {
96
96
  * `audit-code-quality`'s DEEP_NESTING rule exists to stop.
97
97
  */
98
98
  function SlotTable({ slots, showPrice, onSlotsChange, }) {
99
+ /*
100
+ * Read the form context directly. `SlotTable` is its own component, so the
101
+ * `shellCtx` the parent destructures is out of scope here — but the parent
102
+ * mounts `FormShellContext.Provider` above this, so the errors are
103
+ * reachable. `useContext` rather than `useFormShell()`: this table is
104
+ * also usable outside a form and must not require a provider.
105
+ */
106
+ const formCtx = React.useContext(FormShellContext);
107
+ const slotError = (idx, field) => formCtx?.errors[`slots.${idx}.${field}`];
99
108
  /**
100
109
  * Next number is max+1, not length+1.
101
110
  *
@@ -146,19 +155,26 @@ function SlotTable({ slots, showPrice, onSlotsChange, }) {
146
155
  {
147
156
  key: "name",
148
157
  header: "Prize name",
149
- render: (s) => (_jsx(Input, { type: "text", className: "w-full", placeholder: `Prize for slot ${s.slotNumber}`, value: s.name, onChange: (e) => updateSlot(s._idx, "name", e.target.value), "aria-label": `Slot ${s.slotNumber} prize name` })),
158
+ /*
159
+ * These inputs live in DataTable COLUMN renderers, which get no
160
+ * `errors` argument — a section render would. They read the context
161
+ * this component itself mounts, with the dotted INDEXED key
162
+ * `applyZodIssues` produces (`slots.2.price`), so a rejected slot marks
163
+ * its own row rather than the first one.
164
+ */
165
+ render: (s) => (_jsx(Input, { type: "text", className: "w-full", placeholder: `Prize for slot ${s.slotNumber}`, value: s.name, error: slotError(s._idx, "name"), onChange: (e) => updateSlot(s._idx, "name", e.target.value), "aria-label": `Slot ${s.slotNumber} prize name` })),
150
166
  },
151
167
  {
152
168
  key: "image",
153
169
  header: "Image",
154
- render: (s) => (_jsx(Input, { type: "text", className: "w-full", placeholder: "/media/\u2026 or https://\u2026", value: s.image ?? "", onChange: (e) => updateSlot(s._idx, "image", e.target.value), "aria-label": `Slot ${s.slotNumber} prize image URL` })),
170
+ render: (s) => (_jsx(Input, { type: "text", className: "w-full", placeholder: "/media/\u2026 or https://\u2026", value: s.image ?? "", error: slotError(s._idx, "image"), onChange: (e) => updateSlot(s._idx, "image", e.target.value), "aria-label": `Slot ${s.slotNumber} prize image URL` })),
155
171
  },
156
172
  ...(showPrice
157
173
  ? [
158
174
  {
159
175
  key: "price",
160
176
  header: "Price (₹)",
161
- render: (s) => (_jsx(Input, { type: "number", className: "w-24", placeholder: "0", value: s.price, onChange: (e) => updateSlot(s._idx, "price", e.target.value), "aria-label": `Slot ${s.slotNumber} price` })),
177
+ render: (s) => (_jsx(Input, { type: "number", className: "w-24", placeholder: "0", value: s.price, error: slotError(s._idx, "price"), onChange: (e) => updateSlot(s._idx, "price", e.target.value), "aria-label": `Slot ${s.slotNumber} price` })),
162
178
  },
163
179
  ]
164
180
  : []),
@@ -23,7 +23,7 @@ export function AnalysisRunnerView() {
23
23
  });
24
24
  const body = (await res.json());
25
25
  if (!res.ok || !body.ok) {
26
- setError(("error" in body && body.error) || `HTTP ${res.status}`);
26
+ setError(toUserMessage("code" in body ? String(body.code) : undefined, undefined, { fallback: `The analysis run failed (HTTP ${res.status}).` }));
27
27
  setRunning(false);
28
28
  return;
29
29
  }
@@ -32,7 +32,7 @@ export function CloudLogsListView({ title, subtitle, initialEntries, initialNext
32
32
  const res = await fetch(url.toString());
33
33
  const body = (await res.json());
34
34
  if (!res.ok || !body.ok) {
35
- setError(("error" in body && body.error) || `HTTP ${res.status}`);
35
+ setError(toUserMessage("code" in body ? String(body.code) : undefined, undefined, { fallback: `Could not load logs (HTTP ${res.status}).` }));
36
36
  return;
37
37
  }
38
38
  setEntries((prev) => [...prev, ...body.data.entries]);
@@ -138,81 +138,7 @@ export async function reconcileStuckGatewayEvents(ctx) {
138
138
  }
139
139
  return { redispatched, escalated, reads: stuck.length };
140
140
  }
141
- /**
142
- * "The gateway captured a different amount from the one the order records."
143
- *
144
- * Extracted from the sweep below, where it sat inside
145
- * fn → `for record` → `for order` → `if` and the whole case literal was a
146
- * brace too deep. Pure construction — no branching, and `recorded` is passed
147
- * in rather than re-derived, so the one money rule still lives at the call
148
- * site (Root Cause #75).
149
- */
150
- async function openAmountMismatchCase(record, orderId, recorded, now) {
151
- await paymentCasesRepository().openOrObserve({
152
- kind: "amount_mismatch",
153
- subjectId: `${record.id}:${orderId}`,
154
- severity: "error",
155
- title: `Captured ${record.amount} but recorded ${recorded} — ${orderId}`,
156
- detail: [
157
- `The gateway captured a different amount from the one the order records.`,
158
- `Either the buyer was over-charged or the order under-states what was paid.`,
159
- "",
160
- `Gateway: ${record.gateway}`,
161
- `Payment: ${record.id}`,
162
- `Order: ${orderId}`,
163
- `Captured: ${record.amount} ${record.currency}`,
164
- `Order total: ${recorded}`,
165
- `Difference: ${(record.amount - recorded).toFixed(2)}`,
166
- ].join("\n"),
167
- entityType: "order",
168
- entityId: orderId,
169
- amountAtRisk: Math.abs(record.amount - recorded),
170
- orderIds: [orderId],
171
- gatewayPaymentId: record.id,
172
- now,
173
- });
174
- }
175
- /**
176
- * "Our refund total disagrees with the gateway's."
177
- *
178
- * Same extraction as `openAmountMismatchCase`. `ours` and `base` are both
179
- * computed by the caller from the shared helpers, so this never re-derives a
180
- * refund figure of its own.
181
- *
182
- * 🛑 The severity split is the whole point and must not be flattened: the
183
- * gateway having refunded MORE than we recorded is money that left without a
184
- * record (`error`); our side showing more is a refund reserved and never
185
- * completed (`warning`).
186
- */
187
- async function openRefundMismatchCase(record, orderId, ours, base, now) {
188
- await paymentCasesRepository().openOrObserve({
189
- kind: "refund_mismatch",
190
- subjectId: `${record.id}:refunds`,
191
- severity: record.amountRefunded > ours ? "error" : "warning",
192
- title: `Refund total disagrees with the gateway — ${orderId}`,
193
- detail: [
194
- record.amountRefunded > ours
195
- ? `🛑 The gateway has refunded MORE than our records show. Money has left that we did not record.`
196
- : `Our records show more refunded than the gateway did — a refund may have been reserved and never completed.`,
197
- "",
198
- `Gateway: ${record.gateway}`,
199
- `Payment: ${record.id}`,
200
- `Order: ${orderId}`,
201
- `Gateway refunded: ${record.amountRefunded}`,
202
- `Our committed refunds: ${ours}`,
203
- `Refundable base: ${base}`,
204
- record.refundStatus ? `Gateway refund status: ${record.refundStatus}` : null,
205
- ]
206
- .filter((l) => l !== null)
207
- .join("\n"),
208
- entityType: "order",
209
- entityId: orderId,
210
- amountAtRisk: Math.abs(record.amountRefunded - ours),
211
- orderIds: [orderId],
212
- gatewayPaymentId: record.id,
213
- now,
214
- });
215
- }
141
+ /* ──────────────── R2: the gateway has it — do we? ───────────────────────── */
216
142
  async function reconcileGatewayPayments(ctx) {
217
143
  const payment = getProviders().payment;
218
144
  let examined = 0;
@@ -298,28 +224,9 @@ async function reconcileGatewayPayments(ctx) {
298
224
  continue;
299
225
  }
300
226
  for (const order of orders) {
301
- /*
302
- * Amount check. Compared against the order's own recorded total rather
303
- * than recomputed from its lines — recomputing would re-derive the
304
- * pricing pipeline here and disagree with it the day a rate changes,
305
- * which is a sixth copy of the money rule (Root Cause #75).
306
- */
307
- const recorded = order.totalPrice ?? 0;
308
- if (Math.abs(recorded - record.amount) > MONEY_EPSILON) {
309
- amountMismatch += 1;
310
- await openAmountMismatchCase(record, order.id, recorded, ctx.now);
311
- }
312
- /*
313
- * Refund check — the gateway's `amount_refunded` against our own
314
- * non-failed refund events. `committedRefundTotal` is the same helper
315
- * the refund cap uses, so "what we think we refunded" has ONE
316
- * definition rather than a second one written here.
317
- */
318
- const ours = committedRefundTotal(order);
319
- if (Math.abs(ours - record.amountRefunded) > MONEY_EPSILON) {
320
- refundMismatch += 1;
321
- await openRefundMismatchCase(record, order.id, ours, refundableBase(order), ctx.now);
322
- }
227
+ const outcome = await reconcilePaymentAgainstOrder(record, order, ctx);
228
+ amountMismatch += outcome.amountMismatch;
229
+ refundMismatch += outcome.refundMismatch;
323
230
  }
324
231
  }
325
232
  catch (err) {
@@ -331,6 +238,89 @@ async function reconcileGatewayPayments(ctx) {
331
238
  }
332
239
  return { examined, missing, amountMismatch, refundMismatch, reads };
333
240
  }
241
+ /**
242
+ * The two per-order checks R2 runs against one gateway record.
243
+ *
244
+ * Extracted so each `openOrObserve` call is a statement in a function body
245
+ * rather than five braces deep inside `for record -> try -> for order -> if`.
246
+ * Returns the counts rather than mutating the caller's accumulators, so the
247
+ * loop above reads as a sum rather than as two side effects.
248
+ */
249
+ async function reconcilePaymentAgainstOrder(record, order, ctx) {
250
+ let amountMismatch = 0;
251
+ let refundMismatch = 0;
252
+ /*
253
+ * Amount check. Compared against the order's own recorded total rather
254
+ * than recomputed from its lines — recomputing would re-derive the
255
+ * pricing pipeline here and disagree with it the day a rate changes,
256
+ * which is a sixth copy of the money rule (Root Cause #75).
257
+ */
258
+ const recorded = order.totalPrice ?? 0;
259
+ if (Math.abs(recorded - record.amount) > MONEY_EPSILON) {
260
+ amountMismatch += 1;
261
+ await paymentCasesRepository().openOrObserve({
262
+ kind: "amount_mismatch",
263
+ subjectId: `${record.id}:${order.id}`,
264
+ severity: "error",
265
+ title: `Captured ${record.amount} but recorded ${recorded} — ${order.id}`,
266
+ detail: [
267
+ `The gateway captured a different amount from the one the order records.`,
268
+ `Either the buyer was over-charged or the order under-states what was paid.`,
269
+ "",
270
+ `Gateway: ${record.gateway}`,
271
+ `Payment: ${record.id}`,
272
+ `Order: ${order.id}`,
273
+ `Captured: ${record.amount} ${record.currency}`,
274
+ `Order total: ${recorded}`,
275
+ `Difference: ${(record.amount - recorded).toFixed(2)}`,
276
+ ].join("\n"),
277
+ entityType: "order",
278
+ entityId: order.id,
279
+ amountAtRisk: Math.abs(record.amount - recorded),
280
+ orderIds: [order.id],
281
+ gatewayPaymentId: record.id,
282
+ now: ctx.now,
283
+ });
284
+ }
285
+ /*
286
+ * Refund check — the gateway's `amount_refunded` against our own
287
+ * non-failed refund events. `committedRefundTotal` is the same helper
288
+ * the refund cap uses, so "what we think we refunded" has ONE
289
+ * definition rather than a second one written here.
290
+ */
291
+ const ours = committedRefundTotal(order);
292
+ if (Math.abs(ours - record.amountRefunded) > MONEY_EPSILON) {
293
+ refundMismatch += 1;
294
+ await paymentCasesRepository().openOrObserve({
295
+ kind: "refund_mismatch",
296
+ subjectId: `${record.id}:refunds`,
297
+ severity: record.amountRefunded > ours ? "error" : "warning",
298
+ title: `Refund total disagrees with the gateway — ${order.id}`,
299
+ detail: [
300
+ record.amountRefunded > ours
301
+ ? `🛑 The gateway has refunded MORE than our records show. Money has left that we did not record.`
302
+ : `Our records show more refunded than the gateway did — a refund may have been reserved and never completed.`,
303
+ "",
304
+ `Gateway: ${record.gateway}`,
305
+ `Payment: ${record.id}`,
306
+ `Order: ${order.id}`,
307
+ `Gateway refunded: ${record.amountRefunded}`,
308
+ `Our committed refunds: ${ours}`,
309
+ `Refundable base: ${refundableBase(order)}`,
310
+ record.refundStatus ? `Gateway refund status: ${record.refundStatus}` : null,
311
+ ]
312
+ .filter((l) => l !== null)
313
+ .join("\n"),
314
+ entityType: "order",
315
+ entityId: order.id,
316
+ amountAtRisk: Math.abs(record.amountRefunded - ours),
317
+ orderIds: [order.id],
318
+ gatewayPaymentId: record.id,
319
+ now: ctx.now,
320
+ });
321
+ }
322
+ return { amountMismatch, refundMismatch };
323
+ }
334
324
  /* ──────────────── R3: refund claimed, never settled ─────────────────────── */
335
325
  /**
336
326
  * A `refundClaims` row still `claimed` an hour later is a crash between
@@ -1427,6 +1427,40 @@ export const ACTIONS = {
1427
1427
  confirmKind: "danger",
1428
1428
  },
1429
1429
  },
1430
+ /*
1431
+ * 🛑 The confirmation names the consequence rather than the mechanism.
1432
+ * "Revoke session" means nothing to a buyer; "that device is signed out"
1433
+ * is the fact they need to weigh, and the warning about their own device
1434
+ * is the one mistake this control makes easy to make.
1435
+ */
1436
+ "revoke-session": {
1437
+ iconKey: "lock",
1438
+ id: "user.revoke-session",
1439
+ label: "Sign out device",
1440
+ ariaLabel: "Sign out this device",
1441
+ description: "End an active session so that device has to sign in again.",
1442
+ kind: "danger",
1443
+ confirmation: {
1444
+ title: "Sign out this device?",
1445
+ body: "Whoever is using it will be signed out and will need your password to get back in. If this is the device you are on right now, you will be signed out too.",
1446
+ confirmLabel: "Sign out device",
1447
+ confirmKind: "danger",
1448
+ },
1449
+ },
1450
+ "delete-payment-method": {
1451
+ iconKey: "delete",
1452
+ id: "user.delete-payment-method",
1453
+ label: "Remove card",
1454
+ ariaLabel: "Remove this saved payment method",
1455
+ description: "Remove a saved payment method from the account.",
1456
+ kind: "danger",
1457
+ confirmation: {
1458
+ title: "Remove this payment method?",
1459
+ body: "It will no longer appear at checkout. Any order already placed with it is unaffected.",
1460
+ confirmLabel: "Remove",
1461
+ confirmKind: "danger",
1462
+ },
1463
+ },
1430
1464
  "set-default-address": {
1431
1465
  iconKey: "confirm",
1432
1466
  id: "user.set-default-address",
@@ -2397,17 +2431,44 @@ export const ACTIONS = {
2397
2431
  kind: "ghost",
2398
2432
  permissions: ["admin"],
2399
2433
  },
2434
+ "activate-carousel": {
2435
+ iconKey: "resume",
2436
+ id: "admin.activate-carousel",
2437
+ label: "Activate",
2438
+ ariaLabel: "Show this slide in the carousel",
2439
+ description: "Set a carousel slide to active so the hero carousel renders it.",
2440
+ kind: "primary",
2441
+ permissions: ["admin"],
2442
+ },
2443
+ "deactivate-carousel": {
2444
+ iconKey: "hide",
2445
+ id: "admin.deactivate-carousel",
2446
+ label: "Deactivate",
2447
+ ariaLabel: "Hide this slide from the carousel",
2448
+ description: "Set a carousel slide to inactive without deleting it.",
2449
+ kind: "secondary",
2450
+ permissions: ["admin"],
2451
+ },
2400
2452
  /*
2401
- * Homepage sections have no bucket of their own, so their edit leaf lives
2402
- * here beside the carousel pair for the same reason — both are homepage
2403
- * chrome an admin edits from a list rather than from a detail page.
2453
+ * Homepage sections have no bucket of their own, so their leaves live here
2454
+ * beside the carousel ones for the same reason — both are homepage chrome
2455
+ * an admin edits from a list rather than from a detail page.
2456
+ *
2457
+ * 🛑 `delete-section` exists but is NOT reachable from the public homepage.
2458
+ * Section `order` is POSITIONAL, so removing one leaves a gap in its
2459
+ * neighbours' numbering — not something to offer from a public page, where
2460
+ * the only confirmation a person gets is a dialog.
2461
+ *
2462
+ * That prohibition is enforced INDEPENDENTLY of this registry, which is
2463
+ * why the leaf can exist: `RESOURCE_LEAVES.SECTION` in `OwnerQuickActions`
2464
+ * omits `remove`, and `showDelete` gates on `leaves.remove` rather than on
2465
+ * what a call site passes — so the pencil strip renders no bin regardless.
2466
+ * This comment read "there is deliberately NO delete-section counterpart"
2467
+ * until 2026-10-22, when the admin sections listing gained a real one.
2404
2468
  *
2405
- * 🛑 There is deliberately NO `delete-section` counterpart. Section `order`
2406
- * is POSITIONAL, so removing one silently renumbers its neighbours — not
2407
- * something to offer from the public homepage, where the only confirmation
2408
- * a person gets is a dialog. `OwnerQuickActions` enforces the absence by
2409
- * omitting `remove` from the SECTION mapping, so no call site can pass a
2410
- * delete handler and have it render.
2469
+ * The listing is the right home for it: it owns these records, it shows
2470
+ * the `order` column the gap appears in, and its Reorder panel has a
2471
+ * Reindex 1..N button beside it to close one.
2411
2472
  */
2412
2473
  "edit-section": {
2413
2474
  iconKey: "edit",
@@ -2418,6 +2479,62 @@ export const ACTIONS = {
2418
2479
  kind: "ghost",
2419
2480
  permissions: ["admin"],
2420
2481
  },
2482
+ "enable-section": {
2483
+ iconKey: "resume",
2484
+ id: "admin.enable-section",
2485
+ label: "Enable",
2486
+ ariaLabel: "Enable this homepage section",
2487
+ description: "Render this section on its surface again.",
2488
+ kind: "primary",
2489
+ permissions: ["admin"],
2490
+ },
2491
+ "disable-section": {
2492
+ iconKey: "hide",
2493
+ id: "admin.disable-section",
2494
+ label: "Disable",
2495
+ ariaLabel: "Disable this homepage section",
2496
+ description: "Stop rendering this section without deleting it or its saved config.",
2497
+ kind: "secondary",
2498
+ permissions: ["admin"],
2499
+ },
2500
+ "delete-section": {
2501
+ iconKey: "delete",
2502
+ id: "admin.delete-section",
2503
+ label: "Delete Section",
2504
+ ariaLabel: "Delete this homepage section",
2505
+ description: "Permanently remove this homepage section and its saved config.",
2506
+ kind: "danger",
2507
+ permissions: ["admin"],
2508
+ confirmation: {
2509
+ title: "Delete this section?",
2510
+ /*
2511
+ * The last sentence is load-bearing: it names the reversible
2512
+ * alternative at the moment of the irreversible choice, and that
2513
+ * alternative is one click away in the same row.
2514
+ */
2515
+ body: "The section and its saved config are removed permanently. Display order is positional, so the sections after it keep their current numbers and a gap is left — Reorder sections → Reindex 1..N closes it. To hide the section from the homepage while keeping its config, disable it instead.",
2516
+ confirmLabel: "Delete section",
2517
+ confirmKind: "danger",
2518
+ },
2519
+ },
2520
+ "show-nav-item": {
2521
+ iconKey: "view",
2522
+ id: "admin.show-nav-item",
2523
+ label: "Show",
2524
+ ariaLabel: "Show this navigation item",
2525
+ description: "Make a navigation item visible in the sidebar and menus.",
2526
+ kind: "primary",
2527
+ permissions: ["admin"],
2528
+ },
2529
+ "hide-nav-item": {
2530
+ iconKey: "hide",
2531
+ id: "admin.hide-nav-item",
2532
+ label: "Hide",
2533
+ ariaLabel: "Hide this navigation item",
2534
+ description: "Hide a navigation item without deleting it.",
2535
+ kind: "secondary",
2536
+ permissions: ["admin"],
2537
+ },
2421
2538
  "delete-carousel": {
2422
2539
  iconKey: "delete",
2423
2540
  id: "admin.delete-carousel",
@@ -3085,6 +3202,29 @@ export const ACTIONS = {
3085
3202
  confirmKind: "danger",
3086
3203
  },
3087
3204
  },
3205
+ /*
3206
+ * 🛑 Distinct from `clear-previews` and `discard-staged`, which are
3207
+ * SESSION/STAGING operations over files no record points at yet. This one
3208
+ * removes a FINALISED asset, so the confirmation has to say what nothing
3209
+ * else in this bucket has to: the bytes are referenced by whatever already
3210
+ * embedded them, and the proxy resolves `/media/<shortId>` through the
3211
+ * `mediaAssets` row — so deleting is how a live page gets a broken image.
3212
+ */
3213
+ "delete-media": {
3214
+ iconKey: "delete",
3215
+ id: "media.delete-media",
3216
+ label: "Delete file",
3217
+ ariaLabel: "Delete this media file",
3218
+ description: "Permanently delete a media file and the asset row that resolves it.",
3219
+ kind: "danger",
3220
+ permissions: ["admin"],
3221
+ confirmation: {
3222
+ title: "Delete this file?",
3223
+ body: "The file is removed from storage and stops resolving. Any product, blog post or section still embedding it will show a broken image — this cannot be undone.",
3224
+ confirmLabel: "Delete file",
3225
+ confirmKind: "danger",
3226
+ },
3227
+ },
3088
3228
  },
3089
3229
  SUPPORT: {
3090
3230
  "create-ticket": {
@@ -27,6 +27,16 @@ export type MediaKind = "image" | "video" | "pdf";
27
27
  export declare const PRODUCT_MAX_IMAGES = 24;
28
28
  /** Videos per listing. Bounded by `ProductDocument.video` being singular. */
29
29
  export declare const PRODUCT_MAX_VIDEOS = 1;
30
+ /**
31
+ * Photos a buyer may attach to one review.
32
+ *
33
+ * 🛑 Deliberately NOT `PRODUCT_MAX_IMAGES`, which is 24 and is a SELLER's
34
+ * ceiling for a whole listing. `audit-media-limit-literals` suggests that
35
+ * constant for any `images: z.array(…).max(n)`, and taking the suggestion
36
+ * here would have quadrupled what a review accepts — the right shape, the
37
+ * wrong number. A review is a few photos of one thing.
38
+ */
39
+ export declare const REVIEW_MAX_IMAGES = 6;
30
40
  /**
31
41
  * Highest legal `index` in a product media filename.
32
42
  *
@@ -41,6 +41,16 @@ export const MAX_VIDEO_BYTES = 50 * MEGABYTE;
41
41
  export const PRODUCT_MAX_IMAGES = 24;
42
42
  /** Videos per listing. Bounded by `ProductDocument.video` being singular. */
43
43
  export const PRODUCT_MAX_VIDEOS = 1;
44
+ /**
45
+ * Photos a buyer may attach to one review.
46
+ *
47
+ * 🛑 Deliberately NOT `PRODUCT_MAX_IMAGES`, which is 24 and is a SELLER's
48
+ * ceiling for a whole listing. `audit-media-limit-literals` suggests that
49
+ * constant for any `images: z.array(…).max(n)`, and taking the suggestion
50
+ * here would have quadrupled what a review accepts — the right shape, the
51
+ * wrong number. A review is a few photos of one thing.
52
+ */
53
+ export const REVIEW_MAX_IMAGES = 6;
44
54
  /**
45
55
  * Highest legal `index` in a product media filename.
46
56
  *
@@ -1,5 +1,6 @@
1
1
  import { normalizeError } from "../../../errors/normalize.js";
2
2
  import { mapToHttpError } from "../../../errors/error-mapping.js";
3
+ import { toApiIssues } from "../../../errors/error-envelope.js";
3
4
  import { describeCauseChain } from "../../../errors/describe-cause.js";
4
5
  import { reportActionError } from "./action-error-reporter.js";
5
6
  /**
@@ -57,7 +58,20 @@ export async function wrapAction(fn) {
57
58
  ok: false,
58
59
  code: mapped.code,
59
60
  error: mapped.message,
60
- ...(mapped.issues ? { issues: mapped.issues } : {}),
61
+ /*
62
+ * 🛑 `toApiIssues`, not the raw issues — this was the ONE producer in
63
+ * the codebase that skipped the scrubber, and it is the one with 221
64
+ * call sites.
65
+ *
66
+ * A raw `ZodIssue` carries `received`: the value the user actually
67
+ * submitted. On a failed signup, address save or payout-details save
68
+ * that is an email, a phone number or a bank account, echoed straight
69
+ * back into the client payload. `toApiIssues` narrows each issue to
70
+ * `{ message, path?, code? }`, which is exactly why it exists — every
71
+ * other producer (`routeHandler`, `createApiHandler`, the offer
72
+ * actions) already routes through it.
73
+ */
74
+ ...(mapped.issues ? { issues: toApiIssues(mapped.issues) } : {}),
61
75
  };
62
76
  }
63
77
  }
@@ -1,6 +1,6 @@
1
1
  import { isApiError } from "./ApiError.js";
2
2
  import { ApiClientError } from "../../http/ApiClient.js";
3
- import { applyZodIssues, hasAttachableIssue } from "../../ui/forms/apply-zod-issues.js";
3
+ import { applyZodIssues, hasAttachableIssue, rootLevelMessages } from "../../ui/forms/apply-zod-issues.js";
4
4
  import { normalizeError } from "../../errors/normalize.js";
5
5
  import { getErrorDisplay, toUserMessage } from "../../errors/error-display-map.js";
6
6
  import { HTTP_ERROR_CODES } from "../../errors/error-mapping.js";
@@ -36,7 +36,21 @@ export function surfaceError(err, opts) {
36
36
  const issues = issuesOf(err);
37
37
  if (setFieldError && hasAttachableIssue(issues)) {
38
38
  applyZodIssues(issues, (name, message) => setFieldError(name, message ?? ""));
39
- return; // inline errors only — no toast
39
+ /*
40
+ * 🛑 Root-level issues are NOT covered by the line above and must still be
41
+ * surfaced, or a cross-field rule fails silently.
42
+ *
43
+ * `applyZodIssues` skips any issue with an empty `path` — there is no
44
+ * field to put it on. For a mixed response (one field error plus one
45
+ * cross-field `.refine()`, the shape of "passwords must match" or "end
46
+ * date must be after start date") `hasAttachableIssue` is true, so we used
47
+ * to take this branch and `return` with the refine message discarded: the
48
+ * save was refused and nothing on screen said why.
49
+ */
50
+ for (const message of rootLevelMessages(issues)) {
51
+ showToast(message, "error");
52
+ }
53
+ return; // field errors inline; only the unattachable ones toast
40
54
  }
41
55
  if (hasStableErrorCode(err)) {
42
56
  const display = getErrorDisplay(err.code);
@@ -3,6 +3,7 @@ import * as React from "react";
3
3
  import { useMutation, } from "@tanstack/react-query";
4
4
  import { useToast } from "../../ui/components/Toast.js";
5
5
  import { surfaceError } from "./surface-error.js";
6
+ import { toUserMessage } from "../../errors/error-display-map.js";
6
7
  export function useApiMutation(options) {
7
8
  const { showToast, updateToast } = useToast();
8
9
  const loadingIdRef = React.useRef(null);
@@ -40,7 +41,22 @@ export function useApiMutation(options) {
40
41
  onSettled: (data, err, variables, context, ctx) => {
41
42
  if (loadingIdRef.current) {
42
43
  if (err) {
43
- updateToast(loadingIdRef.current, "error", err instanceof Error ? err.message : "Action failed");
44
+ /*
45
+ * 🛑 This was `err instanceof Error ? err.message : "Action failed"`
46
+ * — verbatim the inverted ternary `audit-raw-error-text`'s own
47
+ * header calls "the dominant shape", sitting two lines below the
48
+ * correct `surfaceError` call in the same hook. The authored
49
+ * sentence showed only when the value was NOT an Error, i.e. almost
50
+ * never, so the normal case published the raw message.
51
+ *
52
+ * It was dormant only because `loadingMessage` currently has no
53
+ * call sites; the first consumer to set one would have got two
54
+ * toasts, one of them raw. `updateToast` is also absent from that
55
+ * audit's SINKS list, so nothing would have caught it.
56
+ */
57
+ updateToast(loadingIdRef.current, "error", toUserMessage(err?.code, undefined, {
58
+ fallback: "Action failed",
59
+ }));
44
60
  }
45
61
  else if (!successMessage) {
46
62
  updateToast(loadingIdRef.current, "success", "Done");
package/dist/client.d.ts CHANGED
@@ -455,7 +455,7 @@ export type { OwnerQuickActionsProps, OwnerQuickActionsResource } from "./featur
455
455
  export type { StoreListingEntitlements } from "./features/seller/hooks/useStoreListingEntitlements";
456
456
  export type { ListingTypePlugin } from "./_internal/shared/listing-types/_registry";
457
457
  export type { ListingType } from "./features/products/types/index";
458
- export { MEGABYTE, MAX_IMAGE_BYTES, MAX_PDF_BYTES, MAX_VIDEO_BYTES, MAX_LABEL, MAX_BYTES, ALLOWED_IMAGE_MIMES, IMAGE_ACCEPT_ATTR, ALLOWED_VIDEO_MIMES, ALLOWED_DOC_MIMES, ALLOWED_MIMES, ALLOWED_TYPES_LABEL, MIME_TO_EXT, PDF_MAGIC, VIDEO_CONVERSION_HINTS, classifyMime, isAllowedMime, maxBytesFor, getConversionHint, PRODUCT_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "./_internal/shared/media/limits";
458
+ export { MEGABYTE, MAX_IMAGE_BYTES, MAX_PDF_BYTES, MAX_VIDEO_BYTES, MAX_LABEL, MAX_BYTES, ALLOWED_IMAGE_MIMES, IMAGE_ACCEPT_ATTR, ALLOWED_VIDEO_MIMES, ALLOWED_DOC_MIMES, ALLOWED_MIMES, ALLOWED_TYPES_LABEL, MIME_TO_EXT, PDF_MAGIC, VIDEO_CONVERSION_HINTS, classifyMime, isAllowedMime, maxBytesFor, getConversionHint, PRODUCT_MAX_IMAGES, REVIEW_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "./_internal/shared/media/limits";
459
459
  export type { MediaKind, AllowedImageMime, AllowedVideoMime, AllowedDocMime, AllowedMime, } from "./_internal/shared/media/limits";
460
460
  export { ScamAwarenessModal } from "./features/scams/components/ScamAwarenessModal";
461
461
  export type { ScamAwarenessModalProps } from "./features/scams/components/ScamAwarenessModal";
package/dist/client.js CHANGED
@@ -493,7 +493,7 @@ export { YourPurchaseStrip } from "./features/orders/components/YourPurchaseStri
493
493
  export { ReviewFormDrawer, reviewFormSchema } from "./features/reviews/components/ReviewFormDrawer.js";
494
494
  export { PurchaseReviewAffordance } from "./features/reviews/components/PurchaseReviewAffordance.js";
495
495
  // Media upload limits — shared by client uploaders + server sign/finalize routes.
496
- export { MEGABYTE, MAX_IMAGE_BYTES, MAX_PDF_BYTES, MAX_VIDEO_BYTES, MAX_LABEL, MAX_BYTES, ALLOWED_IMAGE_MIMES, IMAGE_ACCEPT_ATTR, ALLOWED_VIDEO_MIMES, ALLOWED_DOC_MIMES, ALLOWED_MIMES, ALLOWED_TYPES_LABEL, MIME_TO_EXT, PDF_MAGIC, VIDEO_CONVERSION_HINTS, classifyMime, isAllowedMime, maxBytesFor, getConversionHint, PRODUCT_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "./_internal/shared/media/limits.js";
496
+ export { MEGABYTE, MAX_IMAGE_BYTES, MAX_PDF_BYTES, MAX_VIDEO_BYTES, MAX_LABEL, MAX_BYTES, ALLOWED_IMAGE_MIMES, IMAGE_ACCEPT_ATTR, ALLOWED_VIDEO_MIMES, ALLOWED_DOC_MIMES, ALLOWED_MIMES, ALLOWED_TYPES_LABEL, MIME_TO_EXT, PDF_MAGIC, VIDEO_CONVERSION_HINTS, classifyMime, isAllowedMime, maxBytesFor, getConversionHint, PRODUCT_MAX_IMAGES, REVIEW_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "./_internal/shared/media/limits.js";
497
497
  export { ScamAwarenessModal } from "./features/scams/components/ScamAwarenessModal.js";
498
498
  export { SellerTrustBadge } from "./features/scams/components/SellerTrustBadge.js";
499
499
  // [CLIENT-ONLY] — Admin panel components for action/nav permission management.
@@ -535,6 +535,7 @@ export declare const SELLER_ENDPOINTS: {
535
535
  readonly BUNDLES: "/api/store/bundles";
536
536
  /** Tickets raised ABOUT this store (W11). */
537
537
  readonly SUPPORT: "/api/store/support";
538
+ readonly SUPPORT_TICKET_BY_ID: (id: string) => string;
538
539
  readonly BUNDLE_BY_ID: (id: string) => string;
539
540
  readonly ORDERS_ASSIGN: (orderId: string) => string;
540
541
  readonly PRODUCT_BY_ID: (id: string) => string;
@@ -1061,6 +1062,7 @@ export declare const API_ENDPOINTS: {
1061
1062
  readonly BUNDLES: "/api/store/bundles";
1062
1063
  /** Tickets raised ABOUT this store (W11). */
1063
1064
  readonly SUPPORT: "/api/store/support";
1065
+ readonly SUPPORT_TICKET_BY_ID: (id: string) => string;
1064
1066
  readonly BUNDLE_BY_ID: (id: string) => string;
1065
1067
  readonly ORDERS_ASSIGN: (orderId: string) => string;
1066
1068
  readonly PRODUCT_BY_ID: (id: string) => string;
@@ -1608,6 +1610,7 @@ export declare const API_ROUTES: {
1608
1610
  readonly BUNDLES: "/api/store/bundles";
1609
1611
  /** Tickets raised ABOUT this store (W11). */
1610
1612
  readonly SUPPORT: "/api/store/support";
1613
+ readonly SUPPORT_TICKET_BY_ID: (id: string) => string;
1611
1614
  readonly BUNDLE_BY_ID: (id: string) => string;
1612
1615
  readonly ORDERS_ASSIGN: (orderId: string) => string;
1613
1616
  readonly PRODUCT_BY_ID: (id: string) => string;