@mohasinac/appkit 4.60.1 → 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 (202) 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/features/auctions/actions.d.ts +1 -1
  6. package/dist/_internal/server/features/auctions/actions.js +12 -1
  7. package/dist/_internal/server/features/blog/actions.d.ts +1 -1
  8. package/dist/_internal/server/features/blog/actions.js +12 -1
  9. package/dist/_internal/server/features/brands/actions.d.ts +1 -1
  10. package/dist/_internal/server/features/brands/actions.js +12 -1
  11. package/dist/_internal/server/features/cart/actions.d.ts +1 -1
  12. package/dist/_internal/server/features/cart/actions.js +12 -1
  13. package/dist/_internal/server/features/checkout/actions.js +0 -1
  14. package/dist/_internal/server/features/events/actions.d.ts +1 -1
  15. package/dist/_internal/server/features/events/actions.js +12 -1
  16. package/dist/_internal/server/features/gateway-events/redact.js +14 -2
  17. package/dist/_internal/server/features/history/actions.d.ts +1 -1
  18. package/dist/_internal/server/features/history/actions.js +12 -1
  19. package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
  20. package/dist/_internal/server/features/lottery/actions.js +12 -1
  21. package/dist/_internal/server/features/orders/actions.d.ts +1 -1
  22. package/dist/_internal/server/features/orders/actions.js +12 -1
  23. package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
  24. package/dist/_internal/server/features/orders/service.js +31 -1
  25. package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
  26. package/dist/_internal/server/features/payouts/actions.js +12 -1
  27. package/dist/_internal/server/features/points/award.js +9 -1
  28. package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
  29. package/dist/_internal/server/features/pre-orders/actions.js +12 -1
  30. package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
  31. package/dist/_internal/server/features/prize-draws/actions.js +12 -1
  32. package/dist/_internal/server/features/products/actions.d.ts +1 -1
  33. package/dist/_internal/server/features/products/actions.js +12 -1
  34. package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
  35. package/dist/_internal/server/features/promotions/actions.js +12 -1
  36. package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
  37. package/dist/_internal/server/features/protection-claims/actions.js +21 -12
  38. package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
  39. package/dist/_internal/server/features/raffle/actions.js +12 -1
  40. package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
  41. package/dist/_internal/server/features/refunds/actions.js +14 -2
  42. package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
  43. package/dist/_internal/server/features/reviews/actions.js +12 -1
  44. package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
  45. package/dist/_internal/server/features/wishlist/actions.js +12 -1
  46. package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
  47. package/dist/_internal/server/jobs/core/paymentReconcile.js +93 -76
  48. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  49. package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
  50. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
  51. package/dist/_internal/shared/actions/action-registry.js +149 -9
  52. package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
  53. package/dist/_internal/shared/features/promotions/schema.js +7 -0
  54. package/dist/_internal/shared/media/limits.d.ts +10 -0
  55. package/dist/_internal/shared/media/limits.js +10 -0
  56. package/dist/_internal/shared/obligations/deadlines.js +11 -1
  57. package/dist/_internal/shared/tester/window.d.ts +25 -0
  58. package/dist/_internal/shared/tester/window.js +66 -0
  59. package/dist/_internal/shared/types/action-result.js +15 -1
  60. package/dist/client/api/surface-error.js +16 -2
  61. package/dist/client/api/useApiMutation.js +17 -1
  62. package/dist/client.d.ts +1 -1
  63. package/dist/client.js +1 -1
  64. package/dist/constants/api-endpoints.d.ts +3 -0
  65. package/dist/constants/api-endpoints.js +1 -0
  66. package/dist/constants/field-names.d.ts +17 -0
  67. package/dist/constants/field-names.js +23 -0
  68. package/dist/core/server-action.js +19 -3
  69. package/dist/core/unit-of-work.js +15 -1
  70. package/dist/errors/error-mapping.js +26 -1
  71. package/dist/errors/firestore-codes.d.ts +37 -0
  72. package/dist/errors/firestore-codes.js +52 -0
  73. package/dist/errors/index.d.ts +0 -1
  74. package/dist/errors/index.js +12 -2
  75. package/dist/features/account/components/UserOffersPanel.js +5 -3
  76. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  77. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  78. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  79. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  80. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  81. package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
  82. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  83. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
  84. package/dist/features/admin/components/AdminMediaView.js +36 -3
  85. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  86. package/dist/features/admin/components/AdminOffersView.js +2 -1
  87. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  88. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  89. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  90. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  91. package/dist/features/admin/components/DataListingView.js +2 -0
  92. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  93. package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
  94. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  95. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  96. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  97. package/dist/features/admin/repository/notification.repository.js +32 -0
  98. package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
  99. package/dist/features/admin/repository/site-settings.repository.js +7 -0
  100. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  101. package/dist/features/admin/schemas/action-index-form.js +115 -0
  102. package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
  103. package/dist/features/auth/components/RegisterForm.js +2 -1
  104. package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
  105. package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
  106. package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
  107. package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
  108. package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
  109. package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
  110. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  111. package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
  112. package/dist/features/categories/repository/price-observation.repository.js +21 -2
  113. package/dist/features/events/actions/event-actions.js +4 -1
  114. package/dist/features/events/components/AdminEventEditorView.js +79 -50
  115. package/dist/features/events/components/EventsListPageView.js +2 -1
  116. package/dist/features/events/schemas/firestore.d.ts +17 -0
  117. package/dist/features/events/schemas/firestore.js +1 -0
  118. package/dist/features/events/utils/sort-pinned.d.ts +33 -0
  119. package/dist/features/events/utils/sort-pinned.js +46 -0
  120. package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
  121. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  122. package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
  123. package/dist/features/inventory/components/ReceiveStationView.js +61 -17
  124. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
  125. package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
  126. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  127. package/dist/features/obligations/repository/obligations.repository.js +10 -1
  128. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  129. package/dist/features/orders/components/RefundRequestView.js +4 -4
  130. package/dist/features/orders/schemas/firestore.d.ts +19 -0
  131. package/dist/features/orders/schemas/firestore.js +19 -0
  132. package/dist/features/orders/schemas/index.d.ts +6 -6
  133. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
  134. package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
  135. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  136. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  137. package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
  138. package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
  139. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  140. package/dist/features/products/components/ProductDetailActions.js +2 -1
  141. package/dist/features/products/components/ProductForm.js +2 -2
  142. package/dist/features/products/components/ProductGrid.js +1 -1
  143. package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
  144. package/dist/features/promotions/schemas/firestore.d.ts +22 -0
  145. package/dist/features/promotions/schemas/firestore.js +22 -0
  146. package/dist/features/promotions/schemas/index.d.ts +479 -6
  147. package/dist/features/promotions/schemas/index.js +153 -0
  148. package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
  149. package/dist/features/seller/components/FulfillmentView.js +3 -2
  150. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  151. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  152. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  153. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  154. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  155. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  156. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  157. package/dist/features/seller/components/SellerProductsView.js +5 -1
  158. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  159. package/dist/features/seller/components/SellerShippingView.js +2 -1
  160. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  161. package/dist/features/seller/components/SellerSupportView.js +69 -2
  162. package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
  163. package/dist/features/seller/schemas/coupon-form.js +18 -0
  164. package/dist/features/shell/SectionForm.js +35 -9
  165. package/dist/features/shell/build-sections.js +14 -1
  166. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  167. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  168. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  169. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  170. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  171. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  172. package/dist/features/sticker-trace/server.d.ts +2 -0
  173. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  174. package/dist/http/api-handler.js +4 -1
  175. package/dist/index.d.ts +8 -4
  176. package/dist/index.js +6 -4
  177. package/dist/next/routing/route-map.d.ts +4 -2
  178. package/dist/next/routing/route-map.js +19 -1
  179. package/dist/schemas/registry.d.ts +15 -10
  180. package/dist/schemas/sieve.d.ts +3 -3
  181. package/dist/server.d.ts +1 -2
  182. package/dist/server.js +6 -4
  183. package/dist/styles.css +51 -0
  184. package/dist/ui/components/Checkbox.js +10 -2
  185. package/dist/ui/components/DateInput.js +8 -1
  186. package/dist/ui/components/Input.js +22 -2
  187. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  188. package/dist/ui/components/PaginatedSelect.js +0 -0
  189. package/dist/ui/components/Radio.js +8 -1
  190. package/dist/ui/components/Select.js +10 -2
  191. package/dist/ui/components/TagInput.d.ts +8 -1
  192. package/dist/ui/components/TagInput.js +4 -3
  193. package/dist/ui/components/Textarea.js +10 -2
  194. package/dist/ui/forms/FieldCheckbox.js +1 -1
  195. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  196. package/dist/ui/forms/FormErrorList.js +34 -1
  197. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  198. package/dist/ui/forms/apply-zod-issues.js +23 -1
  199. package/package.json +1 -1
  200. package/scripts/seed-cli.mjs +1822 -1812
  201. package/dist/features/tester/utils/phases.d.ts +0 -48
  202. package/dist/features/tester/utils/phases.js +0 -139
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Firestore / gRPC error-code predicates, in one place.
3
+ *
4
+ * ## Why this file exists
5
+ *
6
+ * `isAlreadyExists` had been written THREE times — in the gateway-events,
7
+ * payment-cases and protection-claims repositories, two of them byte-identical
8
+ * — and each copy duck-typed the caught error the same way:
9
+ *
10
+ * const code = (err as { code?: unknown }).code;
11
+ *
12
+ * That is past the Rule of Three, and `audit-unknown-leakage` was reporting all
13
+ * six of those field reads: casting to `{ code?: unknown }` narrows the OBJECT
14
+ * and leaves the FIELD unknown, so every comparison below it is unchecked.
15
+ * Naming the shape once gives the comparisons a real type and leaves one
16
+ * implementation to fix when the SDK changes what it throws.
17
+ *
18
+ * ## 🛑 Why three checks and not one
19
+ *
20
+ * The Admin SDK has surfaced this condition as a numeric gRPC code, as a string
21
+ * code, and as a bare message, depending on version and transport. All three
22
+ * are tested, and the message test is in ADDITION to the codes rather than
23
+ * instead of them — a stringly-typed guard alone is what breaks silently on an
24
+ * SDK bump, and the failure mode is a create that reports success while having
25
+ * collided.
26
+ */
27
+ /** gRPC status 6. The numeric form, which is what the Admin SDK usually sets. */
28
+ export const GRPC_ALREADY_EXISTS = 6;
29
+ /** Narrow an unknown thrown value to the fields these predicates read. */
30
+ function asFirestoreError(err) {
31
+ if (typeof err !== "object" || err === null)
32
+ return null;
33
+ return err;
34
+ }
35
+ /**
36
+ * Did Firestore refuse this write because the document already exists?
37
+ *
38
+ * The one question every `createWithId`-style insert needs answered, and the
39
+ * reason it matters is that "already there" is usually SUCCESS for an
40
+ * idempotent writer — a claim, a case or an inbox row that a retry re-sends.
41
+ * Treating it as a failure turns a safe retry into a 500.
42
+ */
43
+ export function isAlreadyExistsError(err) {
44
+ const e = asFirestoreError(err);
45
+ if (!e)
46
+ return false;
47
+ if (e.code === GRPC_ALREADY_EXISTS)
48
+ return true;
49
+ if (e.code === "already-exists" || e.code === "ALREADY_EXISTS")
50
+ return true;
51
+ return typeof e.message === "string" && e.message.includes("ALREADY_EXISTS");
52
+ }
@@ -10,7 +10,6 @@ export { NotFoundError } from "./not-found-error";
10
10
  export { ConflictError } from "./conflict-error";
11
11
  export { DatabaseError } from "./database-error";
12
12
  export { PaymentProviderUnreachableError } from "./payment-provider-unreachable";
13
- export { handleApiError, logError, isAppError } from "./error-handler";
14
13
  export { mapToHttpError, HTTP_ERROR_CODES, type MappedError, type HttpErrorCode, } from "./error-mapping";
15
14
  export { ERROR_DISPLAY_MAP, getErrorDisplay, toUserMessage, GENERIC_USER_MESSAGE, type ErrorDisplayEntry, } from "./error-display-map";
16
15
  export { safeRead, installDegradedReadReporter, type DegradedReadReport, } from "./safe-read";
@@ -13,8 +13,18 @@ export { NotFoundError } from "./not-found-error.js";
13
13
  export { ConflictError } from "./conflict-error.js";
14
14
  export { DatabaseError } from "./database-error.js";
15
15
  export { PaymentProviderUnreachableError } from "./payment-provider-unreachable.js";
16
- // error-handler uses `next/server` — only import in server/API-route contexts
17
- export { handleApiError, logError, isAppError } from "./error-handler.js";
16
+ /*
17
+ * 🛑 `error-handler` is deliberately NOT re-exported here. Import it from
18
+ * "./error-handler" directly.
19
+ *
20
+ * It does `import { NextResponse } from "next/server.js"`, and 83 files
21
+ * import THIS barrel — including repositories reachable from the Firebase
22
+ * Functions entry. So the barrel dragged Next's server runtime into the tsup
23
+ * bundle, which died on two unresolved `react-server-dom-webpack/*` requires
24
+ * and two `@vercel/og` .wasm files, naming none of the files responsible.
25
+ * The old line carried a comment saying "only import in server/API-route
26
+ * contexts" — a convention nothing could enforce. Root Cause #18.
27
+ */
18
28
  // Code→HTTP mapper (server) — and the i18n / inline-field map (client-safe)
19
29
  export { mapToHttpError, HTTP_ERROR_CODES, } from "./error-mapping.js";
20
30
  export { ERROR_DISPLAY_MAP, getErrorDisplay, toUserMessage, GENERIC_USER_MESSAGE, } from "./error-display-map.js";
@@ -1,11 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useState, useTransition } from "react";
4
- import { Alert, Badge, Button, Div, Heading, LoginRequiredModal, Row, Spinner, Stack, Text } from "../../../ui/index.js";
4
+ import { Alert, Badge, Button, Div, Heading, LoginRequiredModal, Row, Spinner, Stack, Text, TextLink } from "../../../ui/index.js";
5
+ import { ROUTES } from "../../../next/routing/route-map.js";
5
6
  import { isAuthError } from "../../../utils/auth-error.js";
6
7
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
7
8
  import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints.js";
8
9
  import { normalizeError } from "../../../errors/normalize.js";
10
+ import { toUserMessage } from "../../../errors/error-display-map.js";
9
11
  const CLS_COUNTER_AMOUNT = "text-[length:var(--appkit-text-sm)] font-semibold text-info dark:text-info";
10
12
  function formatRupees(amount) {
11
13
  if (amount === undefined || amount === null)
@@ -60,7 +62,7 @@ function BuyerOfferCard({ offer, onAcceptCounter, onWithdraw, onCheckout, onUpda
60
62
  }
61
63
  });
62
64
  }
63
- return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "3", children: [_jsxs(Row, { align: "start", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: "truncate", color: "primary", size: "sm", weight: "semibold", children: offer.productTitle ?? "Unknown Product" }), _jsx(Text, { className: "mt-0.5", color: "muted", size: "xs", children: relativeTime(offer.createdAt) })] }), _jsx(Badge, { variant: statusVariant(offer.status), size: "xs", className: "shrink-0 capitalize", children: offer.status })] }), _jsxs(Row, { wrap: true, gap: "md", children: [_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Listed" }), _jsx(Text, { size: "sm", weight: "medium", color: "muted", children: formatRupees(offer.listedPrice) })] }), _jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Your Offer" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: formatRupees(offer.offerAmount) })] }), offer.counterAmount != null && (_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Seller Counter" }), _jsx(Text, { className: CLS_COUNTER_AMOUNT, children: formatRupees(offer.counterAmount) })] })), offer.lockedPrice != null && (_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Agreed Price" }), _jsx(Text, { className: "text-success", size: "sm", weight: "semibold", children: formatRupees(offer.lockedPrice) })] }))] }), offer.sellerNote && (_jsxs(Text, { className: "italic", color: "muted", size: "xs", children: ["Seller note: \"", offer.sellerNote, "\""] })), offer.status === "countered" && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "text-info", size: "xs", weight: "medium", children: ["Seller countered at ", formatRupees(offer.counterAmount), ". Accept or withdraw your offer."] }), _jsxs(Row, { wrap: true, gap: "sm", children: [_jsx(Button, { size: "sm", variant: "primary", onClick: () => act(() => onAcceptCounter(offer.id), { status: "accepted", lockedPrice: offer.counterAmount }), disabled: isPending, children: isPending ? "Accepting…" : `Accept ${formatRupees(offer.counterAmount)}` }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming("withdraw"), disabled: isPending, className: "text-error border border-error/20", children: "Withdraw" })] })] })), offer.status === "accepted" && onCheckout && (_jsx(Button, { size: "sm", variant: "primary", onClick: () => act(() => onCheckout(offer.id), { status: "paid" }), disabled: isPending, children: isPending ? "Processing…" : "Checkout at Agreed Price" })), offer.status === "pending" && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming("withdraw"), disabled: isPending, className: "text-error border border-error/20 text-[length:var(--appkit-text-xs)]", children: "Withdraw Offer" })), confirming === "withdraw" && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => act(() => onWithdraw(offer.id), { status: "withdrawn" }), disabled: isPending, className: "text-error border border-error/20", children: isPending ? "Withdrawing…" : "Confirm Withdraw" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming(null), disabled: isPending, children: "Cancel" })] })), error && _jsx(Alert, { variant: "error", children: _jsx(Text, { size: "xs", children: error }) })] }));
65
+ return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "3", children: [_jsxs(Row, { align: "start", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Div, { className: "min-w-0", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL(offer.productSlug ?? offer.productId)), size: "sm", weight: "semibold", truncate: true, children: offer.productTitle ?? "Unknown Product" }), _jsx(Text, { className: "mt-0.5", color: "muted", size: "xs", children: relativeTime(offer.createdAt) })] }), _jsx(Badge, { variant: statusVariant(offer.status), size: "xs", className: "shrink-0 capitalize", children: offer.status })] }), _jsxs(Row, { wrap: true, gap: "md", children: [_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Listed" }), _jsx(Text, { size: "sm", weight: "medium", color: "muted", children: formatRupees(offer.listedPrice) })] }), _jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Your Offer" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: formatRupees(offer.offerAmount) })] }), offer.counterAmount != null && (_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Seller Counter" }), _jsx(Text, { className: CLS_COUNTER_AMOUNT, children: formatRupees(offer.counterAmount) })] })), offer.lockedPrice != null && (_jsxs(Div, { children: [_jsx(Text, { className: "tracking-wide", color: "faint", size: "xs", transform: "uppercase", children: "Agreed Price" }), _jsx(Text, { className: "text-success", size: "sm", weight: "semibold", children: formatRupees(offer.lockedPrice) })] }))] }), offer.sellerNote && (_jsxs(Text, { className: "italic", color: "muted", size: "xs", children: ["Seller note: \"", offer.sellerNote, "\""] })), offer.status === "countered" && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "text-info", size: "xs", weight: "medium", children: ["Seller countered at ", formatRupees(offer.counterAmount), ". Accept or withdraw your offer."] }), _jsxs(Row, { wrap: true, gap: "sm", children: [_jsx(Button, { size: "sm", variant: "primary", onClick: () => act(() => onAcceptCounter(offer.id), { status: "accepted", lockedPrice: offer.counterAmount }), disabled: isPending, children: isPending ? "Accepting…" : `Accept ${formatRupees(offer.counterAmount)}` }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming("withdraw"), disabled: isPending, className: "text-error border border-error/20", children: "Withdraw" })] })] })), offer.status === "accepted" && onCheckout && (_jsx(Button, { size: "sm", variant: "primary", onClick: () => act(() => onCheckout(offer.id), { status: "paid" }), disabled: isPending, children: isPending ? "Processing…" : "Checkout at Agreed Price" })), offer.status === "pending" && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming("withdraw"), disabled: isPending, className: "text-error border border-error/20 text-[length:var(--appkit-text-xs)]", children: "Withdraw Offer" })), confirming === "withdraw" && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => act(() => onWithdraw(offer.id), { status: "withdrawn" }), disabled: isPending, className: "text-error border border-error/20", children: isPending ? "Withdrawing…" : "Confirm Withdraw" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setConfirming(null), disabled: isPending, children: "Cancel" })] })), error && _jsx(Alert, { variant: "error", children: _jsx(Text, { size: "xs", children: error }) })] }));
64
66
  }
65
67
  export function UserOffersPanel({ fetchEndpoint = ACCOUNT_ENDPOINTS.OFFERS, onAcceptCounter, onWithdraw, onCheckout, className = "", }) {
66
68
  const [offers, setOffers] = useState([]);
@@ -92,7 +94,7 @@ export function UserOffersPanel({ fetchEndpoint = ACCOUNT_ENDPOINTS.OFFERS, onAc
92
94
  }
93
95
  catch (err) {
94
96
  void normalizeError(err);
95
- setFetchError(err instanceof Error ? err.message : "Failed to load offers.");
97
+ setFetchError(toUserMessage(err?.code, undefined, { fallback: "Failed to load offers." }));
96
98
  }
97
99
  finally {
98
100
  setLoading(false);
@@ -27,10 +27,12 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
27
27
  import React from "react";
28
28
  import { useQuery, useQueryClient } from "@tanstack/react-query";
29
29
  import { useApiMutation } from "@mohasinac/appkit/client";
30
- import { Alert, Badge, Row, Span, Stack, Text, Toggle, useToast } from "../../../ui/index.js";
30
+ import { Alert, Badge, Row, Span, Stack, Text, TextLink, Toggle, useToast } from "../../../ui/index.js";
31
31
  import { apiClient } from "../../../http/index.js";
32
32
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
33
33
  import { DataListingView } from "./DataListingView.js";
34
+ import { QuickEditMenu } from "./QuickEditMenu.js";
35
+ import { actionIndexOverrideSchema, splitKeywords, joinKeywords, ACTION_INDEX_OVERRIDE_FIELDS, } from "../schemas/action-index-form.js";
34
36
  const ENDPOINT = ADMIN_ENDPOINTS.ACTION_INDEX;
35
37
  export function AdminActionIndexView({ labels = {} }) {
36
38
  const queryClient = useQueryClient();
@@ -41,7 +43,12 @@ export function AdminActionIndexView({ labels = {} }) {
41
43
  staleTime: 60000,
42
44
  });
43
45
  const payload = query.data?.data;
44
- const overrides = payload?.control?.entries ?? {};
46
+ /*
47
+ * Memoised because the `?? {}` fallback is a fresh object every render,
48
+ * which makes the `rows` memo below rebuild on every render — it reads
49
+ * `overrides` for each entry's effective `enabled`.
50
+ */
51
+ const overrides = React.useMemo(() => payload?.control?.entries ?? {}, [payload?.control?.entries]);
45
52
  const toggleMutation = useApiMutation({
46
53
  errorMessage: "Could not update that entry.",
47
54
  mutationFn: async (vars) => apiClient.patch(ENDPOINT, {
@@ -53,6 +60,44 @@ export function AdminActionIndexView({ labels = {} }) {
53
60
  void queryClient.invalidateQueries({ queryKey: ["admin", "action-index"] });
54
61
  },
55
62
  });
63
+ /*
64
+ * Spread the EXISTING override first, exactly as the toggle does. The
65
+ * endpoint's `setOverride` replaces the whole override object, so sending
66
+ * only the edited fields would silently clear this entry's `enabled` —
67
+ * re-showing something an admin had deliberately hidden.
68
+ */
69
+ const overrideMutation = useApiMutation({
70
+ errorMessage: "Could not save that entry.",
71
+ mutationFn: async (vars) => {
72
+ const parsed = actionIndexOverrideSchema.parse(vars.values);
73
+ return apiClient.patch(ENDPOINT, {
74
+ entryId: vars.entryId,
75
+ override: {
76
+ ...overrides[vars.entryId],
77
+ label: parsed.label,
78
+ description: parsed.description,
79
+ keywords: splitKeywords(parsed.keywords),
80
+ weight: parsed.weight,
81
+ },
82
+ });
83
+ },
84
+ onSuccess: () => {
85
+ showToast("Entry saved.", "success");
86
+ void queryClient.invalidateQueries({ queryKey: ["admin", "action-index"] });
87
+ },
88
+ });
89
+ /** Named so the row-action array stays a flat list of declarations. */
90
+ const saveOverride = React.useCallback(async (entryId, values) => {
91
+ await overrideMutation.mutateAsync({ entryId, values });
92
+ }, [overrideMutation]);
93
+ const clearMutation = useApiMutation({
94
+ errorMessage: "Could not reset that entry.",
95
+ mutationFn: async (entryId) => apiClient.patch(ENDPOINT, { entryId, clear: true }),
96
+ onSuccess: () => {
97
+ showToast("Entry reset to its code default.", "success");
98
+ void queryClient.invalidateQueries({ queryKey: ["admin", "action-index"] });
99
+ },
100
+ });
56
101
  /*
57
102
  * Ordered by weight, then label — the same ranking `matchesNavQuery` gives
58
103
  * the search, so the admin reading this list sees entries in the order a
@@ -69,6 +114,10 @@ export function AdminActionIndexView({ labels = {} }) {
69
114
  href: `${entry.href}${entry.deepLink ?? ""}`,
70
115
  permission: entry.requiredPermission ?? "—",
71
116
  enabled: overrides[entry.id]?.enabled !== false,
117
+ // Seeds the edit drawer. `entry.*` is already override-merged by
118
+ // `mergeActionIndex`, so these are the effective values an admin sees.
119
+ keywords: joinKeywords(entry.keywords),
120
+ weight: entry.weight ?? 0,
72
121
  })), [payload?.entries, overrides]);
73
122
  const sync = payload?.sync;
74
123
  return (_jsxs(Stack, { gap: "md", children: [sync && sync.orphanedOverrides.length > 0 && (_jsxs(Alert, { variant: "warning", title: "Overrides with no entry", children: [sync.orphanedOverrides.length, " saved override", sync.orphanedOverrides.length === 1 ? "" : "s", " point at entries the code no longer has (", sync.orphanedOverrides.slice(0, 4).join(", "), sync.orphanedOverrides.length > 4 ? "…" : "", "). They are ignored \u2014 clear them when convenient."] })), _jsx(DataListingView, { config: {
@@ -109,9 +158,19 @@ export function AdminActionIndexView({ labels = {} }) {
109
158
  },
110
159
  { key: "portal", header: "Portal" },
111
160
  {
161
+ /*
162
+ * A LINK, not mono text.
163
+ *
164
+ * This row IS a destination — "where is it" is half of what
165
+ * this screen exists to answer, and the answer was printed as
166
+ * inert text beside a toggle. The link is also what makes the
167
+ * row reachable at all: every other column is metadata about
168
+ * the entry, so without it there is no way to go look at the
169
+ * thing being enabled or disabled.
170
+ */
112
171
  key: "href",
113
172
  header: "Goes to",
114
- render: (row) => (_jsx(Span, { size: "xs", family: "mono", color: "muted", children: row.href })),
173
+ render: (row) => (_jsx(TextLink, { href: row.href, size: "xs", onClick: (e) => e.stopPropagation(), children: _jsx(Span, { size: "xs", family: "mono", children: row.href }) })),
115
174
  },
116
175
  {
117
176
  key: "permission",
@@ -121,8 +180,44 @@ export function AdminActionIndexView({ labels = {} }) {
121
180
  {
122
181
  key: "enabled",
123
182
  header: "Shown",
124
- render: (row) => (_jsx(Row, { align: "center", gap: "sm", children: _jsx(Toggle, { checked: row.enabled, size: "sm", "aria-label": `Show ${row.primary}`, onChange: (next) => toggleMutation.mutate({ entryId: row.id, enabled: next }) }) })),
183
+ render: (row) => (_jsx(Row, { align: "center", gap: "sm", children: _jsx(Toggle, { checked: row.enabled, size: "sm", ariaLabel: `Show ${row.primary} in navigation and search`, onChange: (next) => toggleMutation.mutate({ entryId: row.id, enabled: next }) }) })),
125
184
  },
126
185
  ],
127
- } }), _jsx(Text, { size: "xs", color: "muted", children: "Hiding an entry removes it from the sidebar, the header search and the command palette at once. Labels, descriptions and keywords can be edited here; a route and its required permission belong to the code." })] }));
186
+ /*
187
+ * 🛑 This exists because the footer below already PROMISED it:
188
+ * "Labels, descriptions and keywords can be edited here." They
189
+ * could not. `PATCH /api/admin/action-index` has always accepted
190
+ * `label` / `description` / `keywords` / `weight` in an override
191
+ * (see `ActionIndexOverride`), and the only control on this screen
192
+ * was the Shown toggle — so the sentence described a capability the
193
+ * server had and the UI had never grown. Root Cause #51's shape: a
194
+ * label describing something other than what the surface does.
195
+ *
196
+ * `href`, `kind` and every permission field stay out, per the
197
+ * component docstring: repointing a built-in route is routing, and
198
+ * relaxing a permission is access control.
199
+ */
200
+ renderRowActions: (row) => (_jsx(QuickEditMenu, { actions: [
201
+ {
202
+ label: "Edit entry",
203
+ formTitle: `Edit "${row.primary}"`,
204
+ schema: actionIndexOverrideSchema,
205
+ fields: ACTION_INDEX_OVERRIDE_FIELDS,
206
+ defaultValues: {
207
+ label: row.primary,
208
+ description: row.secondary,
209
+ keywords: row.keywords,
210
+ weight: row.weight,
211
+ },
212
+ onSubmit: (values) => saveOverride(row.id, values),
213
+ submitLabel: "Save entry",
214
+ },
215
+ {
216
+ label: "Reset to code default",
217
+ separator: true,
218
+ destructive: true,
219
+ onClick: () => clearMutation.mutate(row.id),
220
+ },
221
+ ] })),
222
+ } }), _jsx(Text, { size: "xs", color: "muted", children: "Hiding an entry removes it from the sidebar, the header search and the command palette at once. Use the row menu to edit an entry\u2019s label, description, keywords and search weight, or to reset it to its code default; a route and its required permission belong to the code." })] }));
128
223
  }
@@ -2,7 +2,8 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { useQueryClient } from "@tanstack/react-query";
5
- import { Badge, Button, Div, Heading, Row, Span, Stack, Text, useToast } from "../../../ui/index.js";
5
+ import { Badge, Button, Div, Heading, Row, Span, Stack, Text, TextLink, useToast } from "../../../ui/index.js";
6
+ import { ROUTES } from "../../../next/routing/route-map.js";
6
7
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
7
8
  import { apiClient } from "../../../http/index.js";
8
9
  import { useApiMutation } from "@mohasinac/appkit/client";
@@ -31,6 +32,24 @@ export function AdminAddressClustersView(_props) {
31
32
  void queryClient.invalidateQueries({ queryKey: ["admin", "address-clusters"] });
32
33
  },
33
34
  });
35
+ /*
36
+ * 🛑 "Flag Suspicious" was ONE-WAY: there was no unflag, so a wrong call —
37
+ * or a cluster that turned out to be a family sharing an address — left a
38
+ * permanent mark with no way back from this screen.
39
+ *
40
+ * `clear_ban` has existed on the route the whole time and calls
41
+ * `clearBanById`; only the control was missing.
42
+ */
43
+ const clearFlagMutation = useApiMutation({
44
+ errorMessage: "Failed to clear that flag.",
45
+ mutationFn: async (id) => {
46
+ await apiClient.patch(ADMIN_ENDPOINTS.ADDRESS_BY_ID(id), { action: "clear_ban" });
47
+ },
48
+ onSuccess: () => {
49
+ showToast("Flag cleared.", "success");
50
+ void queryClient.invalidateQueries({ queryKey: ["admin", "address-clusters"] });
51
+ },
52
+ });
34
53
  const clusters = data?.clusters ?? [];
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)))] }));
54
+ 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(TextLink, { href: addr.ownerType === "user" ? String(ROUTES.ADMIN.USER_DETAIL(addr.ownerId)) : String(ROUTES.ADMIN.STORE_DETAIL(addr.ownerId)), size: "sm", weight: "medium", truncate: true, 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: () => clearFlagMutation.mutate(addr.id), disabled: clearFlagMutation.isPending, children: "Clear flag" })) : (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => flagMutation.mutate(addr.id), disabled: flagMutation.isPending, children: "Flag Suspicious" }))] })] }, addr.id))) })] }, cluster.addressHash)))] }));
36
55
  }
@@ -201,13 +201,13 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
201
201
  required: true,
202
202
  quick: true,
203
203
  fields: ["title", "slug", "excerpt", "content"],
204
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Content" }), _jsx(Input, { label: "Title", value: values.title, onChange: (e) => {
204
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Content" }), _jsx(Input, { label: "Title", error: errors.title, value: values.title, onChange: (e) => {
205
205
  const v = e.target.value;
206
206
  onChange({ title: v, ...(!slugManual && { slug: toSlug(v) }) });
207
- }, placeholder: "e.g. How to Grade Pok\u00E9mon Cards" }), _jsx(Input, { label: "Slug", value: values.slug, onChange: (e) => {
207
+ }, placeholder: "e.g. How to Grade Pok\u00E9mon Cards" }), _jsx(Input, { label: "Slug", error: errors.slug, value: values.slug, onChange: (e) => {
208
208
  setSlugManual(true);
209
209
  onChange({ slug: e.target.value });
210
- }, placeholder: "blog-how-to-grade-pokemon-cards", helperText: "Auto-generated from title. Must start with 'blog-'." }), _jsx(Input, { label: "Excerpt", value: values.excerpt, onChange: (e) => onChange({ excerpt: e.target.value }), placeholder: "Short summary shown in listings and cards" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Content" }), _jsx(RichTextEditor, { ariaLabel: "Content", value: values.content, onChange: (v) => onChange({ content: v }), placeholder: "Write your article here...", minHeightClassName: "min-h-[320px]" })] })] })),
210
+ }, placeholder: "blog-how-to-grade-pokemon-cards", helperText: "Auto-generated from title. Must start with 'blog-'." }), _jsx(Input, { label: "Excerpt", error: errors.excerpt, value: values.excerpt, onChange: (e) => onChange({ excerpt: e.target.value }), placeholder: "Short summary shown in listings and cards" }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Content" }), _jsx(RichTextEditor, { ariaLabel: "Content", value: values.content, onChange: (v) => onChange({ content: v }), placeholder: "Write your article here...", minHeightClassName: "min-h-[320px]" })] })] })),
211
211
  },
212
212
  {
213
213
  id: "media",
@@ -216,20 +216,20 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
216
216
  // its children, which would abort an in-flight upload.
217
217
  keepMounted: true,
218
218
  fields: ["coverImage", "youtubeId"],
219
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(ImageUpload, { label: "Cover Image", currentImage: values.coverImage, onUpload: (file) => upload(file, "blog", true, {
219
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Media" }), _jsx(ImageUpload, { label: "Cover Image", currentImage: values.coverImage, onUpload: (file) => upload(file, "blog", true, {
220
220
  type: "blog-cover",
221
221
  title: values.title || values.slug,
222
222
  category: values.category,
223
- }), onChange: (url) => onChange({ coverImage: url }) }), _jsx(Input, { label: "YouTube Video ID (optional)", value: values.youtubeId, onChange: (e) => onChange({ youtubeId: e.target.value }), placeholder: "e.g. dQw4w9WgXcQ", helperText: "The 11-character video ID from the YouTube URL." })] })),
223
+ }), onChange: (url) => onChange({ coverImage: url }) }), _jsx(Input, { label: "YouTube Video ID (optional)", error: errors.youtubeId, value: values.youtubeId, onChange: (e) => onChange({ youtubeId: e.target.value }), placeholder: "e.g. dQw4w9WgXcQ", helperText: "The 11-character video ID from the YouTube URL." })] })),
224
224
  },
225
225
  {
226
226
  id: "seo",
227
227
  label: "SEO & Tags",
228
228
  quick: true,
229
229
  fields: ["category", "tags", "metaTitle", "metaDescription"],
230
- render: ({ values, onChange }) => {
230
+ render: ({ values, onChange, errors }) => {
231
231
  const readTime = Math.max(1, Math.round(values.content.replace(/<[^>]*>/g, "").split(/\s+/).filter(Boolean).length / 200));
232
- return (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "SEO & Tags" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Category", options: CATEGORY_OPTIONS, value: values.category, onValueChange: (v) => onChange({ category: v }) }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Estimated Read Time" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["~", readTime, " min (auto-calculated from content)"] })] })] }), _jsx(TagInput, { label: "Tags", value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, grading, tcg" }), _jsx(Input, { label: "Meta Title (optional)", value: values.metaTitle, onChange: (e) => onChange({ metaTitle: e.target.value }), placeholder: "Defaults to post title" }), _jsx(Input, { label: "Meta Description (optional)", value: values.metaDescription, onChange: (e) => onChange({ metaDescription: e.target.value }), placeholder: "SEO description \u2014 max 160 chars", maxLength: 160 })] }));
232
+ return (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "SEO & Tags" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Category", error: errors.category, options: CATEGORY_OPTIONS, value: values.category, onValueChange: (v) => onChange({ category: v }) }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Estimated Read Time" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["~", readTime, " min (auto-calculated from content)"] })] })] }), _jsx(TagInput, { label: "Tags", error: errors.tags, value: values.tags, onChange: (t) => onChange({ tags: t }), placeholder: "e.g. pokemon, grading, tcg" }), _jsx(Input, { label: "Meta Title (optional)", error: errors.metaTitle, value: values.metaTitle, onChange: (e) => onChange({ metaTitle: e.target.value }), placeholder: "Defaults to post title" }), _jsx(Input, { label: "Meta Description (optional)", error: errors.metaDescription, value: values.metaDescription, onChange: (e) => onChange({ metaDescription: e.target.value }), placeholder: "SEO description \u2014 max 160 chars", maxLength: 160 })] }));
233
233
  },
234
234
  },
235
235
  {
@@ -237,7 +237,7 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
237
237
  label: "Publish",
238
238
  quick: true,
239
239
  fields: ["status", "publishedAt", "authorName", "isFeatured"],
240
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
240
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
241
241
  },
242
242
  ];
243
243
  const deleteModal = (_jsx(ConfirmDeleteModal, { isOpen: showDeleteConfirm, onConfirm: () => {
@@ -224,7 +224,7 @@ export function AdminCarouselEditorView({ slideId, onSaved, onDeleted, onCancel,
224
224
  label: "Slide info",
225
225
  required: true,
226
226
  fields: ["title", "active", "order", "settings"],
227
- render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", value: title, onChange: (v) => setTitle(v), required: true, placeholder: "e.g. Hot Wheels RLC Exclusives" }), _jsx(Toggle, { label: "Active (visible on homepage)", checked: active, onChange: setActive }), _jsx(Input, { label: "Display order", type: "number", value: order, onChange: (e) => setOrder(e.target.value), min: 0, placeholder: "1" }), _jsx(Select, { label: "Slide height", value: height, onChange: (e) => setHeight(e.target.value), options: HEIGHT_OPTIONS }), _jsx(Input, { label: "Autoplay delay (ms)", type: "number", value: autoplayDelayMs, onChange: (e) => setAutoplayDelayMs(e.target.value), min: 1000, max: 15000, placeholder: "4000", helperText: "How long this slide shows before auto-advancing. Default: 4000 ms." })] })),
227
+ render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "title", label: "Title", value: title, onChange: (v) => setTitle(v), required: true, placeholder: "e.g. Hot Wheels RLC Exclusives" }), _jsx(Toggle, { label: "Active (visible on homepage)", checked: active, onChange: setActive }), _jsx(Input, { label: "Display order", error: form.shellCtx.errors.order, type: "number", value: order, onChange: (e) => setOrder(e.target.value), min: 0, placeholder: "1" }), _jsx(Select, { label: "Slide height", value: height, onChange: (e) => setHeight(e.target.value), options: HEIGHT_OPTIONS }), _jsx(Input, { label: "Autoplay delay (ms)", type: "number", value: autoplayDelayMs, onChange: (e) => setAutoplayDelayMs(e.target.value), min: 1000, max: 15000, placeholder: "4000", helperText: "How long this slide shows before auto-advancing. Default: 4000 ms." })] })),
228
228
  },
229
229
  {
230
230
  id: "background",
@@ -2,8 +2,10 @@
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 { useRouter } from "next/navigation.js";
5
6
  import { useQueryClient } from "@tanstack/react-query";
6
- import { Badge, BulkActionBar, Button, ConfirmDeleteModal, Div, ListingFilterDrawer, ListingLayout, ListingToolbar, Pagination, Row, Span, Stack, Text, StickyToolbar } from "../../../ui/index.js";
7
+ import { Badge, BulkActionBar, Button, ConfirmDeleteModal, Div, ListingFilterDrawer, ListingLayout, ListingToolbar, Pagination, Row, Span, Stack, Text, TextLink, StickyToolbar, Toggle, useToast } from "../../../ui/index.js";
8
+ import { ROUTES } from "../../../next/routing/route-map.js";
7
9
  import { useBottomActions } from "../../layout/index.js";
8
10
  import { MediaImage } from "../../media/MediaImage.js";
9
11
  import { ADMIN_ENDPOINTS, HOMEPAGE_ENDPOINTS } from "../../../constants/api-endpoints.js";
@@ -14,6 +16,8 @@ import { useAdminListing } from "../hooks/useAdminListing.js";
14
16
  import { DataTable } from "./DataTable.js";
15
17
  import { RowActionMenu } from "../../../ui/components/RowActionMenu.js";
16
18
  import { apiClient } from "../../../http/index.js";
19
+ import { normalizeError } from "../../../errors/normalize.js";
20
+ import { toUserMessage } from "../../../errors/error-display-map.js";
17
21
  const PAGE_SIZE = 50;
18
22
  const FILTER_KEYS = ["active"];
19
23
  const DEFAULT_SORT = "order";
@@ -33,6 +37,8 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
33
37
  const [deleteTargetId, setDeleteTargetId] = useState(null);
34
38
  const [localRows, setLocalRows] = useState([]);
35
39
  const queryClient = useQueryClient();
40
+ const router = useRouter();
41
+ const { showToast } = useToast();
36
42
  const { view, setView, table, searchInput, setSearchInput, commitSearch, filterOpen, setFilterOpen, openFilters, applyFilters, clearFilters, pendingFilters, setPendingFilters, activeFilterCount, hasActiveState, resetAll, rows: fetchedRows, isLoading, errorMessage, currentPage, totalPages, selection, } = useAdminListing({
37
43
  filterKeys: FILTER_KEYS,
38
44
  defaultSort: DEFAULT_SORT,
@@ -111,8 +117,58 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
111
117
  setDraggingId(null);
112
118
  reorderMutation.mutate(next.map((r) => r.id));
113
119
  };
120
+ const setActiveMutation = useApiMutation({
121
+ errorMessage: "Could not change that slide's status.",
122
+ mutationFn: ({ id, active }) =>
123
+ // PUT, not PATCH: this route has no PATCH, and every field on
124
+ // `updateSlideSchema` is optional, so a lone `{ active }` is a valid
125
+ // partial update.
126
+ apiClient.put(ADMIN_ENDPOINTS.CAROUSEL_BY_ID(id), { active }),
127
+ onSuccess: () => {
128
+ queryClient.invalidateQueries({ queryKey: ["admin", "carousel", "listing"] });
129
+ // The public hero carousel reads under its own key.
130
+ queryClient.invalidateQueries({ queryKey: ["carousel"] });
131
+ },
132
+ });
133
+ /*
134
+ * 🛑 The bulk delete used to be gated entirely on an `onBulkDelete` PROP —
135
+ * and the page renders `<AdminCarouselView />` with no props at all. So
136
+ * `bulkActions` was always `[]`, the BulkActionBar never rendered, and
137
+ * DataTable was handed no selection props either, which meant no checkbox
138
+ * column existed to select with in the first place.
139
+ * `ACTIONS.ADMIN["delete-carousel"]` and its confirmation dialog were
140
+ * unreachable from this page.
141
+ *
142
+ * The prop is kept so an embedder can still override the behaviour, but it
143
+ * no longer decides whether the action EXISTS — this view owns a delete
144
+ * mutation already and can perfectly well call it N times.
145
+ */
146
+ const handleBulkDelete = useCallback(async () => {
147
+ const ids = selection.selectedIds;
148
+ try {
149
+ if (onBulkDelete) {
150
+ await onBulkDelete(ids);
151
+ }
152
+ else {
153
+ await Promise.all(ids.map((id) => apiClient.delete(ADMIN_ENDPOINTS.CAROUSEL_BY_ID(id))));
154
+ await queryClient.invalidateQueries({ queryKey: ["admin", "carousel", "listing"] });
155
+ }
156
+ selection.clearSelection();
157
+ showToast(`${ids.length} slide${ids.length === 1 ? "" : "s"} deleted.`, "success");
158
+ }
159
+ catch (err) {
160
+ const normalized = normalizeError(err);
161
+ /*
162
+ * The selection is deliberately NOT cleared on failure. `Promise.all`
163
+ * rejects on the first error, so some of these ids may be gone and
164
+ * some may not — clearing would hide which rows are still there and
165
+ * leave the admin no way to retry the remainder.
166
+ */
167
+ showToast(toUserMessage(normalized.code, undefined, { fallback: "Could not delete the selected slides." }), "error");
168
+ }
169
+ }, [onBulkDelete, selection, queryClient, showToast]);
114
170
  const bulkActions = [
115
- ...(onBulkDelete ? [buildBulkAction(ACTIONS.ADMIN["delete-carousel"], async () => { await onBulkDelete(selection.selectedIds); selection.clearSelection(); })] : []),
171
+ buildBulkAction(ACTIONS.ADMIN["delete-carousel"], handleBulkDelete),
116
172
  ];
117
173
  useBottomActions(selection.selectedCount > 0 ? { bulk: { selectedCount: selection.selectedCount, onClearSelection: selection.clearSelection, actions: bulkActions } } : {});
118
174
  if (hasChildren) {
@@ -138,6 +194,24 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
138
194
  header: "Details",
139
195
  render: (row) => row.secondary,
140
196
  },
197
+ {
198
+ /*
199
+ * 🛑 Both preventDefault AND stopPropagation.
200
+ *
201
+ * `rowHrefTemplate` makes the whole row an anchor, so stopping
202
+ * propagation alone leaves the anchor's DEFAULT action intact — the
203
+ * toggle would flip and then navigate to the edit page, which reads as
204
+ * "clicking the switch opens the editor". A normal column cell is also
205
+ * outside the two `<Td>`s DataTable stops propagation on; only the
206
+ * selection and row-action cells get that for free.
207
+ */
208
+ key: "active",
209
+ header: "Active",
210
+ className: "w-24",
211
+ render: (row) => (_jsx(Row, { align: "center", gap: "sm", role: "presentation", onClick: (e) => { e.preventDefault(); e.stopPropagation(); }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(Toggle, { size: "sm", checked: row.status === "Active", ariaLabel: row.status === "Active"
212
+ ? `${ACTIONS.ADMIN["deactivate-carousel"].ariaLabel}: ${row.primary}`
213
+ : `${ACTIONS.ADMIN["activate-carousel"].ariaLabel}: ${row.primary}`, onChange: (next) => setActiveMutation.mutate({ id: row.id, active: next }) }) })),
214
+ },
141
215
  {
142
216
  key: "status",
143
217
  header: "Status",
@@ -152,7 +226,10 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
152
226
  key: "actions",
153
227
  header: "",
154
228
  render: (row) => (_jsx(RowActionMenu, { actions: [
155
- { label: ACTIONS.ADMIN["edit-carousel"].label, onClick: () => { window.location.href = `/admin/carousel/${row.id}/edit`; } },
229
+ // router.push, not window.location.href — a full page load to
230
+ // reach a page the row already links to is strictly worse, and
231
+ // this is the same navigation DataTable's row anchor performs.
232
+ { label: ACTIONS.ADMIN["edit-carousel"].label, onClick: () => router.push(String(ROUTES.ADMIN.CAROUSEL_EDIT(row.id))) },
156
233
  {
157
234
  label: ACTIONS.ADMIN["delete-carousel"].label,
158
235
  destructive: true,
@@ -163,5 +240,20 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
163
240
  ];
164
241
  return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput,
165
242
  // Search intentionally absent: this endpoint does not read `q`, so the box accepted typing and changed nothing. Restore it when the collection gains searchTxt — audit-listing-search-capability tracks it.
166
- onSearchChange: setSearchInput, onSearchCommit: commitSearch, showTableView: true, view: view, onViewChange: (v) => setView(v), onResetAll: resetAll, hasActiveState: hasActiveState }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), selection.selectedCount > 0 && bulkActions.length > 0 && (_jsx(BulkActionBar, { selectedCount: selection.selectedCount, actions: bulkActions, onClearSelection: selection.clearSelection })), _jsxs(Div, { paddingX: "x-sm-md", padding: "y-md", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), _jsx(DataTable, { rows: displayRows, columns: columns, isLoading: isLoading, emptyLabel: "No carousel slides \u2014 use 'New slide' to add one", rowHrefTemplate: "/admin/carousel/{id}/edit" })] }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Row, { wrap: true, gap: "sm", children: [{ label: "All", value: "" }, { label: "Active", value: "true" }, { label: "Inactive", value: "false" }].map((opt) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => setPendingFilters((p) => ({ ...p, active: opt.value })), className: `rounded-full px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium border transition-colors min-h-0 h-auto ${(pendingFilters.active || "") === opt.value ? "bg-primary text-white border-primary" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-surface-hover"}`, children: opt.label }, opt.label))) })] }) }), deleteTargetId && (_jsx(ConfirmDeleteModal, { isOpen: true, title: "Delete Carousel Slide", message: "Delete this carousel slide? This cannot be undone.", onConfirm: () => { deleteMutation.mutate(deleteTargetId); setDeleteTargetId(null); }, onClose: () => setDeleteTargetId(null), isDeleting: deleteMutation.isPending }))] }));
243
+ onSearchChange: setSearchInput, onSearchCommit: commitSearch, showTableView: true, view: view, onViewChange: (v) => setView(v), onResetAll: resetAll, hasActiveState: hasActiveState,
244
+ /*
245
+ * 🛑 The empty state named a button that did not exist: "No carousel
246
+ * slides — use 'New slide' to add one", with no New slide control
247
+ * anywhere on the page. `/admin/carousel/new` has always been a real
248
+ * route and nothing linked to it.
249
+ */
250
+ extra: _jsx(Button, { gap: "sm", size: "sm", asChild: true, children: _jsx(TextLink, { variant: "inherit", href: String(ROUTES.ADMIN.CAROUSEL_NEW), children: "New slide" }) }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), selection.selectedCount > 0 && bulkActions.length > 0 && (_jsx(BulkActionBar, { selectedCount: selection.selectedCount, actions: bulkActions, onClearSelection: selection.clearSelection })), _jsxs(Div, { paddingX: "x-sm-md", padding: "y-md", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), _jsx(DataTable, { rows: displayRows, columns: columns, isLoading: isLoading, emptyLabel: "No carousel slides yet", emptyAction: _jsx(Button, { gap: "sm", size: "sm", asChild: true, children: _jsx(TextLink, { variant: "inherit", href: String(ROUTES.ADMIN.CAROUSEL_NEW), children: "New slide" }) }), rowHrefTemplate: "/admin/carousel/{id}/edit",
251
+ /*
252
+ * Selection was destructured and a bulk-action array built from it,
253
+ * and these three props were never passed — so no checkbox column
254
+ * rendered and nothing could be selected to act on.
255
+ */
256
+ selectedIds: selection.selectedIdSet, onToggleSelect: (id) => selection.toggle(id), onToggleSelectAll: () => selection.toggleAll() })] }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Row, { wrap: true, gap: "sm", children: [{ label: "All", value: "" }, { label: "Active", value: "true" }, { label: "Inactive", value: "false" }].map((opt) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => setPendingFilters((p) => ({ ...p, active: opt.value })), className: `rounded-full px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium border transition-colors min-h-0 h-auto ${(pendingFilters.active || "") === opt.value ? "bg-primary text-white border-primary" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-surface-hover"}`, children: opt.label }, opt.label))) })] }) }), deleteTargetId && (_jsx(ConfirmDeleteModal, { isOpen: true,
257
+ /* Rule #7: confirmation copy lives on the ActionDef, not here. */
258
+ title: ACTIONS.ADMIN["delete-carousel"].confirmation?.title, message: ACTIONS.ADMIN["delete-carousel"].confirmation?.body, confirmText: ACTIONS.ADMIN["delete-carousel"].confirmation?.confirmLabel, onConfirm: () => { deleteMutation.mutate(deleteTargetId); setDeleteTargetId(null); }, onClose: () => setDeleteTargetId(null), isDeleting: deleteMutation.isPending }))] }));
167
259
  }
@@ -56,6 +56,9 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
56
56
  minPurchase: "",
57
57
  buyQuantity: "1",
58
58
  getQuantity: "1",
59
+ // Blank, not a number — the ceiling is optional and a default here would
60
+ // silently cap a new BOGO coupon the admin never meant to cap.
61
+ maxItemPrice: "",
59
62
  totalLimit: "",
60
63
  perUserLimit: "",
61
64
  currentUsage: 0,
@@ -101,6 +104,7 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
101
104
  minPurchase: c.discount?.minPurchase !== undefined ? String(c.discount.minPurchase) : "",
102
105
  buyQuantity: c.bxgy?.buyQuantity !== undefined ? String(c.bxgy.buyQuantity) : "1",
103
106
  getQuantity: c.bxgy?.getQuantity !== undefined ? String(c.bxgy.getQuantity) : "1",
107
+ maxItemPrice: c.bxgy?.maxItemPrice !== undefined ? String(c.bxgy.maxItemPrice) : "",
104
108
  totalLimit: c.usage?.totalLimit !== undefined ? String(c.usage.totalLimit) : "",
105
109
  perUserLimit: c.usage?.perUserLimit !== undefined ? String(c.usage.perUserLimit) : "",
106
110
  currentUsage: c.usage?.currentUsage ?? 0,
@@ -147,6 +151,10 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
147
151
  bxgy: {
148
152
  buyQuantity: num(draft.buyQuantity) ?? 1,
149
153
  getQuantity: num(draft.getQuantity) ?? 1,
154
+ // `num("")` is undefined, and an undefined ceiling is "no limit" —
155
+ // which is what a blank box should mean. Do not `?? 0` it: zero
156
+ // would disqualify every unit and silently zero the coupon.
157
+ maxItemPrice: num(draft.maxItemPrice),
150
158
  },
151
159
  }),
152
160
  usage: {