@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
@@ -53,8 +53,8 @@ export declare const emiInstallmentSchema: z.ZodObject<{
53
53
  }, z.ZodTypeAny, "passthrough">>]>>;
54
54
  }, "strip", z.ZodTypeAny, {
55
55
  status: "paid" | "pending" | "overdue";
56
- amount: number;
57
56
  index: number;
57
+ amount: number;
58
58
  dueDate: string | Date | z.objectOutputType<{
59
59
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
60
60
  }, z.ZodTypeAny, "passthrough">;
@@ -68,8 +68,8 @@ export declare const emiInstallmentSchema: z.ZodObject<{
68
68
  }, z.ZodTypeAny, "passthrough"> | undefined;
69
69
  }, {
70
70
  status: "paid" | "pending" | "overdue";
71
- amount: number;
72
71
  index: number;
72
+ amount: number;
73
73
  dueDate: string | Date | z.objectInputType<{
74
74
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
75
75
  }, z.ZodTypeAny, "passthrough">;
@@ -405,8 +405,8 @@ export declare const orderFirestoreSchema: z.ZodObject<{
405
405
  }, z.ZodTypeAny, "passthrough">>]>>;
406
406
  }, "strip", z.ZodTypeAny, {
407
407
  status: "paid" | "pending" | "overdue";
408
- amount: number;
409
408
  index: number;
409
+ amount: number;
410
410
  dueDate: string | Date | z.objectOutputType<{
411
411
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
412
412
  }, z.ZodTypeAny, "passthrough">;
@@ -420,8 +420,8 @@ export declare const orderFirestoreSchema: z.ZodObject<{
420
420
  }, z.ZodTypeAny, "passthrough"> | undefined;
421
421
  }, {
422
422
  status: "paid" | "pending" | "overdue";
423
- amount: number;
424
423
  index: number;
424
+ amount: number;
425
425
  dueDate: string | Date | z.objectInputType<{
426
426
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
427
427
  }, z.ZodTypeAny, "passthrough">;
@@ -684,8 +684,8 @@ export declare const orderFirestoreSchema: z.ZodObject<{
684
684
  emiSurchargeAmount?: number | undefined;
685
685
  emiInstallments?: {
686
686
  status: "paid" | "pending" | "overdue";
687
- amount: number;
688
687
  index: number;
688
+ amount: number;
689
689
  dueDate: string | Date | z.objectOutputType<{
690
690
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
691
691
  }, z.ZodTypeAny, "passthrough">;
@@ -838,8 +838,8 @@ export declare const orderFirestoreSchema: z.ZodObject<{
838
838
  emiSurchargeAmount?: number | undefined;
839
839
  emiInstallments?: {
840
840
  status: "paid" | "pending" | "overdue";
841
- amount: number;
842
841
  index: number;
842
+ amount: number;
843
843
  dueDate: string | Date | z.objectInputType<{
844
844
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
845
845
  }, z.ZodTypeAny, "passthrough">;
@@ -29,6 +29,7 @@ import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js"
29
29
  import { normalizeListingType } from "../utils/listing-type.js";
30
30
  import { resolveGroupMemberImage } from "../utils/group-member-image.js";
31
31
  import { checkGroupRules, describeGroupRules, EMPTY_GROUP_RULES, } from "../../../_internal/shared/features/products/group-rules.js";
32
+ import { toUserMessage } from "../../../errors/error-display-map.js";
32
33
  const __O = { xAuto: "overflow-x-auto", hidden: "overflow-hidden" };
33
34
  const memberImage = resolveGroupMemberImage;
34
35
  function memberHref(m) {
@@ -112,9 +113,11 @@ export function GroupMemberPicker({ groupId, groupSource, members, rules = EMPTY
112
113
  onAuthRequired?.();
113
114
  return;
114
115
  }
116
+ // `code` is the stable identifier the error envelope carries; `error` is
117
+ // the server's own sentence and is deliberately no longer read here.
115
118
  const json = (await res.json());
116
119
  if (!res.ok || json.success === false) {
117
- setError(json.error ?? "Couldn't add those items to your cart.");
120
+ setError(toUserMessage(json.code, undefined, { fallback: "Couldn't add those items to your cart." }));
118
121
  return;
119
122
  }
120
123
  setQty({});
@@ -12,6 +12,7 @@ import { NonRefundableConsentModal } from "./NonRefundableConsentModal.js";
12
12
  import { useAuthGate } from "../../../react/hooks/useAuthGate.js";
13
13
  import { ACTION_ID } from "../constants/action-defs.js";
14
14
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
15
+ import { toUserMessage } from "../../../errors/error-display-map.js";
15
16
  /**
16
17
  * Client buy panel for a prize-draw detail page (SB4-G).
17
18
  *
@@ -81,7 +82,7 @@ export function PrizeDrawEntryActions({ productId, productSlug, title, pricePerE
81
82
  * a correct refusal look like the same silent nothing this flow did
82
83
  * before it was repaired.
83
84
  */
84
- showToast(n.message, "error");
85
+ showToast(toUserMessage(n.code, undefined, { fallback: "We couldn't enter you into that draw. Please try again." }), "error");
85
86
  return;
86
87
  }
87
88
  router.push(String(ROUTES.USER.CART));
@@ -15,6 +15,7 @@ import { useBottomActions } from "../../layout/hooks/useBottomActions.js";
15
15
  import { formatCurrency } from "../../../utils/number.formatter.js";
16
16
  import { useCanSeePrices } from "../../../react/hooks/useCanSeePrices.js";
17
17
  import { normalizeError } from "../../../errors/normalize.js";
18
+ import { toUserMessage } from "../../../errors/error-display-map.js";
18
19
  /**
19
20
  * Wires the three primary product-detail CTAs (Buy Now / Add to Cart / Wishlist)
20
21
  * to the server APIs with guest-fallback. Replaces the previously-unwired
@@ -35,7 +36,7 @@ export function ProductDetailActions({ productId, productTitle, productImage, pr
35
36
  // itself — centralised so every add-to-cart call site gets the same
36
37
  // richer toast, not just this one.
37
38
  const addCart = useAddToCart({
38
- onError: (err) => showToast(err?.message ?? "Could not add to cart", "error"),
39
+ onError: (err) => showToast(toUserMessage(err?.code, undefined, { fallback: "Could not add to cart" }), "error"),
39
40
  });
40
41
  async function handleAddToCart(then) {
41
42
  setBusy("cart");
@@ -9,7 +9,8 @@ import { apiClient } from "../../../http/index.js";
9
9
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
10
10
  import { BarcodeField } from "./BarcodeField.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
- import { useToast } from "../../../ui/index.js";
12
+ import { useToast, TextLink } from "../../../ui/index.js";
13
+ import { ROUTES } from "../../../next/routing/route-map.js";
13
14
  function statusBadgeVariant(status) {
14
15
  if (status === "confirmed")
15
16
  return "warning";
@@ -118,5 +119,5 @@ export function FulfillmentView({ currentUserId, }) {
118
119
  setScanError(null);
119
120
  }, children: "Cancel" })] })] })), !scanResult && !scanError && (_jsx(Text, { color: "muted", size: "sm", children: orders.length === 0 && !isLoadingOrders
120
121
  ? "No orders waiting — great work!"
121
- : `${orders.length} order${orders.length === 1 ? "" : "s"} in queue.` }))] })), mode === "list" && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Button, { variant: filterMine ? "primary" : "secondary", size: "sm", onClick: () => setFilterMine((v) => !v), children: filterMine ? "Showing My Orders" : "My Orders" }), _jsx(Text, { size: "xs", color: "muted", children: isLoadingOrders ? "Loading…" : `${visibleOrders.length} order${visibleOrders.length === 1 ? "" : "s"}` })] }), visibleOrders.length === 0 && !isLoadingOrders && (_jsx(Text, { color: "muted", children: "No orders waiting \u2014 great work!" })), visibleOrders.map((order) => (_jsxs(Stack, { surface: "card", padding: "sm", rounded: "md", gap: "xs", children: [_jsxs(Row, { align: "center", gap: "xs", wrap: true, children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["#", order.id] }), _jsx(Badge, { variant: statusBadgeVariant(order.status), children: order.status }), order.pickedAt && (_jsx(Badge, { variant: "success", children: "Picked" })), order.packedAt && (_jsx(Badge, { variant: "success", children: "Packed" }))] }), _jsxs(Text, { size: "xs", color: "muted", children: [order.userName, order.items && order.items.length > 0 && (_jsxs(_Fragment, { children: [" \u00B7 ", order.items.map((i) => i.productTitle).join(", ")] }))] }), order.assignedWorkerId && (_jsxs(Text, { size: "xs", color: "muted", children: ["Assigned: ", order.assignedWorkerId === currentUserId ? "Me" : order.assignedWorkerId] })), _jsxs(Row, { gap: "xs", children: [!order.pickedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPicked(order.id), isLoading: actionLoadingId === order.id + ":picked", children: "Mark Picked" })), order.pickedAt && !order.packedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPacked(order.id), isLoading: actionLoadingId === order.id + ":packed", children: "Mark Packed" }))] })] }, order.id)))] }))] }));
122
+ : `${orders.length} order${orders.length === 1 ? "" : "s"} in queue.` }))] })), mode === "list" && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Button, { variant: filterMine ? "primary" : "secondary", size: "sm", onClick: () => setFilterMine((v) => !v), children: filterMine ? "Showing My Orders" : "My Orders" }), _jsx(Text, { size: "xs", color: "muted", children: isLoadingOrders ? "Loading…" : `${visibleOrders.length} order${visibleOrders.length === 1 ? "" : "s"}` })] }), visibleOrders.length === 0 && !isLoadingOrders && (_jsx(Text, { color: "muted", children: "No orders waiting \u2014 great work!" })), visibleOrders.map((order) => (_jsxs(Stack, { surface: "card", padding: "sm", rounded: "md", gap: "xs", children: [_jsxs(Row, { align: "center", gap: "xs", wrap: true, children: [_jsxs(TextLink, { href: String(ROUTES.STORE.ORDER_DETAIL(order.id)), size: "sm", weight: "medium", children: ["#", order.id] }), _jsx(Badge, { variant: statusBadgeVariant(order.status), children: order.status }), order.pickedAt && (_jsx(Badge, { variant: "success", children: "Picked" })), order.packedAt && (_jsx(Badge, { variant: "success", children: "Packed" }))] }), _jsxs(Text, { size: "xs", color: "muted", children: [order.userName, order.items && order.items.length > 0 && (_jsxs(_Fragment, { children: [" \u00B7 ", order.items.map((i) => i.productTitle).join(", ")] }))] }), order.assignedWorkerId && (_jsxs(Text, { size: "xs", color: "muted", children: ["Assigned: ", order.assignedWorkerId === currentUserId ? "Me" : order.assignedWorkerId] })), _jsxs(Row, { gap: "xs", children: [!order.pickedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPicked(order.id), isLoading: actionLoadingId === order.id + ":picked", children: "Mark Picked" })), order.pickedAt && !order.packedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPacked(order.id), isLoading: actionLoadingId === order.id + ":packed", children: "Mark Packed" }))] })] }, order.id)))] }))] }));
122
123
  }
@@ -11,6 +11,7 @@ import { useEntityDelete } from "../../../react/hooks/useEntityDelete.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
12
  import { TextLink } from "../../../ui/index.js";
13
13
  import { ROUTES } from "../../../constants/index.js";
14
+ import { toUserMessage } from "../../../errors/error-display-map.js";
14
15
  const __O = {
15
16
  xAuto: "overflow-x-auto",
16
17
  };
@@ -47,9 +48,9 @@ function fromDoc(doc) {
47
48
  isDefault: doc.isDefault,
48
49
  };
49
50
  }
50
- function AddressCard({ address, onEdit, onDelete, }) {
51
+ function AddressCard({ address, onEdit, onDelete, onSetDefault, }) {
51
52
  const isBanned = address.banStatus === "banned";
52
- return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "sm", children: [_jsxs(Row, { align: "start", justify: "between", gap: "xs", children: [_jsxs(Row, { gap: "xs", wrap: true, className: "min-w-0", children: [_jsx(MapPin, { className: "h-4 w-4 shrink-0 text-[var(--appkit-color-primary)]" }), _jsx(Span, { size: "sm", weight: "semibold", className: "truncate", color: "primary", children: address.label }), address.isDefault && (_jsxs(Span, { size: "xs", weight: "medium", className: CLS_DEFAULT_PILL, children: [_jsx(Star, { className: "h-3 w-3" }), "Default"] })), isBanned && _jsx(Badge, { variant: "danger", size: "sm", children: "Banned" })] }), _jsx(Row, { gap: "px", className: "shrink-0", children: isBanned ? (_jsx(Span, { size: "xs", color: "muted", title: "Contact support to resolve address ban", children: "Contact support" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { type: "button", onClick: onEdit, title: "Edit address", "aria-label": "Edit address", variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)] hover:text-[var(--appkit-color-text-muted)]", children: _jsx(Pencil, { className: "h-4 w-4" }) }), _jsx(IconButton, { type: "button", onClick: onDelete, title: "Delete address", "aria-label": "Delete address", variant: "ghost", rounded: "lg", className: CLS_DELETE_BTN, children: _jsx(Trash2, { className: "h-4 w-4" }) })] })) })] }), _jsxs(Text, { size: "sm", color: "muted", children: [address.fullName, " \u00B7 ", address.phone] }), _jsxs(Text, { className: "leading-relaxed", color: "muted", size: "xs", children: [address.addressLine1, address.addressLine2 ? `, ${address.addressLine2}` : "", address.landmark ? ` (near ${address.landmark})` : "", _jsx("br", {}), address.city, ", ", address.state, " ", address.postalCode, ", ", address.country] })] }));
53
+ return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "sm", children: [_jsxs(Row, { align: "start", justify: "between", gap: "xs", children: [_jsxs(Row, { gap: "xs", wrap: true, className: "min-w-0", children: [_jsx(MapPin, { className: "h-4 w-4 shrink-0 text-[var(--appkit-color-primary)]" }), _jsx(Span, { size: "sm", weight: "semibold", className: "truncate", color: "primary", children: address.label }), address.isDefault && (_jsxs(Span, { size: "xs", weight: "medium", className: CLS_DEFAULT_PILL, children: [_jsx(Star, { className: "h-3 w-3" }), "Default"] })), isBanned && _jsx(Badge, { variant: "danger", size: "sm", children: "Banned" })] }), _jsx(Row, { gap: "px", className: "shrink-0", children: isBanned ? (_jsx(Span, { size: "xs", color: "muted", title: "Contact support to resolve address ban", children: "Contact support" })) : (_jsxs(_Fragment, { children: [!address.isDefault && (_jsx(IconButton, { type: "button", onClick: onSetDefault, title: "Set as default pickup address", "aria-label": `Set ${address.label} as the default pickup address`, variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)] hover:text-[var(--appkit-color-text-muted)]", children: _jsx(Star, { className: "h-4 w-4" }) })), _jsx(IconButton, { type: "button", onClick: onEdit, title: "Edit address", "aria-label": "Edit address", variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)] hover:text-[var(--appkit-color-text-muted)]", children: _jsx(Pencil, { className: "h-4 w-4" }) }), _jsx(IconButton, { type: "button", onClick: onDelete, title: "Delete address", "aria-label": "Delete address", variant: "ghost", rounded: "lg", className: CLS_DELETE_BTN, children: _jsx(Trash2, { className: "h-4 w-4" }) })] })) })] }), _jsxs(Text, { size: "sm", color: "muted", children: [address.fullName, " \u00B7 ", address.phone] }), _jsxs(Text, { className: "leading-relaxed", color: "muted", size: "xs", children: [address.addressLine1, address.addressLine2 ? `, ${address.addressLine2}` : "", address.landmark ? ` (near ${address.landmark})` : "", _jsx("br", {}), address.city, ", ", address.state, " ", address.postalCode, ", ", address.country] })] }));
53
54
  }
54
55
  // ---------------------------------------------------------------------------
55
56
  // Main Component
@@ -129,7 +130,7 @@ export function SellerAddressesView({ apiBase = SELLER_ENDPOINTS.STORE_ADDRESSES
129
130
  }
130
131
  catch (err) {
131
132
  void normalizeError(err);
132
- setSaveError(err instanceof Error ? err.message : "Failed to save address");
133
+ setSaveError(toUserMessage(err?.code, undefined, { fallback: "Failed to save address" }));
133
134
  }
134
135
  finally {
135
136
  setSaving(false);
@@ -141,9 +142,36 @@ export function SellerAddressesView({ apiBase = SELLER_ENDPOINTS.STORE_ADDRESSES
141
142
  onSuccess: () => { load(); },
142
143
  });
143
144
  const handleDelete = (addr) => setDeleteTargetAddr(addr);
145
+ /**
146
+ * Promote one pickup address to the default.
147
+ *
148
+ * A partial PUT rather than a dedicated endpoint: unlike buyer addresses,
149
+ * which have `/api/user/addresses/[id]/set-default`, the store side has no
150
+ * such route — but `addressUpdateSchema` is the form schema `.partial()`,
151
+ * so `{ isDefault: true }` on its own is a valid body.
152
+ */
153
+ const handleSetDefault = async (addr) => {
154
+ try {
155
+ // Same request shape as `handleSave` below, rather than a second
156
+ // hand-rolled fetch beside it.
157
+ const method = "PUT";
158
+ const res = await fetch(`${apiBase}/${addr.id}`, {
159
+ method,
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({ isDefault: true }),
162
+ });
163
+ if (!res.ok)
164
+ throw new Error("Failed to set default address");
165
+ await load();
166
+ }
167
+ catch (err) {
168
+ const normalized = normalizeError(err);
169
+ setSaveError(toUserMessage(normalized.code, undefined, { fallback: "Failed to set default address" }));
170
+ }
171
+ };
144
172
  const set = (key, value) => setDraft((p) => ({ ...p, [key]: value }));
145
173
  const setField = (key) => (value) => set(key, value);
146
- return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", children: _jsxs(Row, { justify: "between", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", color: "primary", children: "Pickup Addresses" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: "Manage your store's pickup and return locations" })] }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TextLink, { href: String(ROUTES.STORE.ADDRESSES_NEW), children: "Open full page \u2192" }), _jsxs(Button, { gap: "sm", size: "sm", onClick: openAdd, children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx(Span, { children: "Add Address" })] })] })] }) }), _jsxs(Div, { paddingX: "x-md-lg", className: "max-w-2xl", padding: "y-lg", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), isLoading ? (_jsx(Row, { justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })) : addresses.length === 0 ? (_jsxs(Stack, { border: "default", className: "border-2 border-dashed", padding: "y-4xl", align: "center", gap: "3", rounded: "xl", children: [_jsx(MapPin, { className: "h-8 w-8 text-[var(--appkit-color-text-muted)]" }), _jsx(Text, { size: "sm", color: "muted", children: "No pickup addresses yet" }), _jsx(Button, { size: "sm", variant: "outline", onClick: openAdd, children: "Add your first address" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Row, { justify: "end", className: "mb-2", children: [_jsx(Button, { size: "sm", variant: listView === "table" ? "primary" : "ghost", onClick: () => setListView("table"), children: "Table" }), _jsx(Button, { size: "sm", variant: listView === "cards" ? "primary" : "ghost", onClick: () => setListView("cards"), children: "Cards" })] }), listView === "cards" ? (_jsx(Grid, { gap: "sm", children: addresses.map((addr) => (_jsx(Div, { className: deletingId === addr.id ? "opacity-50 pointer-events-none" : "", children: _jsx(AddressCard, { address: addr, onEdit: () => openEdit(addr), onDelete: () => handleDelete(addr) }) }, addr.id))) })) : (_jsx(Div, { className: `${__O.xAuto}`, rounded: "lg", border: "default", children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { surface: "muted", children: _jsxs(Tr, { children: [_jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Label" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Name" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "City" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Phone" }), _jsx(Th, { className: "text-right", padding: "sm", weight: "semibold", children: "Actions" })] }) }), _jsx(Tbody, { children: addresses.map((addr) => {
174
+ return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", children: _jsxs(Row, { justify: "between", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", color: "primary", children: "Pickup Addresses" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: "Manage your store's pickup and return locations" })] }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TextLink, { href: String(ROUTES.STORE.ADDRESSES_NEW), children: "Open full page \u2192" }), _jsxs(Button, { gap: "sm", size: "sm", onClick: openAdd, children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx(Span, { children: "Add Address" })] })] })] }) }), _jsxs(Div, { paddingX: "x-md-lg", className: "max-w-2xl", padding: "y-lg", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), isLoading ? (_jsx(Row, { justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })) : addresses.length === 0 ? (_jsxs(Stack, { border: "default", className: "border-2 border-dashed", padding: "y-4xl", align: "center", gap: "3", rounded: "xl", children: [_jsx(MapPin, { className: "h-8 w-8 text-[var(--appkit-color-text-muted)]" }), _jsx(Text, { size: "sm", color: "muted", children: "No pickup addresses yet" }), _jsx(Button, { size: "sm", variant: "outline", onClick: openAdd, children: "Add your first address" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Row, { justify: "end", className: "mb-2", children: [_jsx(Button, { size: "sm", variant: listView === "table" ? "primary" : "ghost", onClick: () => setListView("table"), children: "Table" }), _jsx(Button, { size: "sm", variant: listView === "cards" ? "primary" : "ghost", onClick: () => setListView("cards"), children: "Cards" })] }), listView === "cards" ? (_jsx(Grid, { gap: "sm", children: addresses.map((addr) => (_jsx(Div, { className: deletingId === addr.id ? "opacity-50 pointer-events-none" : "", children: _jsx(AddressCard, { address: addr, onEdit: () => openEdit(addr), onDelete: () => handleDelete(addr), onSetDefault: () => void handleSetDefault(addr) }) }, addr.id))) })) : (_jsx(Div, { className: `${__O.xAuto}`, rounded: "lg", border: "default", children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { surface: "muted", children: _jsxs(Tr, { children: [_jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Label" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Name" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "City" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Phone" }), _jsx(Th, { className: "text-right", padding: "sm", weight: "semibold", children: "Actions" })] }) }), _jsx(Tbody, { children: addresses.map((addr) => {
147
175
  const isBanned = addr.banStatus === "banned";
148
176
  return (_jsxs(Tr, { className: `border-t border-[var(--appkit-color-border)] ${deletingId === addr.id ? "opacity-50" : ""}`, children: [_jsx(Td, { padding: "sm", children: _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Span, { children: addr.label }), isBanned && _jsx(Badge, { variant: "danger", size: "sm", children: "Banned" })] }) }), _jsx(Td, { padding: "sm", children: addr.fullName }), _jsxs(Td, { padding: "sm", children: [addr.city, ", ", addr.state] }), _jsx(Td, { className: "tabular-nums", padding: "sm", children: addr.phone }), _jsx(Td, { className: "text-right", padding: "sm", children: isBanned ? (_jsx(Span, { size: "xs", color: "muted", title: "Contact support to resolve address ban", children: "Contact support" })) : (_jsxs(Row, { justify: "end", gap: "xs", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => openEdit(addr), children: ROW_ACTION_META[ROW_ACTION_ID.EDIT].label }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => handleDelete(addr), children: ROW_ACTION_META[ROW_ACTION_ID.DELETE].label })] })) })] }, addr.id));
149
177
  }) })] }) }))] }))] }), _jsx(SideDrawer, { isOpen: drawerOpen, onClose: closeDrawer, title: editingId ? "Edit Address" : "Add Address", footer:
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React, { useState, useCallback } from "react";
5
+ import { z } from "zod";
5
6
  import { alertMetricsForScope } from "../../store-extensions/constants/alert-metrics.js";
6
7
  import { useQuery, useQueryClient } from "@tanstack/react-query";
7
8
  import { Alert, Badge, Button, Div, Heading, Row, Section, Stack, Text, Toggle, useToast } from "../../../ui/index.js";
@@ -14,6 +15,7 @@ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues.js";
14
15
  import { buildSectionsFromSchema, visibleValues } from "../../shell/build-sections.js";
15
16
  import { SectionForm, useSectionFormNav } from "../../shell/SectionForm.js";
16
17
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
18
+ import { QuickEditMenu } from "../../admin/components/QuickEditMenu.js";
17
19
  const __P = {
18
20
  p5: "p-[var(--appkit-space-5)]",
19
21
  };
@@ -80,11 +82,55 @@ const OPERATOR_BADGE = {
80
82
  // ---------------------------------------------------------------------------
81
83
  // Sub-components
82
84
  // ---------------------------------------------------------------------------
83
- function AlertCard({ alert, onToggle, onDelete, }) {
85
+ /**
86
+ * The three values a seller actually tunes once an alert is live.
87
+ *
88
+ * Deliberately not `metric` or `operator`: changing either turns the alert
89
+ * into a different alert while keeping its firing history, so
90
+ * `lastTriggeredAt`/`lastObservedValue` on the card would then describe a rule
91
+ * that no longer exists. Those stay a delete-and-recreate.
92
+ */
93
+ const alertQuickEditSchema = z.object({
94
+ label: z.string().trim().min(1, "A name is required."),
95
+ threshold: z.coerce.number().min(0, "Threshold cannot be negative."),
96
+ windowHours: z.coerce.number().int().min(1),
97
+ });
98
+ const ALERT_QUICK_FIELDS = [
99
+ { name: "label", label: "Name", type: "text", required: true },
100
+ {
101
+ name: "threshold",
102
+ label: "Threshold",
103
+ type: "number",
104
+ required: true,
105
+ helperText: "The value the metric is compared against.",
106
+ },
107
+ {
108
+ name: "windowHours",
109
+ label: "Window",
110
+ type: "select",
111
+ required: true,
112
+ options: WINDOW_OPTIONS,
113
+ },
114
+ ];
115
+ function AlertCard({ alert, onToggle, onDelete, onEdit, }) {
84
116
  const metricLabel = METRIC_OPTIONS.find((m) => m.value === alert.metric)?.label ?? alert.metric;
85
117
  const opLabel = OPERATOR_OPTIONS.find((o) => o.value === alert.operator)?.label ?? alert.operator;
86
118
  const windowLabel = WINDOW_OPTIONS.find((w) => w.value === String(alert.windowHours))?.label ?? `${alert.windowHours}h`;
87
- return (_jsxs(Row, { surface: "card", padding: "sm", align: "start", justify: "between", gap: "md", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-1", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: alert.label }), _jsx(Badge, { variant: alert.isActive ? "success" : "default", children: alert.isActive ? "Active" : "Paused" })] }), _jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: [metricLabel, " ", opLabel, " ", alert.threshold, " \u00B7 ", windowLabel] }), METRIC_DESCRIPTIONS[alert.metric] ? (_jsx(Text, { className: "mb-1", color: "muted", size: "xs", children: METRIC_DESCRIPTIONS[alert.metric] })) : null, alert.lastTriggeredAt ? (_jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: ["Last fired ", new Date(alert.lastTriggeredAt).toLocaleString(), alert.lastObservedValue != null ? ` at ${alert.lastObservedValue}` : ""] })) : null, _jsx(Row, { gap: "xs", wrap: true, children: alert.notifyChannels.map((ch) => (_jsx(Badge, { variant: OPERATOR_BADGE[alert.operator] ?? "default", children: ch }, ch))) }), alert.lastTriggeredAt && (_jsxs(Text, { className: "mt-1", color: "faint", size: "xs", children: ["Last triggered: ", new Date(alert.lastTriggeredAt).toLocaleString("en-IN")] }))] }), _jsxs(Row, { className: "shrink-0", align: "center", gap: "sm", children: [_jsx(Toggle, { checked: alert.isActive, onChange: (v) => onToggle(alert.id, v), size: "sm" }), _jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-analytics-alert"], onClick: () => onDelete(alert.id), className: "text-error hover:text-error" })] })] }));
119
+ return (_jsxs(Row, { surface: "card", padding: "sm", align: "start", justify: "between", gap: "md", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-1", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: alert.label }), _jsx(Badge, { variant: alert.isActive ? "success" : "default", children: alert.isActive ? "Active" : "Paused" })] }), _jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: [metricLabel, " ", opLabel, " ", alert.threshold, " \u00B7 ", windowLabel] }), METRIC_DESCRIPTIONS[alert.metric] ? (_jsx(Text, { className: "mb-1", color: "muted", size: "xs", children: METRIC_DESCRIPTIONS[alert.metric] })) : null, alert.lastTriggeredAt ? (_jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: ["Last fired ", new Date(alert.lastTriggeredAt).toLocaleString(), alert.lastObservedValue != null ? ` at ${alert.lastObservedValue}` : ""] })) : null, _jsx(Row, { gap: "xs", wrap: true, children: alert.notifyChannels.map((ch) => (_jsx(Badge, { variant: OPERATOR_BADGE[alert.operator] ?? "default", children: ch }, ch))) }), alert.lastTriggeredAt && (_jsxs(Text, { className: "mt-1", color: "faint", size: "xs", children: ["Last triggered: ", new Date(alert.lastTriggeredAt).toLocaleString("en-IN")] }))] }), _jsxs(Row, { className: "shrink-0", align: "center", gap: "sm", children: [_jsx(Toggle, { checked: alert.isActive, onChange: (v) => onToggle(alert.id, v), size: "sm", ariaLabel: `${alert.isActive ? "Pause" : "Resume"} ${alert.label}` }), _jsx(QuickEditMenu, { actions: [
120
+ {
121
+ label: "Edit alert",
122
+ formTitle: `Edit "${alert.label}"`,
123
+ schema: alertQuickEditSchema,
124
+ fields: ALERT_QUICK_FIELDS,
125
+ defaultValues: {
126
+ label: alert.label,
127
+ threshold: alert.threshold,
128
+ windowHours: String(alert.windowHours),
129
+ },
130
+ onSubmit: (values) => onEdit(alert.id, values),
131
+ submitLabel: "Save alert",
132
+ },
133
+ ] }), _jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-analytics-alert"], onClick: () => onDelete(alert.id), className: "text-error hover:text-error" })] })] }));
88
134
  }
89
135
  const EMPTY_DRAFT = {
90
136
  label: "",
@@ -142,6 +188,17 @@ export function SellerAnalyticsAlertsView({ labels = {}, }) {
142
188
  void queryClient.invalidateQueries({ queryKey: ["seller", "analytics-alerts"] });
143
189
  },
144
190
  });
191
+ const editMutation = useApiMutation({
192
+ errorMessage: "Failed to update alert",
193
+ mutationFn: async ({ id, values }) => {
194
+ const parsed = alertQuickEditSchema.parse(values);
195
+ return apiClient.patch(SELLER_ENDPOINTS.ANALYTICS_ALERT_BY_ID(id), parsed);
196
+ },
197
+ onSuccess: () => {
198
+ showToast("Alert updated", "success");
199
+ void queryClient.invalidateQueries({ queryKey: ["seller", "analytics-alerts"] });
200
+ },
201
+ });
145
202
  const toggleChannel = useCallback((ch) => {
146
203
  setDraft((d) => ({
147
204
  ...d,
@@ -192,5 +249,7 @@ export function SellerAnalyticsAlertsView({ labels = {}, }) {
192
249
  * page's own form — a viewport-pinned Save would claim the
193
250
  * bottom tier for a control the reader may have scrolled past.
194
251
  */
195
- bottomBar: false })] })] })), isLoading && (_jsx(Stack, { gap: "sm", children: [1, 2, 3].map((i) => (_jsx(Div, { className: "h-16 animate-pulse", surface: "subtle", rounded: "lg" }, i))) })), !isLoading && alerts.length === 0 && !showForm && (_jsx(Alert, { variant: "info", children: "No alerts configured. Create an alert to get notified when a key metric crosses a threshold." })), !isLoading && alerts.length > 0 && (_jsx(Stack, { gap: "3", children: alerts.map((alert) => (_jsx(AlertCard, { alert: alert, onToggle: (id, isActive) => toggleMutation.mutate({ id, isActive }), onDelete: (id) => deleteMutation.mutate(id) }, alert.id))) }))] }));
252
+ bottomBar: false })] })] })), isLoading && (_jsx(Stack, { gap: "sm", children: [1, 2, 3].map((i) => (_jsx(Div, { className: "h-16 animate-pulse", surface: "subtle", rounded: "lg" }, i))) })), !isLoading && alerts.length === 0 && !showForm && (_jsx(Alert, { variant: "info", children: "No alerts configured. Create an alert to get notified when a key metric crosses a threshold." })), !isLoading && alerts.length > 0 && (_jsx(Stack, { gap: "3", children: alerts.map((alert) => (_jsx(AlertCard, { alert: alert, onToggle: (id, isActive) => toggleMutation.mutate({ id, isActive }), onDelete: (id) => deleteMutation.mutate(id), onEdit: async (id, values) => {
253
+ await editMutation.mutateAsync({ id, values });
254
+ } }, alert.id))) }))] }));
196
255
  }
@@ -1,12 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { SIEVE_OP, sieveFilter, sortBy } from "@mohasinac/appkit/client";
4
- import { Badge, Button, FilterChipGroup, Stack, Text, TextLink } from "../../../ui/index.js";
4
+ import { Badge, Button, FilterChipGroup, Stack, Text, TextLink, Toggle } from "../../../ui/index.js";
5
5
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
6
6
  import { ROW_ACTION_META, ROW_ACTION_ID } from "../../products/constants/action-defs.js";
7
7
  import { SELLER_BULK_ACTIONS } from "../../products/constants/action-defs.js";
8
8
  import { BUNDLE_COPY, BUNDLE_STOCK_VARIANT, } from "../../../_internal/shared/features/categories/bundle-copy.js";
9
9
  import { toRecordArray, toRelativeDate, toStringValue, } from "../../admin/hooks/useAdminListingData.js";
10
+ import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
11
+ import { apiClient } from "../../../http/index.js";
10
12
  import { DataListingView } from "../../admin/components/DataListingView.js";
11
13
  function formatPrice(value) {
12
14
  if (typeof value !== "number" || value <= 0)
@@ -38,6 +40,22 @@ const COLUMNS = [
38
40
  { key: "updatedAt", header: "Updated", className: "w-28" },
39
41
  ];
40
42
  export function SellerBundlesView({ editHrefTemplate, newHref }) {
43
+ /*
44
+ * 🛑 `isActive` was a READ-ONLY badge here, and the update schema was built
45
+ * to take it on its own — shipping-config's even says so in a comment: "a
46
+ * PATCH may legitimately carry only `isActive`". Nothing in the client had
47
+ * ever sent one, so flipping one boolean meant a full-editor round trip.
48
+ *
49
+ * The optimistic/rollback logic lives in the hook because eight listings
50
+ * grew this control at once; see its header for why rollback must DELETE
51
+ * the override key rather than write back a captured value.
52
+ */
53
+ const activeFlag = useOptimisticRowFlag({
54
+ mutate: (id, next) => apiClient.put(SELLER_ENDPOINTS.BUNDLE_BY_ID(id), { isActive: next }),
55
+ invalidate: [["seller", "bundles", "listing"], ["bundles"]],
56
+ successMessage: (next) => (next ? "Bundle activated." : "Bundle deactivated."),
57
+ errorMessage: "Could not change that bundle's status.",
58
+ });
41
59
  const config = {
42
60
  portal: "seller",
43
61
  title: "Bundles",
@@ -60,19 +78,29 @@ export function SellerBundlesView({ editHrefTemplate, newHref }) {
60
78
  { value: sortBy("createdAt", "ASC"), label: "Oldest" },
61
79
  ],
62
80
  columns: COLUMNS,
63
- mapRows: (response) => toRecordArray(response.items).map((item, index) => ({
64
- id: toStringValue(item.id, `bundle-${index}`),
65
- primary: toStringValue(item.name, "Untitled bundle"),
66
- secondary: toStringValue(item.slug, "no-slug"),
67
- price: formatPrice(item.bundlePrice),
68
- members: String(Array.isArray(item.bundleProductIds) ? item.bundleProductIds.length : 0),
69
- stockStatus: toStringValue(item.bundleStockStatus, "in_stock"),
70
- isActive: item.isActive === true,
71
- status: item.isActive === true
72
- ? BUNDLE_COPY.adminList.activeBadge
73
- : BUNDLE_COPY.adminList.inactiveBadge,
74
- updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
75
- })),
81
+ mapRows: (response) => toRecordArray(response.items).map((item, index) => {
82
+ const id = toStringValue(item.id, `bundle-${index}`);
83
+ /*
84
+ * Resolved ONCE, through the pending override, and then used for both
85
+ * `isActive` and the `status` LABEL below. Deriving the label from the
86
+ * raw value while the flag read the override is how the badge ends up
87
+ * saying "Inactive" beside a switch that is on.
88
+ */
89
+ const isActive = activeFlag.resolve(id, item.isActive === true);
90
+ return {
91
+ id,
92
+ primary: toStringValue(item.name, "Untitled bundle"),
93
+ secondary: toStringValue(item.slug, "no-slug"),
94
+ price: formatPrice(item.bundlePrice),
95
+ members: String(Array.isArray(item.bundleProductIds) ? item.bundleProductIds.length : 0),
96
+ stockStatus: toStringValue(item.bundleStockStatus, "in_stock"),
97
+ isActive,
98
+ status: isActive
99
+ ? BUNDLE_COPY.adminList.activeBadge
100
+ : BUNDLE_COPY.adminList.inactiveBadge,
101
+ updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
102
+ };
103
+ }),
76
104
  getTotal: (response, mappedRows) => typeof response.total === "number" ? response.total : mappedRows.length,
77
105
  buildFilters: (filterState) => {
78
106
  const parts = [];
@@ -83,6 +111,17 @@ export function SellerBundlesView({ editHrefTemplate, newHref }) {
83
111
  return parts.join(",") || undefined;
84
112
  },
85
113
  rowHrefTemplate: editHrefTemplate,
114
+ /*
115
+ * 🛑 This view had NO renderRowActions at all — the only per-row
116
+ * affordance was the row link to the editor, so a bundle could only be
117
+ * activated or deactivated by opening it.
118
+ *
119
+ * The toggle lives here rather than in a column because `rowHrefTemplate`
120
+ * makes the row an anchor: a normal column cell does not stop
121
+ * propagation, so a switch there would flip AND navigate. This slot is
122
+ * already click-stopped in all three renderers.
123
+ */
124
+ renderRowActions: (row) => (_jsx(Toggle, { size: "sm", checked: row.isActive, disabled: activeFlag.pendingId === row.id, ariaLabel: `${row.isActive ? "Deactivate" : "Activate"} ${row.primary}`, onChange: (next) => { void activeFlag.set(row.id, next); } })),
86
125
  toolbarExtra: (_jsx(Button, { asChild: true, size: "sm", variant: "primary", children: _jsx(TextLink, { href: newHref, layout: "flex", align: "center", gap: "xs", children: "+ New Bundle" }) })),
87
126
  // Rule #7: bulk-action array sourced from the SELLER_BULK_ACTIONS preset.
88
127
  buildBulkActions: (selection) => {
@@ -1,11 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Row, sortBy } from "@mohasinac/appkit/client";
4
- import { Badge, Button, Span } from "../../../ui/index.js";
4
+ import { Badge, Button, Span, Toggle } from "../../../ui/index.js";
5
5
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
6
6
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
7
7
  import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers.js";
8
8
  import { toRecordArray, toRelativeDate, toStringValue, } from "../../admin/hooks/useAdminListingData.js";
9
+ import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
10
+ import { apiClient } from "../../../http/index.js";
9
11
  import { DataListingView } from "../../admin/components/DataListingView.js";
10
12
  const THEME_LABELS = {
11
13
  related: "Related",
@@ -51,6 +53,22 @@ const COLUMNS = [
51
53
  },
52
54
  ];
53
55
  export function SellerGroupedListingsView({ onCreateClick, onEditClick, onDeleteClick, }) {
56
+ /*
57
+ * 🛑 `isActive` was a READ-ONLY badge here, and the update schema was built
58
+ * to take it on its own — shipping-config's even says so in a comment: "a
59
+ * PATCH may legitimately carry only `isActive`". Nothing in the client had
60
+ * ever sent one, so flipping one boolean meant a full-editor round trip.
61
+ *
62
+ * The optimistic/rollback logic lives in the hook because eight listings
63
+ * grew this control at once; see its header for why rollback must DELETE
64
+ * the override key rather than write back a captured value.
65
+ */
66
+ const activeFlag = useOptimisticRowFlag({
67
+ mutate: (id, next) => apiClient.patch(SELLER_ENDPOINTS.GROUPED_LISTING_BY_ID(id), { isActive: next }),
68
+ invalidate: [["seller", "grouped-listings"], ["grouped-listings"]],
69
+ successMessage: (next) => (next ? "Group activated." : "Group deactivated."),
70
+ errorMessage: "Could not change that group's status.",
71
+ });
54
72
  const config = {
55
73
  portal: "seller",
56
74
  title: "Grouped Listings",
@@ -72,7 +90,9 @@ export function SellerGroupedListingsView({ onCreateClick, onEditClick, onDelete
72
90
  title: toStringValue(item.title, "Untitled group"),
73
91
  groupTheme: toStringValue(item.groupTheme, "generic"),
74
92
  productCount: Array.isArray(item.productIds) ? item.productIds.length : 0,
75
- isActive: item.isActive === true,
93
+ // Resolved through the pending override so the Status badge and the
94
+ // row toggle cannot disagree between the click and the refetch.
95
+ isActive: activeFlag.resolve(toStringValue(item.id, `group-${index}`), item.isActive === true),
76
96
  visibilityStatus: toStringValue(item.visibilityStatus, "hidden"),
77
97
  createdAt: toRelativeDate(item.createdAt),
78
98
  })),
@@ -92,7 +112,7 @@ export function SellerGroupedListingsView({ onCreateClick, onEditClick, onDelete
92
112
  : undefined,
93
113
  // Mirrors the "Edit" row action below so table rows and cards both navigate on click.
94
114
  onRowClick: onEditClick ? (row) => onEditClick(row.id) : undefined,
95
- renderRowActions: (row) => (_jsxs(Row, { gap: "xs", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => onEditClick?.(row.id), children: ACTIONS.STORE["edit-listing"].label }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => onDeleteClick?.(row.id), children: ACTIONS.STORE["delete-listing"].label })] })),
115
+ renderRowActions: (row) => (_jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Toggle, { size: "sm", checked: row.isActive, disabled: activeFlag.pendingId === row.id, ariaLabel: `${row.isActive ? "Deactivate" : "Activate"} ${row.title}`, onChange: (next) => { void activeFlag.set(row.id, next); } }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => onEditClick?.(row.id), children: ACTIONS.STORE["edit-listing"].label }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => onDeleteClick?.(row.id), children: ACTIONS.STORE["delete-listing"].label })] })),
96
116
  };
97
117
  return _jsx(DataListingView, { config: config });
98
118
  }
@@ -37,6 +37,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
37
37
  */
38
38
  import React, { useCallback, useState } from "react";
39
39
  import { Badge, ConfirmDeleteModal, RowActionMenu, Span, Text } from "../../../ui/index.js";
40
+ import { apiClient } from "../../../http/index.js";
40
41
  import { useEntityDelete } from "../../../react/hooks/useEntityDelete.js";
41
42
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
42
43
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
@@ -46,6 +47,7 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names.js";
46
47
  import { SIEVE_OP, sieveFilter } from "../../../utils/sieve-builder.js";
47
48
  import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js";
48
49
  import { toCurrency, toRecordArray, toRelativeDate, toStringValue, } from "../../admin/hooks/useAdminListingData.js";
50
+ import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
49
51
  import { DataListingView } from "../../admin/components/DataListingView.js";
50
52
  import { ROW_ACTION_META, SELLER_BULK_ACTIONS } from "../../products/constants/action-defs.js";
51
53
  import { useAvailabilityScope } from "../../products/hooks/useAvailabilityScope.js";
@@ -204,6 +206,17 @@ export function SellerListingTypeView({ type, onCreateClick, onEditClick, onDele
204
206
  await performDelete(id);
205
207
  setDeleteTargetId(null);
206
208
  }, [performDelete]);
209
+ /*
210
+ * Optimistic status, via the shared hook — see its header for why a
211
+ * rollback must DELETE the override key rather than write back a captured
212
+ * value, and why the key is cleared on success too.
213
+ */
214
+ const statusFlag = useOptimisticRowFlag({
215
+ mutate: (id, next) => apiClient.patch(SELLER_ENDPOINTS.PRODUCT_BY_ID(id), { status: next }),
216
+ invalidate: [["seller", plugin.tabSlug]],
217
+ successMessage: (next) => next === "published" ? "Listing published." : "Listing unpublished.",
218
+ errorMessage: "Could not change that listing's status.",
219
+ });
207
220
  const handleEdit = useCallback((id) => {
208
221
  if (onEditClick)
209
222
  onEditClick(id);
@@ -233,7 +246,15 @@ export function SellerListingTypeView({ type, onCreateClick, onEditClick, onDele
233
246
  {
234
247
  key: "status",
235
248
  header: "Status",
236
- render: (row) => (_jsx(Badge, { variant: row.status === "active" ? "active" : "inactive", size: "xs", className: "capitalize", children: row.status })),
249
+ /*
250
+ * Reads the optimistic override so the badge and the row action agree
251
+ * the instant one is used — otherwise the menu would say "Unpublish"
252
+ * while the badge beside it still said published, until the refetch.
253
+ */
254
+ render: (row) => {
255
+ const status = statusFlag.resolve(row.id, row.status);
256
+ return (_jsx(Badge, { variant: status === "active" || status === "published" ? "active" : "inactive", size: "xs", className: "capitalize", children: status }));
257
+ },
237
258
  },
238
259
  textCell("createdAt", spec.createdHeader ?? "Created"),
239
260
  ];
@@ -291,15 +312,40 @@ export function SellerListingTypeView({ type, onCreateClick, onEditClick, onDele
291
312
  },
292
313
  }))
293
314
  : undefined,
294
- renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
295
- { label: ACTIONS.STORE["edit-listing"].label, onClick: () => handleEdit(row.id) },
296
- {
297
- label: ACTIONS.STORE["delete-listing"].label,
298
- destructive: true,
299
- onClick: () => setDeleteTargetId(row.id),
300
- disabled: deletingId === row.id,
301
- },
302
- ] })),
315
+ renderRowActions: (row) => {
316
+ const isPublished = statusFlag.resolve(row.id, row.status) === "published";
317
+ return (_jsx(RowActionMenu, { actions: [
318
+ { label: ACTIONS.STORE["edit-listing"].label, onClick: () => handleEdit(row.id) },
319
+ /*
320
+ * 🛑 This one entry lights up SIX seller pages — art, classified,
321
+ * digital codes, live, spares and stickers are each a ~25-line
322
+ * wrapper around this component.
323
+ *
324
+ * `status` was a read-only Badge on all of them, so changing one
325
+ * listing from published to draft meant opening the full editor,
326
+ * even though `PATCH /api/store/products/[id]` has always taken
327
+ * `status` and SellerProductsView already drives that exact call.
328
+ *
329
+ * `ACTIONS.STORE["publish-listing"]` / `["unpublish-listing"]`
330
+ * were defined with ZERO call sites — this is what finally wires
331
+ * them, rather than reinventing the labels inline.
332
+ */
333
+ {
334
+ label: isPublished
335
+ ? ACTIONS.STORE["unpublish-listing"].label
336
+ : ACTIONS.STORE["publish-listing"].label,
337
+ separator: true,
338
+ disabled: statusFlag.pendingId === row.id,
339
+ onClick: () => { void statusFlag.set(row.id, isPublished ? "draft" : "published"); },
340
+ },
341
+ {
342
+ label: ACTIONS.STORE["delete-listing"].label,
343
+ destructive: true,
344
+ onClick: () => setDeleteTargetId(row.id),
345
+ disabled: deletingId === row.id,
346
+ },
347
+ ] }));
348
+ },
303
349
  };
304
350
  return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), deleteTargetId && (_jsx(ConfirmDeleteModal, { isOpen: true, title: `Delete ${spec.deleteLabel}`, message: `Are you sure you want to delete this ${spec.deleteLabel.toLowerCase()}? This cannot be undone.`, onConfirm: () => handleDelete(deleteTargetId), onClose: () => setDeleteTargetId(null), isDeleting: deletingId === deleteTargetId }))] }));
305
351
  }