@mohasinac/appkit 4.61.0 → 4.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
  3. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  5. package/dist/_internal/server/jobs/core/paymentReconcile.js +87 -97
  6. package/dist/_internal/shared/actions/action-registry.js +149 -9
  7. package/dist/_internal/shared/media/limits.d.ts +10 -0
  8. package/dist/_internal/shared/media/limits.js +10 -0
  9. package/dist/_internal/shared/types/action-result.js +15 -1
  10. package/dist/client/api/surface-error.js +16 -2
  11. package/dist/client/api/useApiMutation.js +17 -1
  12. package/dist/client.d.ts +1 -1
  13. package/dist/client.js +1 -1
  14. package/dist/constants/api-endpoints.d.ts +3 -0
  15. package/dist/constants/api-endpoints.js +1 -0
  16. package/dist/constants/field-names.d.ts +17 -0
  17. package/dist/constants/field-names.js +23 -0
  18. package/dist/core/server-action.js +19 -3
  19. package/dist/errors/error-mapping.js +26 -1
  20. package/dist/features/account/components/UserOffersPanel.js +5 -3
  21. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  22. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  23. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  24. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  25. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  26. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  27. package/dist/features/admin/components/AdminMediaView.js +36 -3
  28. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  29. package/dist/features/admin/components/AdminOffersView.js +2 -1
  30. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  31. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  32. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  33. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  34. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  35. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  36. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  37. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  38. package/dist/features/admin/repository/notification.repository.js +32 -0
  39. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  40. package/dist/features/admin/schemas/action-index-form.js +115 -0
  41. package/dist/features/auth/components/RegisterForm.js +2 -1
  42. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  43. package/dist/features/events/components/AdminEventEditorView.js +8 -6
  44. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  45. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  46. package/dist/features/orders/components/RefundRequestView.js +4 -4
  47. package/dist/features/orders/schemas/index.d.ts +6 -6
  48. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  49. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  50. package/dist/features/products/components/ProductDetailActions.js +2 -1
  51. package/dist/features/seller/components/FulfillmentView.js +3 -2
  52. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  53. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  54. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  55. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  56. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  57. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  58. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  59. package/dist/features/seller/components/SellerProductsView.js +5 -1
  60. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  61. package/dist/features/seller/components/SellerShippingView.js +2 -1
  62. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  63. package/dist/features/seller/components/SellerSupportView.js +69 -2
  64. package/dist/features/shell/SectionForm.js +20 -16
  65. package/dist/features/shell/build-sections.js +14 -1
  66. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  67. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  68. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  69. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  70. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  71. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  72. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  73. package/dist/index.d.ts +5 -1
  74. package/dist/index.js +3 -1
  75. package/dist/next/routing/route-map.d.ts +4 -0
  76. package/dist/next/routing/route-map.js +9 -0
  77. package/dist/schemas/registry.d.ts +4 -4
  78. package/dist/schemas/sieve.d.ts +3 -3
  79. package/dist/server.d.ts +1 -1
  80. package/dist/server.js +1 -1
  81. package/dist/ui/components/Checkbox.js +10 -2
  82. package/dist/ui/components/DateInput.js +8 -1
  83. package/dist/ui/components/Input.js +22 -2
  84. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  85. package/dist/ui/components/PaginatedSelect.js +43 -9
  86. package/dist/ui/components/Radio.js +8 -1
  87. package/dist/ui/components/Select.js +10 -2
  88. package/dist/ui/components/TagInput.d.ts +8 -1
  89. package/dist/ui/components/TagInput.js +4 -3
  90. package/dist/ui/components/Textarea.js +10 -2
  91. package/dist/ui/forms/FieldCheckbox.js +1 -1
  92. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  93. package/dist/ui/forms/FormErrorList.js +34 -1
  94. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  95. package/dist/ui/forms/apply-zod-issues.js +23 -1
  96. package/package.json +1 -1
@@ -690,6 +690,7 @@ export const SELLER_ENDPOINTS = {
690
690
  BUNDLES: "/api/store/bundles",
691
691
  /** Tickets raised ABOUT this store (W11). */
692
692
  SUPPORT: "/api/store/support",
693
+ SUPPORT_TICKET_BY_ID: (id) => `/api/store/support/${id}`,
693
694
  BUNDLE_BY_ID: (id) => `/api/store/bundles/${id}`,
694
695
  ORDERS_ASSIGN: (orderId) => `/api/store/orders/${orderId}/assign`,
695
696
  PRODUCT_BY_ID: (id) => `/api/store/products/${id}`,
@@ -982,6 +982,23 @@ export declare const COMMON_FIELDS: {
982
982
  readonly IS_ACTIVE: "isActive";
983
983
  readonly ORDER: "order";
984
984
  };
985
+ /**
986
+ * The idempotency rows behind a refund.
987
+ *
988
+ * 🛑 `STATE` is the claim's own lifecycle ("claimed" → settled), NOT a
989
+ * postal state. `audit-route-nav-field-constants` matches a raw field
990
+ * string against every constant that happens to share its VALUE, so it
991
+ * suggested `ADDRESS_FIELDS.STATE` here — right string, wrong concept, and
992
+ * taking the suggestion would have made the query read as address filtering.
993
+ */
994
+ export declare const REFUND_CLAIM_FIELDS: {
995
+ readonly STATE: "state";
996
+ readonly CLAIMED_AT: "claimedAt";
997
+ };
998
+ /** Observed market prices, keyed by the taxonomy node they were seen under. */
999
+ export declare const PRICE_OBSERVATION_FIELDS: {
1000
+ readonly CATEGORY_ID: "categoryId";
1001
+ };
985
1002
  export declare const OAUTH_STATE_VALUES: {
986
1003
  readonly PENDING: "pending";
987
1004
  readonly USED: "used";
@@ -1175,6 +1175,29 @@ export const COMMON_FIELDS = {
1175
1175
  ORDER: "order",
1176
1176
  };
1177
1177
  // ============================================================================
1178
+ // REFUND CLAIMS
1179
+ // ============================================================================
1180
+ /**
1181
+ * The idempotency rows behind a refund.
1182
+ *
1183
+ * 🛑 `STATE` is the claim's own lifecycle ("claimed" → settled), NOT a
1184
+ * postal state. `audit-route-nav-field-constants` matches a raw field
1185
+ * string against every constant that happens to share its VALUE, so it
1186
+ * suggested `ADDRESS_FIELDS.STATE` here — right string, wrong concept, and
1187
+ * taking the suggestion would have made the query read as address filtering.
1188
+ */
1189
+ export const REFUND_CLAIM_FIELDS = {
1190
+ STATE: "state",
1191
+ CLAIMED_AT: "claimedAt",
1192
+ };
1193
+ // ============================================================================
1194
+ // PRICE OBSERVATIONS
1195
+ // ============================================================================
1196
+ /** Observed market prices, keyed by the taxonomy node they were seen under. */
1197
+ export const PRICE_OBSERVATION_FIELDS = {
1198
+ CATEGORY_ID: "categoryId",
1199
+ };
1200
+ // ============================================================================
1178
1201
  // OAUTH STATE VALUES
1179
1202
  // ============================================================================
1180
1203
  export const OAUTH_STATE_VALUES = {
@@ -1,6 +1,6 @@
1
1
  import { normalizeError } from "../errors/normalize.js";
2
2
  import { toApiIssues } from "../errors/error-envelope.js";
3
- import { HTTP_ERROR_CODES } from "../errors/error-mapping.js";
3
+ import { HTTP_ERROR_CODES, mapToHttpError } from "../errors/error-mapping.js";
4
4
  /**
5
5
  * createServerAction — @mohasinac/appkit/core
6
6
  *
@@ -97,11 +97,27 @@ export function createServerAction(options) {
97
97
  }
98
98
  catch (err) {
99
99
  void normalizeError(err);
100
- const message = err instanceof Error ? err.message : "An unexpected error occurred";
100
+ /*
101
+ * 🛑 This never got the Rule #9 fix its sibling `wrapAction` did.
102
+ *
103
+ * It took `err.message` verbatim — unscrubbed for 5xx, so a thrown
104
+ * Node/bundler error's internals reached the client — and emitted NO
105
+ * `code`, so `toUserMessage(result.code)` downstream could only ever
106
+ * resolve to the generic sentence.
107
+ *
108
+ * It has no in-repo callers, but it is exported from the public barrel,
109
+ * so it is a trap for the next consumer rather than a live bug. Route it
110
+ * through the same mapper `wrapAction` uses: `mapToHttpError` scrubs 5xx
111
+ * messages in production and carries the real one in `internalMessage`
112
+ * for recorders only.
113
+ */
114
+ const mapped = mapToHttpError(err, {
115
+ isProduction: process.env.NODE_ENV === "production",
116
+ });
101
117
  logger.error(`[createServerAction] handler error in "${actionName}"`, {
102
118
  err,
103
119
  });
104
- result = { ok: false, error: message };
120
+ result = { ok: false, code: mapped.code, error: mapped.message };
105
121
  }
106
122
  // -- 4. After hooks (per-action → global) ------------------------------
107
123
  try {
@@ -42,6 +42,31 @@ function isZodLikeError(err) {
42
42
  "issues" in err &&
43
43
  Array.isArray(err.issues));
44
44
  }
45
+ /**
46
+ * The issues carried by a `ValidationError`, whichever of the two shapes the
47
+ * thrower used.
48
+ *
49
+ * 🛑 `new ValidationError(msg, parsed.error.issues)` silently lost everything.
50
+ * `ValidationError` forwards its second argument to `AppError.data`, and the
51
+ * old test was `isZodLikeError(err.data)` — which requires `"issues" in data`.
52
+ * An ARRAY has no `.issues` property (`"issues" in [] === false`), so passing
53
+ * `parsed.error.issues` (the obvious thing to pass, and what four routes do)
54
+ * produced a 400 with no field information at all. Only passing the whole
55
+ * `ZodError` worked.
56
+ *
57
+ * `media/finalize/route.ts` even carried a comment asserting the opposite —
58
+ * "carries `issues` through to the client, so the caller learns WHICH field it
59
+ * got wrong". It did not.
60
+ *
61
+ * Accept both, so the next caller cannot get it wrong either way.
62
+ */
63
+ function issuesFromErrorData(data) {
64
+ if (Array.isArray(data))
65
+ return data.length > 0 ? data : undefined;
66
+ if (isZodLikeError(data))
67
+ return data.issues;
68
+ return undefined;
69
+ }
45
70
  function isFirestoreLikeError(err) {
46
71
  if (typeof err !== "object" || err === null)
47
72
  return false;
@@ -110,7 +135,7 @@ function classifyError(err, opts) {
110
135
  status: 400,
111
136
  code: HTTP_ERROR_CODES.VALIDATION_FAILED,
112
137
  message: err.message,
113
- issues: isZodLikeError(err.data) ? err.data.issues : undefined,
138
+ issues: issuesFromErrorData(err.data),
114
139
  };
115
140
  }
116
141
  if (err instanceof AuthenticationError) {
@@ -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",