@mohasinac/appkit 4.29.0 → 4.30.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 (160) hide show
  1. package/dist/_internal/client/features/layout/SidebarCollapseToggle.js +1 -1
  2. package/dist/_internal/server/jobs/handlers/_helpers.d.ts +22 -0
  3. package/dist/_internal/server/jobs/handlers/_helpers.js +25 -1
  4. package/dist/_internal/shared/features/blog/schema.d.ts +2 -2
  5. package/dist/_internal/shared/features/blog/schema.js +2 -1
  6. package/dist/_internal/shared/features/brands/schema.d.ts +2 -2
  7. package/dist/_internal/shared/features/brands/schema.js +3 -1
  8. package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +3 -3
  9. package/dist/_internal/shared/features/categories/bundle-schemas.js +2 -1
  10. package/dist/_internal/shared/features/events/schema.d.ts +2 -2
  11. package/dist/_internal/shared/features/events/schema.js +2 -1
  12. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  13. package/dist/_internal/shared/features/products/schema.js +3 -2
  14. package/dist/_internal/shared/features/reviews/schema.d.ts +1 -1
  15. package/dist/_internal/shared/features/reviews/schema.js +2 -1
  16. package/dist/_internal/shared/types/action-result.d.ts +0 -2
  17. package/dist/client/api/surface-error.d.ts +9 -0
  18. package/dist/client/api/surface-error.js +29 -3
  19. package/dist/client/api/useApiMutation.d.ts +10 -0
  20. package/dist/client/api/useApiMutation.js +2 -1
  21. package/dist/core/server-action.d.ts +3 -14
  22. package/dist/core/server-action.js +11 -2
  23. package/dist/errors/error-envelope.d.ts +73 -0
  24. package/dist/errors/error-envelope.js +96 -0
  25. package/dist/errors/error-handler.js +14 -7
  26. package/dist/features/account/components/NotificationBell.js +1 -1
  27. package/dist/features/account/components/UserSidebar.js +2 -2
  28. package/dist/features/account/components/UserSupportView.js +2 -6
  29. package/dist/features/account/hooks/useProfile.d.ts +6 -0
  30. package/dist/features/account/hooks/useProfile.js +1 -0
  31. package/dist/features/admin/components/AdminAdEditorView.js +1 -3
  32. package/dist/features/admin/components/AdminAddressClustersView.js +1 -3
  33. package/dist/features/admin/components/AdminAddressEditorView.js +2 -4
  34. package/dist/features/admin/components/AdminAddressesView.js +1 -3
  35. package/dist/features/admin/components/AdminAllEventEntriesView.js +1 -3
  36. package/dist/features/admin/components/AdminBidsView.js +1 -3
  37. package/dist/features/admin/components/AdminBlogEditorView.js +2 -2
  38. package/dist/features/admin/components/AdminBrandEditorView.js +4 -5
  39. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +1 -3
  40. package/dist/features/admin/components/AdminCategoryEditorView.js +2 -4
  41. package/dist/features/admin/components/AdminContactEditorView.js +1 -3
  42. package/dist/features/admin/components/AdminContactView.js +2 -6
  43. package/dist/features/admin/components/AdminCouponEditorView.js +2 -4
  44. package/dist/features/admin/components/AdminEmployeeEditorView.js +3 -9
  45. package/dist/features/admin/components/AdminFaqEditorView.js +2 -4
  46. package/dist/features/admin/components/AdminFeatureEditorView.js +0 -9
  47. package/dist/features/admin/components/AdminFeatureFlagsView.js +2 -4
  48. package/dist/features/admin/components/AdminNavEditorView.js +1 -3
  49. package/dist/features/admin/components/AdminNavigationView.js +3 -3
  50. package/dist/features/admin/components/AdminNewsletterView.js +1 -3
  51. package/dist/features/admin/components/AdminNotificationsView.js +2 -6
  52. package/dist/features/admin/components/AdminOffersView.js +1 -3
  53. package/dist/features/admin/components/AdminOrderEditorView.js +1 -3
  54. package/dist/features/admin/components/AdminPaymentClustersView.js +1 -3
  55. package/dist/features/admin/components/AdminPaymentMethodsView.js +1 -3
  56. package/dist/features/admin/components/AdminPayoutMarkPaidModal.js +2 -4
  57. package/dist/features/admin/components/AdminProductEditorView.js +2 -4
  58. package/dist/features/admin/components/AdminReturnRequestsView.js +2 -6
  59. package/dist/features/admin/components/AdminReviewsView.js +2 -6
  60. package/dist/features/admin/components/AdminScammerEditorView.js +2 -6
  61. package/dist/features/admin/components/AdminSectionsView.js +2 -6
  62. package/dist/features/admin/components/AdminSessionsView.js +1 -3
  63. package/dist/features/admin/components/AdminSidebar.js +2 -2
  64. package/dist/features/admin/components/AdminSiteSettingsView.js +1 -1
  65. package/dist/features/admin/components/AdminStoreEditorView.js +1 -3
  66. package/dist/features/admin/components/AdminStoresView.js +2 -6
  67. package/dist/features/admin/components/AdminSublistingCategoryEditorView.js +2 -6
  68. package/dist/features/admin/components/AdminSupportTicketDetailView.js +7 -21
  69. package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +2 -4
  70. package/dist/features/admin/components/AdminUserEditorView.js +6 -18
  71. package/dist/features/admin/components/AdminUsersView.js +2 -6
  72. package/dist/features/admin/components/BrandQuickCreateForm.js +2 -4
  73. package/dist/features/admin/components/CategoryQuickCreateForm.js +2 -4
  74. package/dist/features/auctions/components/PlaceBidFormClient.js +1 -1
  75. package/dist/features/auth/components/ForgotPasswordView.js +1 -1
  76. package/dist/features/auth/components/LoginForm.js +1 -1
  77. package/dist/features/auth/components/RegisterForm.js +1 -1
  78. package/dist/features/auth/components/ResetPasswordView.js +1 -1
  79. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  80. package/dist/features/auth/repository/session.repository.js +13 -1
  81. package/dist/features/blog/actions/blog-actions.d.ts +14 -14
  82. package/dist/features/blog/actions/blog-actions.js +6 -8
  83. package/dist/features/blog/schemas/index.d.ts +1 -1
  84. package/dist/features/blog/schemas/index.js +2 -4
  85. package/dist/features/categories/api/[id]/route.js +2 -1
  86. package/dist/features/categories/api/route.js +2 -1
  87. package/dist/features/events/schemas/index.d.ts +1 -1
  88. package/dist/features/events/schemas/index.js +2 -4
  89. package/dist/features/faq/components/ContactCTA.js +1 -1
  90. package/dist/features/filters/AsyncFacetSection.js +1 -1
  91. package/dist/features/filters/FilterFacetSection.js +1 -1
  92. package/dist/features/filters/RangeFilter.js +1 -1
  93. package/dist/features/filters/SwitchFilter.js +1 -1
  94. package/dist/features/homepage/api/carousel/[id]/route.js +4 -3
  95. package/dist/features/homepage/api/carousel/route.js +4 -3
  96. package/dist/features/homepage/api/route.js +3 -2
  97. package/dist/features/homepage/components/GoogleReviewsSection.js +1 -1
  98. package/dist/features/homepage/components/HeroBanner.js +1 -1
  99. package/dist/features/homepage/components/HeroCarousel.js +1 -1
  100. package/dist/features/homepage/components/SocialPostCard.js +1 -1
  101. package/dist/features/homepage/components/WhatsAppCommunitySection.js +1 -1
  102. package/dist/features/layout/AppLayoutShell.js +4 -4
  103. package/dist/features/layout/BottomNavbar.js +2 -2
  104. package/dist/features/layout/MainNavbar.js +1 -1
  105. package/dist/features/layout/TitleBarLayout.js +24 -12
  106. package/dist/features/media/AvatarUpload.js +28 -10
  107. package/dist/features/media/modals/ImageCropModal.js +1 -1
  108. package/dist/features/media/modals/ImageEditor.js +1 -1
  109. package/dist/features/products/components/MakeOfferButton.js +1 -1
  110. package/dist/features/reviews/api/[id]/route.js +3 -2
  111. package/dist/features/reviews/api/route.js +3 -2
  112. package/dist/features/search/components/Search.js +2 -2
  113. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +3 -3
  114. package/dist/features/seller/components/SellerSidebar.js +2 -2
  115. package/dist/features/store-extensions/components/ReviewDecisionModal.js +1 -1
  116. package/dist/features/stores/repository/store.repository.d.ts +21 -0
  117. package/dist/features/stores/repository/store.repository.js +32 -0
  118. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -2
  119. package/dist/index.d.ts +3 -2
  120. package/dist/index.js +4 -1
  121. package/dist/next/api/api-response.d.ts +29 -12
  122. package/dist/next/api/api-response.js +9 -8
  123. package/dist/next/api/apiHandler.d.ts +6 -1
  124. package/dist/next/api/apiHandler.js +13 -1
  125. package/dist/next/api/routeHandler.js +6 -6
  126. package/dist/next/middleware/pii-scrubber.js +5 -2
  127. package/dist/providers/db-firebase/base.repository.d.ts +14 -0
  128. package/dist/providers/db-firebase/base.repository.js +22 -0
  129. package/dist/providers/db-firebase/sieve.d.ts +14 -1
  130. package/dist/providers/db-firebase/sieve.js +3 -1
  131. package/dist/security/pii-encrypt.d.ts +0 -4
  132. package/dist/security/pii-encrypt.js +79 -13
  133. package/dist/security/pii-mask.d.ts +39 -1
  134. package/dist/security/pii-mask.js +33 -4
  135. package/dist/security/pii-redact.d.ts +0 -13
  136. package/dist/security/pii-redact.js +3 -1
  137. package/dist/security/pii-schemas.d.ts +0 -2
  138. package/dist/security/pii-schemas.js +16 -2
  139. package/dist/security/settings-encryption.js +31 -3
  140. package/dist/seed/stores-seed-data.js +18 -0
  141. package/dist/seed/test-utils.js +6 -2
  142. package/dist/ui/DataTable.js +2 -2
  143. package/dist/ui/components/CollapsibleSection.js +1 -1
  144. package/dist/ui/components/ImageGallery.js +1 -1
  145. package/dist/ui/components/ListingLayout.js +1 -1
  146. package/dist/ui/forms/FormShell.d.ts +0 -19
  147. package/dist/ui/forms/FormShell.js +4 -23
  148. package/dist/ui/forms/apply-zod-issues.d.ts +34 -0
  149. package/dist/ui/forms/apply-zod-issues.js +38 -0
  150. package/dist/ui/forms/index.d.ts +2 -1
  151. package/dist/ui/forms/index.js +2 -1
  152. package/dist/ui/icons/icon-registry.d.ts +8 -0
  153. package/dist/ui/icons/icon-registry.js +13 -1
  154. package/dist/utils/action-response.d.ts +1 -1
  155. package/dist/utils/media-url.d.ts +49 -0
  156. package/dist/utils/media-url.js +72 -1
  157. package/dist/validation/schemas.d.ts +15 -2
  158. package/dist/validation/schemas.js +18 -22
  159. package/package.json +1 -1
  160. package/scripts/seed-cli.mjs +10 -2
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The ONE shape of an HTTP error body.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * There were four independent producers of an error response, and the client
7
+ * read a shape none of them fully emitted:
8
+ *
9
+ * - `next/api/api-response.ts` → `{success, error, details}` — no `code`
10
+ * - `next/api/routeHandler.ts` → `{ok, success, code, error, issues, requestId}`
11
+ * - `errors/error-handler.ts` → `{..., data: {issues}}` — issues nested
12
+ * - `core/server-action.ts` → `{ok, error, fieldErrors}`
13
+ *
14
+ * while `http/ApiClient.ts`'s `ApiClientError` reads exactly three keys off the
15
+ * body: `code`, `issues`, `requestId`.
16
+ *
17
+ * The consequence was silent and app-wide. On every route built with
18
+ * `createApiHandler`, a Zod rejection serialised its issues under `details`,
19
+ * which nothing reads — so `err.code` was `undefined`, `surfaceError`'s
20
+ * `hasStableErrorCode()` check failed, the `ERROR_DISPLAY_MAP` branch was
21
+ * skipped, and the user got a bare toast reading "Validation failed" with no
22
+ * field name. `applyZodIssues(err.issues, …)` was dead code everywhere.
23
+ *
24
+ * Every producer now builds its body here, so the wire shape and the reader
25
+ * cannot drift again. `scripts/audit-api-error-envelope.mjs` enforces it in
26
+ * both directions.
27
+ */
28
+ import { HTTP_ERROR_CODES } from "./error-mapping";
29
+ /**
30
+ * Default stable code for an HTTP status, for producers that don't carry one.
31
+ *
32
+ * Mirrors the mapping already applied in `mapToHttpError`'s status branch —
33
+ * extracted so there is one table rather than two that can disagree about
34
+ * what a 409 is called.
35
+ */
36
+ export function codeForStatus(status) {
37
+ if (status === 401)
38
+ return HTTP_ERROR_CODES.UNAUTHENTICATED;
39
+ if (status === 403)
40
+ return HTTP_ERROR_CODES.FORBIDDEN;
41
+ if (status === 404)
42
+ return HTTP_ERROR_CODES.NOT_FOUND;
43
+ if (status === 409)
44
+ return HTTP_ERROR_CODES.ALREADY_EXISTS;
45
+ if (status === 429)
46
+ return HTTP_ERROR_CODES.RATE_LIMITED;
47
+ if (status >= 500)
48
+ return HTTP_ERROR_CODES.INTERNAL;
49
+ return HTTP_ERROR_CODES.VALIDATION_FAILED;
50
+ }
51
+ /**
52
+ * Narrow raw Zod issues to the wire shape.
53
+ *
54
+ * Deliberately picks ONLY `path`, `message` and `code`. A `ZodIssue` can also
55
+ * carry `received` / `expected` / `unionErrors`, and `received` is the value
56
+ * the user actually submitted — i.e. potentially their email, phone or
57
+ * address. Spreading the issue wholesale would publish that in an error body
58
+ * and into any client-error log that captures it.
59
+ *
60
+ * The `unknown` parameter is the point: this is the sole entry point for issue
61
+ * arrays arriving from THREE mutually unassignable sources — zod v3
62
+ * `ZodIssue[]` (appkit), zod v4 `$ZodIssue[]` (the consumer app, a different
63
+ * major), and the `unknown[]` that `mapToHttpError` already returns. Narrowing
64
+ * it to any one of them makes the other two uncallable. This function exists
65
+ * precisely to be the place that narrows.
66
+ */
67
+ export function toApiIssues(input) {
68
+ if (!Array.isArray(input) || input.length === 0)
69
+ return undefined;
70
+ const mapped = [];
71
+ for (const raw of input) {
72
+ if (typeof raw !== "object" || raw === null)
73
+ continue;
74
+ const issue = raw;
75
+ if (typeof issue.message !== "string")
76
+ continue;
77
+ mapped.push({
78
+ message: issue.message,
79
+ ...(Array.isArray(issue.path)
80
+ ? { path: issue.path.filter((p) => typeof p === "string" || typeof p === "number") }
81
+ : {}),
82
+ ...(typeof issue.code === "string" ? { code: issue.code } : {}),
83
+ });
84
+ }
85
+ return mapped.length ? mapped : undefined;
86
+ }
87
+ export function buildErrorEnvelope(input) {
88
+ return {
89
+ ok: false,
90
+ success: false,
91
+ code: input.code ?? codeForStatus(input.status ?? 400),
92
+ error: input.message,
93
+ ...(input.issues?.length ? { issues: input.issues } : {}),
94
+ ...(input.requestId ? { requestId: input.requestId } : {}),
95
+ };
96
+ }
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from "next/server.js";
2
2
  import { AppError } from "./base-error";
3
3
  import { mapToHttpError } from "./error-mapping";
4
+ import { buildErrorEnvelope, toApiIssues } from "./error-envelope";
4
5
  /**
5
6
  * Handle API errors with consistent response format.
6
7
  * Use in Next.js API route catch blocks.
@@ -23,16 +24,22 @@ export function handleApiError(error) {
23
24
  data: error instanceof AppError ? error.data : undefined,
24
25
  });
25
26
  }
27
+ // `issues` go at the TOP LEVEL. They used to be nested under
28
+ // `data.issues`, and `ApiClientError` reads `body.issues` — so this
29
+ // producer's field errors were as dead as `api-response.ts`'s were.
30
+ //
31
+ // `statusCode` is dropped: it only ever duplicated the HTTP status, and
32
+ // nothing read it off the body.
26
33
  return NextResponse.json({
27
- success: false,
28
- error: mapped.message,
29
- code: mapped.code,
30
- statusCode: mapped.status,
34
+ ...buildErrorEnvelope({
35
+ message: mapped.message,
36
+ status: mapped.status,
37
+ code: mapped.code,
38
+ issues: toApiIssues(mapped.issues),
39
+ }),
31
40
  ...(error instanceof AppError && error.data !== undefined
32
41
  ? { data: error.data }
33
- : mapped.issues
34
- ? { data: { issues: mapped.issues } }
35
- : {}),
42
+ : {}),
36
43
  }, { status: mapped.status });
37
44
  }
38
45
  /**
@@ -96,7 +96,7 @@ export function NotificationBell({ limit = 10, viewAllHref, labels, icons, rende
96
96
  }
97
97
  function renderBellButton(props) {
98
98
  const { hideOnMobile, buttonClassName, labels, unreadCount, handleToggle, isOpen } = props;
99
- return (_jsxs(Button, { onClick: handleToggle, rounded: "xl", className: `${hideOnMobile ? "hidden md:flex" : "flex"} p-[0.625rem] md:p-[0.75rem] transition-colors relative text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-bg)] hover:text-[var(--appkit-color-text)] ${buttonClassName}`, "aria-label": labels.title, "aria-expanded": isOpen, children: [_jsx("svg", { className: "w-6 h-6 md:w-7 md:h-7", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" }) }), unreadCount > 0 && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_UNREAD_BADGE, children: unreadCount > 99 ? "99+" : unreadCount }))] }));
99
+ return (_jsxs(Button, { onClick: handleToggle, rounded: "xl", className: `${hideOnMobile ? "hidden md:flex" : "flex"} p-[0.625rem] md:p-[0.75rem] transition-colors relative text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-bg)] hover:text-[var(--appkit-color-text)] ${buttonClassName}`, "aria-label": labels.title, "aria-expanded": isOpen, children: [_jsx("svg", { className: "w-6 h-6 md:w-7 md:h-7 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" }) }), unreadCount > 0 && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_UNREAD_BADGE, children: unreadCount > 99 ? "99+" : unreadCount }))] }));
100
100
  }
101
101
  function renderNotificationDropdown(props) {
102
102
  const { dropdownClassName, labels, unreadCount, isMarkingAll, handleMarkAllRead, isLoading, notifications, notificationIcons, handleMarkRead, handleMarkReadAndClose, renderActionLink, viewAllHref, setIsOpen } = props;
@@ -73,13 +73,13 @@ function DrawerContent({ groups, items, activeHref, onItemClick, }) {
73
73
  const hasActive = !!findActiveNavItem(group.items, activeHref);
74
74
  return (_jsxs(Div, { className: "mb-0.5", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => toggle(group.title), paddingX: "md", paddingY: "sm", weight: "semibold", rounded: "none", className: `w-full text-[0.6875rem] uppercase tracking-widest transition-colors ${hasActive && !isOpen
75
75
  ? "text-primary-600 dark:text-primary-400"
76
- : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: activeItem?.href === item.href, onClick: onItemClick }) }, item.href))) }))] }, group.title));
76
+ : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""} shrink-0`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: activeItem?.href === item.href, onClick: onItemClick }) }, item.href))) }))] }, group.title));
77
77
  })] }));
78
78
  }
79
79
  function DrawerPanel({ title, onClose, children, }) {
80
80
  const { hand } = useHandMode();
81
81
  const { edgeClass, borderClass } = getSidebarOverlayClasses(hand);
82
- return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: `fixed top-0 ${edgeClass} z-50 h-full w-64 ${borderClass}`, surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
82
+ return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: `fixed top-0 ${edgeClass} z-50 h-full w-64 ${borderClass}`, surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
83
83
  }
84
84
  export function UserSidebar({ items, groups, mobileOpen = false, onCloseMobile, desktopOpen = false, onToggle, variant = "overlay", renderFooter }) {
85
85
  const pathname = usePathname();
@@ -69,6 +69,7 @@ export function UserSupportView(_props) {
69
69
  const tickets = data?.tickets ?? [];
70
70
  const invalidate = () => queryClient.invalidateQueries({ queryKey: ["user", "support-tickets"] });
71
71
  const createMutation = useApiMutation({
72
+ errorMessage: "Failed to create ticket.",
72
73
  mutationFn: async () => {
73
74
  /*
74
75
  * This drawer validated NOTHING. Its sibling page
@@ -106,11 +107,9 @@ export function UserSupportView(_props) {
106
107
  setNewOrderId("");
107
108
  invalidate();
108
109
  },
109
- onError: (err) => {
110
- showToast(err?.message ?? "Failed to create ticket.", "error");
111
- },
112
110
  });
113
111
  const replyMutation = useApiMutation({
112
+ errorMessage: "Failed to send reply.",
114
113
  mutationFn: async () => {
115
114
  await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(selectedTicket.id), { body: replyBody.trim() });
116
115
  },
@@ -119,9 +118,6 @@ export function UserSupportView(_props) {
119
118
  setReplyBody("");
120
119
  invalidate();
121
120
  },
122
- onError: (err) => {
123
- showToast(err?.message ?? "Failed to send reply.", "error");
124
- },
125
121
  });
126
122
  return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: "mx-auto max-w-2xl", paddingY: "y-lg", paddingX: "x-md", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", children: [_jsx(Text, { size: "xl", weight: "semibold", color: "primary", children: "Support Tickets" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => setNewTicketOpen(true), children: "New ticket" })] }), renderTicketListArea({ isLoading, error, tickets, setSelectedTicket, setDetailOpen })] }), renderNewTicketDrawer({ newTicketOpen, setNewTicketOpen, newCategory, setNewCategory, newSubject, setNewSubject, newOrderId, setNewOrderId, newDescription, setNewDescription, createMutation }), renderTicketDetailDrawer({ detailOpen, setDetailOpen, selectedTicket, replyBody, setReplyBody, replyMutation })] }));
127
123
  }
@@ -45,4 +45,10 @@ export declare function useUpdateCurrentProfile(options?: {
45
45
  onSuccess?: (data: JsonValue) => void;
46
46
  onError?: (error: Error) => void;
47
47
  endpoint?: string;
48
+ /**
49
+ * Curated failure copy, forwarded to the ONE error surface. A wrapper that
50
+ * omits this forces its callers to add a second toast of their own, which
51
+ * is exactly how one failed avatar save produced three stacked banners.
52
+ */
53
+ errorMessage?: string;
48
54
  }): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, UpdateCurrentProfileInput, unknown>;
@@ -14,6 +14,7 @@ export function useUpdateCurrentProfile(options) {
14
14
  const queryClient = useQueryClient();
15
15
  const endpoint = options?.endpoint ?? ACCOUNT_ENDPOINTS.PROFILE;
16
16
  return useApiMutation({
17
+ errorMessage: options?.errorMessage,
17
18
  mutationFn: (data) => apiClient.patch(endpoint, data),
18
19
  onSuccess: async (data) => {
19
20
  await queryClient.invalidateQueries({ queryKey: ["profile"] });
@@ -83,6 +83,7 @@ export function AdminAdEditorView({ adId, endpointBase = ADMIN_ENDPOINTS.ADS, la
83
83
  setThirdPartyUrl(item.creative?.thirdPartyUrl || "");
84
84
  }, [adQuery.data]);
85
85
  const saveMutation = useApiMutation({
86
+ errorMessage: "Save failed",
86
87
  mutationFn: async () => {
87
88
  const payload = {
88
89
  name,
@@ -116,9 +117,6 @@ export function AdminAdEditorView({ adId, endpointBase = ADMIN_ENDPOINTS.ADS, la
116
117
  onSaved?.(savedId);
117
118
  }
118
119
  },
119
- onError: (error) => {
120
- showToast(error instanceof Error ? error.message : "Save failed", "error");
121
- },
122
120
  });
123
121
  const placements = adQuery.data?.placements ?? metaQuery.data?.placements ?? [];
124
122
  const providerCredentialStatus = metaQuery.data?.providerCredentialStatus;
@@ -22,6 +22,7 @@ export function AdminAddressClustersView(_props) {
22
22
  },
23
23
  });
24
24
  const flagMutation = useApiMutation({
25
+ errorMessage: "Failed to flag address.",
25
26
  mutationFn: async (id) => {
26
27
  await apiClient.patch(ADMIN_ENDPOINTS.ADDRESS_BY_ID(id), { action: "flag_suspicious" });
27
28
  },
@@ -29,9 +30,6 @@ export function AdminAddressClustersView(_props) {
29
30
  showToast("Address flagged as suspicious.", "success");
30
31
  void queryClient.invalidateQueries({ queryKey: ["admin", "address-clusters"] });
31
32
  },
32
- onError: (err) => {
33
- showToast(err.message ?? "Failed to flag address.", "error");
34
- },
35
33
  });
36
34
  const clusters = data?.clusters ?? [];
37
35
  return (_jsxs(Stack, { gap: "lg", padding: "page", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", color: "primary", children: "Address Clusters" }), _jsx(Text, { size: "sm", color: "muted", className: "mt-1", children: "Users sharing the same physical address. Flagging is informational only \u2014 users are not blocked." })] }), 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" }) })), error && (_jsx(Div, { surface: "danger-surface", color: "error", padding: "inline", rounded: "xl", textSize: "sm", children: "Failed to load clusters." })), !isLoading && clusters.length === 0 && (_jsx(Div, { surface: "muted", padding: "y-4xl", rounded: "xl", className: "text-center", children: _jsx(Text, { color: "muted", size: "sm", children: "No shared addresses found." }) })), clusters.map((cluster) => (_jsxs(Stack, { surface: "card", padding: "md", gap: "md", rounded: "xl", border: "default", children: [_jsxs(Row, { justify: "between", align: "start", gap: "sm", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "xs", color: "muted", weight: "medium", children: "Hash" }), _jsx(Span, { size: "xs", color: "muted", className: "font-mono truncate max-w-xs", children: cluster.addressHash })] }), _jsxs(Badge, { variant: "secondary", size: "sm", children: [cluster.addresses.length, " accounts"] })] }), _jsx(Stack, { gap: "xs", children: cluster.addresses.map((addr) => (_jsxs(Row, { justify: "between", align: "center", gap: "sm", surface: "muted", padding: "inlineSm", rounded: "lg", children: [_jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsxs(Text, { size: "sm", weight: "medium", color: "primary", className: "truncate", children: [addr.ownerType === "user" ? "User" : "Store", ": ", addr.ownerId] }), addr.city && (_jsx(Text, { size: "xs", color: "muted", children: [addr.city, addr.state, addr.postalCode].filter(Boolean).join(", ") }))] }), _jsxs(Row, { gap: "xs", align: "center", children: [addr.banStatus && (_jsx(Badge, { variant: BAN_BADGE[addr.banStatus] ?? "secondary", size: "sm", children: addr.banStatus.replace(/_/g, " ") })), addr.banStatus !== "suspicious" && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => flagMutation.mutate(addr.id), disabled: flagMutation.isPending, children: "Flag Suspicious" }))] })] }, addr.id))) })] }, cluster.addressHash)))] }));
@@ -64,6 +64,7 @@ export function AdminAddressEditorView({ addressId, onSaved, onDeleted, ...rest
64
64
  },
65
65
  });
66
66
  const saveMutation = useApiMutation({
67
+ errorMessage: "Failed to save address.",
67
68
  mutationFn: async () => {
68
69
  const payload = {
69
70
  ...form,
@@ -81,11 +82,9 @@ export function AdminAddressEditorView({ addressId, onSaved, onDeleted, ...rest
81
82
  if (onSaved && id)
82
83
  onSaved(String(id));
83
84
  },
84
- onError: (err) => {
85
- showToast(err?.message ?? "Failed to save address.", "error");
86
- },
87
85
  });
88
86
  const deleteMutation = useApiMutation({
87
+ errorMessage: "Failed to delete address.",
89
88
  mutationFn: () => apiClient.delete(ADMIN_ENDPOINTS.ADDRESS_BY_ID(addressId)),
90
89
  onSuccess: () => {
91
90
  showToast("Address deleted.", "success");
@@ -93,7 +92,6 @@ export function AdminAddressEditorView({ addressId, onSaved, onDeleted, ...rest
93
92
  if (onDeleted)
94
93
  onDeleted();
95
94
  },
96
- onError: (err) => showToast(err?.message ?? "Failed to delete address.", "error"),
97
95
  });
98
96
  const isSubmitting = saveMutation.isPending || isLoading;
99
97
  const canSave = form.ownerId &&
@@ -133,6 +133,7 @@ export function AdminAddressesView(_props) {
133
133
  const queryClient = useQueryClient();
134
134
  const { showToast } = useToast();
135
135
  const actionMutation = useApiMutation({
136
+ errorMessage: "Failed to update address.",
136
137
  mutationFn: async ({ id, action }) => {
137
138
  await apiClient.patch(ADMIN_ENDPOINTS.ADDRESS_BY_ID(id), { action });
138
139
  },
@@ -140,9 +141,6 @@ export function AdminAddressesView(_props) {
140
141
  showToast("Address updated.", "success");
141
142
  void queryClient.invalidateQueries({ queryKey: ["admin", "addresses"] });
142
143
  },
143
- onError: (err) => {
144
- showToast(err.message ?? "Failed to update address.", "error");
145
- },
146
144
  });
147
145
  const handleAction = (id, action) => {
148
146
  actionMutation.mutate({ id, action });
@@ -17,6 +17,7 @@ export function AdminAllEventEntriesView({ children, ...props }) {
17
17
  const { showToast } = useToast();
18
18
  const [selected, setSelected] = React.useState(null);
19
19
  const updateMutation = useApiMutation({
20
+ errorMessage: "Failed to update entry.",
20
21
  mutationFn: async ({ id, status }) => {
21
22
  await apiClient.patch(ADMIN_ENDPOINTS.ADMIN_EVENT_ENTRY_BY_ID(id), { status });
22
23
  },
@@ -24,9 +25,6 @@ export function AdminAllEventEntriesView({ children, ...props }) {
24
25
  showToast("Entry updated.", "success");
25
26
  queryClient.invalidateQueries({ queryKey: ["admin", "event-entries"] });
26
27
  },
27
- onError: (err) => {
28
- showToast(err?.message ?? "Failed to update entry.", "error");
29
- },
30
28
  });
31
29
  if (React.Children.count(children) > 0) {
32
30
  return (_jsx(ListingLayout, { portal: "admin", ...props, children: children }));
@@ -24,6 +24,7 @@ export function AdminBidsView({ children, ...props }) {
24
24
  const [selectedRow, setSelectedRow] = useState(null);
25
25
  const [detail, setDetail] = useState(null);
26
26
  const cancelMutation = useApiMutation({
27
+ errorMessage: "Failed to cancel bid.",
27
28
  mutationFn: async (bidId) => {
28
29
  await apiClient.patch(ADMIN_ENDPOINTS.BID_BY_ID(bidId), { status: "cancelled" });
29
30
  },
@@ -33,9 +34,6 @@ export function AdminBidsView({ children, ...props }) {
33
34
  setCancelOpen(false);
34
35
  setSelectedRow(null);
35
36
  },
36
- onError: (err) => {
37
- showToast(err?.message ?? "Failed to cancel bid.", "error");
38
- },
39
37
  });
40
38
  if (React.Children.count(children) > 0) {
41
39
  return (_jsx(ListingLayout, { portal: "admin", ...props, children: children }));
@@ -114,6 +114,7 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
114
114
  }, [postQuery.data]);
115
115
  // --- save ---
116
116
  const saveMutation = useApiMutation({
117
+ errorMessage: "Failed to save post.",
117
118
  mutationFn: async () => {
118
119
  const payload = {
119
120
  title: draft.title,
@@ -164,18 +165,17 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
164
165
  if (issues && issues.length > 0) {
165
166
  applyZodIssues(issues, setFieldError);
166
167
  }
167
- showToast(err?.message ?? "Failed to save post.", "error");
168
168
  },
169
169
  });
170
170
  // --- delete ---
171
171
  const deleteMutation = useApiMutation({
172
+ errorMessage: "Failed to delete post.",
172
173
  mutationFn: () => apiClient.delete(ADMIN_ENDPOINTS.BLOG_BY_ID(postId)),
173
174
  onSuccess: () => {
174
175
  showToast("Post deleted.", "success");
175
176
  if (onDeleted)
176
177
  onDeleted();
177
178
  },
178
- onError: (err) => showToast(err?.message ?? "Failed to delete post.", "error"),
179
179
  });
180
180
  const isLoading = saveMutation.isPending || postQuery.isLoading;
181
181
  /**
@@ -5,6 +5,7 @@ import { useApiMutation } from "@mohasinac/appkit/client";
5
5
  import React from "react";
6
6
  import { useQuery } from "@tanstack/react-query";
7
7
  import { z } from "zod";
8
+ import { mediaUrlSchema } from "../../../validation/schemas";
8
9
  import { Button, ConfirmDeleteModal, Div, Form, Input, StackedViewShell, Toggle, useToast, } from "../../../ui";
9
10
  import { FieldInput, FormErrorSummary } from "../../../ui/forms";
10
11
  import { ImageUpload } from "../../media/upload/ImageUpload";
@@ -15,7 +16,7 @@ const brandFormSchema = z.object({
15
16
  name: z.string().min(1, "Brand name is required").max(120),
16
17
  slug: z.string().regex(/^[a-z0-9-]+$/i).optional().or(z.literal("")),
17
18
  description: z.string().max(2000).optional().or(z.literal("")),
18
- logoURL: z.string().url().optional().or(z.literal("")),
19
+ logoURL: mediaUrlSchema.optional().or(z.literal("")),
19
20
  website: z.string().url().optional().or(z.literal("")),
20
21
  country: z.string().max(80).optional().or(z.literal("")),
21
22
  founded: z.number().int().min(1800).max(new Date().getFullYear()).optional(),
@@ -75,6 +76,7 @@ export function AdminBrandEditorView({ brandId, onSaved, onDeleted, embedded, ..
75
76
  setSlug(toBrandSlug(value));
76
77
  };
77
78
  const saveMutation = useApiMutation({
79
+ errorMessage: "Failed to save brand.",
78
80
  mutationFn: async () => {
79
81
  const payload = {
80
82
  name,
@@ -98,18 +100,15 @@ export function AdminBrandEditorView({ brandId, onSaved, onDeleted, embedded, ..
98
100
  if (onSaved && id)
99
101
  onSaved(id);
100
102
  },
101
- onError: (err) => {
102
- showToast(err?.message ?? "Failed to save brand.", "error");
103
- },
104
103
  });
105
104
  const deleteMutation = useApiMutation({
105
+ errorMessage: "Failed to delete brand.",
106
106
  mutationFn: () => apiClient.delete(ADMIN_ENDPOINTS.BRAND_BY_ID(brandId)),
107
107
  onSuccess: () => {
108
108
  showToast("Brand deleted.", "success");
109
109
  if (onDeleted)
110
110
  onDeleted();
111
111
  },
112
- onError: (err) => showToast(err?.message ?? "Failed to delete brand.", "error"),
113
112
  });
114
113
  const { upload } = useMediaUpload();
115
114
  const isSubmitting = saveMutation.isPending || brandQuery.isLoading;
@@ -36,6 +36,7 @@ export function AdminCarouselGroupEditorView({ onCreated, carouselId, onSaved, .
36
36
  setStatus(doc.status === "active" ? "active" : "draft");
37
37
  }, [existing.data]);
38
38
  const createMutation = useApiMutation({
39
+ errorMessage: "create",
39
40
  /*
40
41
  * PATCH on the edit path, not PUT. `/api/admin/carousels/[id]` exports
41
42
  * GET/PATCH/DELETE — copying `AdminFeatureEditorView`'s `apiClient.put`
@@ -62,9 +63,6 @@ export function AdminCarouselGroupEditorView({ onCreated, carouselId, onSaved, .
62
63
  else
63
64
  router.push(String(ROUTES.ADMIN.CAROUSEL_DETAIL(id)));
64
65
  },
65
- onError: (err) => {
66
- showToast(err?.message ?? `Failed to ${isEdit ? "update" : "create"} carousel.`, "error");
67
- },
68
66
  });
69
67
  return (_jsx(StackedViewShell, { portal: "admin", ...rest, title: isEdit ? "Edit Carousel" : "New Named Carousel", sections: [
70
68
  _jsx(Form, { schema: carouselGroupFormSchema, onSubmit: (e) => e.preventDefault(), spacing: "md", children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FieldInput, { name: "name", label: "Carousel name", value: name, onChange: (v) => setName(v), required: true, placeholder: "e.g. Homepage Hero" }), _jsx(FieldSelect, { name: "status", label: "Status", value: status, onChange: (v) => setStatus(v), options: [
@@ -82,6 +82,7 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
82
82
  setSlug(toCategorySlug(value));
83
83
  };
84
84
  const saveMutation = useApiMutation({
85
+ errorMessage: "Failed to save category.",
85
86
  mutationFn: async () => {
86
87
  const payload = {
87
88
  name,
@@ -103,18 +104,15 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
103
104
  if (onSaved && id)
104
105
  onSaved(id);
105
106
  },
106
- onError: (err) => {
107
- showToast(err?.message ?? "Failed to save category.", "error");
108
- },
109
107
  });
110
108
  const deleteMutation = useApiMutation({
109
+ errorMessage: "Failed to delete category.",
111
110
  mutationFn: () => apiClient.delete(ADMIN_ENDPOINTS.CATEGORY_BY_ID(categoryId)),
112
111
  onSuccess: () => {
113
112
  showToast("Category deleted.", "success");
114
113
  if (onDeleted)
115
114
  onDeleted();
116
115
  },
117
- onError: (err) => showToast(err?.message ?? "Failed to delete category.", "error"),
118
116
  });
119
117
  const isSubmitting = saveMutation.isPending || categoryQuery.isLoading;
120
118
  const actionSidebar = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Card, { variant: "outlined", padding: "md", spacing: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: isEdit ? (isActive ? "Active" : "Inactive") : "New" }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "submit", form: "category-editor-form", className: "w-full", isLoading: isSubmitting, disabled: !name || isSubmitting, children: isEdit ? "Save changes" : "Create category" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", className: "w-full", isLoading: deleteMutation.isPending, onClick: () => setDeleteOpen(true), children: "Delete category" }))] }) }));
@@ -16,6 +16,7 @@ export function AdminContactEditorView({ open, onClose, submissionId, subject, n
16
16
  const queryClient = useQueryClient();
17
17
  const { showToast } = useToast();
18
18
  const actionMutation = useApiMutation({
19
+ errorMessage: "Action failed.",
19
20
  mutationFn: async (action) => {
20
21
  await apiClient.patch(ADMIN_ENDPOINTS.CONTACT_SUBMISSION_BY_ID(submissionId), { action });
21
22
  },
@@ -25,9 +26,6 @@ export function AdminContactEditorView({ open, onClose, submissionId, subject, n
25
26
  queryClient.invalidateQueries({ queryKey: ["admin", "contact"] });
26
27
  onClose();
27
28
  },
28
- onError: (err) => {
29
- showToast(err?.message ?? "Action failed.", "error");
30
- },
31
29
  });
32
30
  const statusColor = {
33
31
  new: "bg-info-surface text-info",
@@ -22,6 +22,7 @@ export function AdminContactView({ children, onBulkMarkRead, onBulkArchive, onBu
22
22
  const [selectedRow, setSelectedRow] = useState(null);
23
23
  const [deleteOpen, setDeleteOpen] = useState(false);
24
24
  const actionMutation = useApiMutation({
25
+ errorMessage: "Action failed.",
25
26
  mutationFn: async ({ id, action }) => {
26
27
  await apiClient.patch(ADMIN_ENDPOINTS.CONTACT_SUBMISSION_BY_ID(id), { action });
27
28
  },
@@ -29,11 +30,9 @@ export function AdminContactView({ children, onBulkMarkRead, onBulkArchive, onBu
29
30
  showToast(`${action === "read" ? "Marked as read" : "Archived"}.`, "success");
30
31
  queryClient.invalidateQueries({ queryKey: ["admin", "contact"] });
31
32
  },
32
- onError: (err) => {
33
- showToast(err?.message ?? "Action failed.", "error");
34
- },
35
33
  });
36
34
  const deleteMutation = useApiMutation({
35
+ errorMessage: "Failed to delete.",
37
36
  mutationFn: async (id) => {
38
37
  await apiClient.delete(ADMIN_ENDPOINTS.CONTACT_SUBMISSION_BY_ID(id));
39
38
  },
@@ -43,9 +42,6 @@ export function AdminContactView({ children, onBulkMarkRead, onBulkArchive, onBu
43
42
  setDeleteOpen(false);
44
43
  setSelectedRow(null);
45
44
  },
46
- onError: (err) => {
47
- showToast(err?.message ?? "Failed to delete.", "error");
48
- },
49
45
  });
50
46
  if (React.Children.count(children) > 0) {
51
47
  return (_jsx(ListingLayout, { portal: "admin", ...props, children: children }));
@@ -132,6 +132,7 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
132
132
  };
133
133
  // --- save ---
134
134
  const saveMutation = useApiMutation({
135
+ errorMessage: "Failed to save coupon.",
135
136
  mutationFn: async () => {
136
137
  const payload = {
137
138
  name,
@@ -180,19 +181,16 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
180
181
  if (onSaved && id)
181
182
  onSaved(id);
182
183
  },
183
- onError: (err) => {
184
- showToast(err?.message ?? "Failed to save coupon.", "error");
185
- },
186
184
  });
187
185
  // --- delete ---
188
186
  const deleteMutation = useApiMutation({
187
+ errorMessage: "Failed to delete coupon.",
189
188
  mutationFn: () => apiClient.delete(ADMIN_ENDPOINTS.COUPON_BY_ID(couponId)),
190
189
  onSuccess: () => {
191
190
  showToast("Coupon deleted.", "success");
192
191
  if (onDeleted)
193
192
  onDeleted();
194
193
  },
195
- onError: (err) => showToast(err?.message ?? "Failed to delete coupon.", "error"),
196
194
  });
197
195
  const isSubmitting = saveMutation.isPending || couponQuery.isLoading;
198
196
  const canSave = Boolean(name && (isEdit ? true : code) && discountValue !== "" && startDate);
@@ -76,6 +76,7 @@ export function AdminEmployeeEditorView({ open, onClose, mode, userId, displayNa
76
76
  });
77
77
  };
78
78
  const inviteMutation = useApiMutation({
79
+ errorMessage: "Failed to invite employee",
79
80
  mutationFn: async () => {
80
81
  await apiClient.post(ADMIN_ENDPOINTS.TEAM, {
81
82
  email: email.trim(),
@@ -88,11 +89,9 @@ export function AdminEmployeeEditorView({ open, onClose, mode, userId, displayNa
88
89
  queryClient.invalidateQueries({ queryKey: ["admin", "team"] });
89
90
  onClose();
90
91
  },
91
- onError: (err) => {
92
- showToast(err.message ?? "Failed to invite employee", "error");
93
- },
94
92
  });
95
93
  const updateMutation = useApiMutation({
94
+ errorMessage: "Failed to update permissions",
96
95
  mutationFn: async () => {
97
96
  await apiClient.put(ADMIN_ENDPOINTS.TEAM_MEMBER(userId), {
98
97
  permissionGroup: group,
@@ -104,11 +103,9 @@ export function AdminEmployeeEditorView({ open, onClose, mode, userId, displayNa
104
103
  queryClient.invalidateQueries({ queryKey: ["admin", "team"] });
105
104
  onClose();
106
105
  },
107
- onError: (err) => {
108
- showToast(err.message ?? "Failed to update permissions", "error");
109
- },
110
106
  });
111
107
  const revokeMutation = useApiMutation({
108
+ errorMessage: "Failed to revoke access",
112
109
  mutationFn: async () => {
113
110
  await apiClient.delete(ADMIN_ENDPOINTS.TEAM_MEMBER(userId));
114
111
  },
@@ -118,9 +115,6 @@ export function AdminEmployeeEditorView({ open, onClose, mode, userId, displayNa
118
115
  setRevokeOpen(false);
119
116
  onClose();
120
117
  },
121
- onError: (err) => {
122
- showToast(err.message ?? "Failed to revoke access", "error");
123
- },
124
118
  });
125
119
  const isBusy = inviteMutation.isPending ||
126
120
  updateMutation.isPending ||
@@ -91,6 +91,7 @@ export function AdminFaqEditorView({ faqId, onSaved, onDeleted, embedded, ...res
91
91
  };
92
92
  // --- save ---
93
93
  const saveMutation = useApiMutation({
94
+ errorMessage: "Failed to save FAQ.",
94
95
  mutationFn: async () => {
95
96
  const payload = {
96
97
  question,
@@ -116,19 +117,16 @@ export function AdminFaqEditorView({ faqId, onSaved, onDeleted, embedded, ...res
116
117
  if (onSaved && id)
117
118
  onSaved(id);
118
119
  },
119
- onError: (err) => {
120
- showToast(err?.message ?? "Failed to save FAQ.", "error");
121
- },
122
120
  });
123
121
  // --- delete ---
124
122
  const deleteMutation = useApiMutation({
123
+ errorMessage: "Failed to delete FAQ.",
125
124
  mutationFn: () => apiClient.delete(ADMIN_ENDPOINTS.FAQ_BY_ID(faqId)),
126
125
  onSuccess: () => {
127
126
  showToast("FAQ deleted.", "success");
128
127
  if (onDeleted)
129
128
  onDeleted();
130
129
  },
131
- onError: (err) => showToast(err?.message ?? "Failed to delete FAQ.", "error"),
132
130
  });
133
131
  const isSubmitting = saveMutation.isPending || faqQuery.isLoading;
134
132
  const canSave = Boolean(question.trim()) && Boolean(answer.trim());