@mohasinac/appkit 4.31.1 → 4.32.1

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 (71) hide show
  1. package/dist/_internal/server/features/products/list-public.d.ts +18 -0
  2. package/dist/_internal/server/features/products/list-public.js +27 -9
  3. package/dist/client.d.ts +9 -0
  4. package/dist/client.js +12 -0
  5. package/dist/features/account/components/UserBidsView.js +6 -1
  6. package/dist/features/account/components/UserOrdersView.js +8 -1
  7. package/dist/features/account/components/UserReturnsView.js +6 -1
  8. package/dist/features/admin/components/AdminAdsView.js +36 -34
  9. package/dist/features/admin/components/AdminAuditLogView.js +12 -1
  10. package/dist/features/admin/components/AdminBundlesView.js +5 -1
  11. package/dist/features/admin/components/AdminCarouselEditorView.js +78 -10
  12. package/dist/features/admin/components/AdminFaqsView.js +10 -1
  13. package/dist/features/admin/components/AdminNotificationsView.js +7 -1
  14. package/dist/features/admin/components/AdminOrderEditorView.js +55 -24
  15. package/dist/features/admin/components/AdminPrizeDrawsView.js +11 -1
  16. package/dist/features/admin/components/AdminProductEditorView.js +78 -18
  17. package/dist/features/admin/components/AdminSectionsView.js +89 -32
  18. package/dist/features/admin/components/AdminSiteSettingsView.js +96 -44
  19. package/dist/features/admin/components/AdminStoresView.js +13 -1
  20. package/dist/features/admin/components/AdminSupportTicketDetailView.js +58 -43
  21. package/dist/features/admin/components/AdminSupportTicketsView.js +11 -1
  22. package/dist/features/admin/components/AdminTeamView.js +11 -1
  23. package/dist/features/admin/components/AdminTesterChecklistView.js +6 -1
  24. package/dist/features/admin/components/AdminUserEditorView.js +65 -82
  25. package/dist/features/admin/components/analytics/AdminPageViewsReportView.js +6 -1
  26. package/dist/features/admin/constants/admin-page-tabs.d.ts +36 -0
  27. package/dist/features/admin/constants/admin-page-tabs.js +45 -0
  28. package/dist/features/admin/schemas/admin-ops-forms.d.ts +81 -0
  29. package/dist/features/admin/schemas/admin-ops-forms.js +123 -1
  30. package/dist/features/admin/schemas/admin-user-form.d.ts +78 -3
  31. package/dist/features/admin/schemas/admin-user-form.js +96 -7
  32. package/dist/features/admin/schemas/homepage-section-form.d.ts +9 -0
  33. package/dist/features/layout/PageTabs.d.ts +17 -0
  34. package/dist/features/layout/PageTabs.js +45 -0
  35. package/dist/features/products/components/GroupSettingsPanel.js +62 -16
  36. package/dist/features/search/action-index/tab-entries.d.ts +18 -0
  37. package/dist/features/search/action-index/tab-entries.js +128 -0
  38. package/dist/features/seller/components/SellerBundlesView.js +5 -1
  39. package/dist/features/seller/components/SellerGoogleReviewsView.js +31 -19
  40. package/dist/features/seller/components/SellerOrdersView.js +57 -19
  41. package/dist/features/seller/components/SellerProductsView.js +10 -1
  42. package/dist/features/seller/components/SellerReviewsView.d.ts +2 -1
  43. package/dist/features/seller/components/SellerReviewsView.js +54 -19
  44. package/dist/features/seller/constants/store-tabs.d.ts +64 -0
  45. package/dist/features/seller/constants/store-tabs.js +60 -0
  46. package/dist/features/seller/schemas/order-forms.d.ts +33 -0
  47. package/dist/features/seller/schemas/order-forms.js +75 -0
  48. package/dist/features/seller/schemas/review-forms.d.ts +37 -0
  49. package/dist/features/seller/schemas/review-forms.js +82 -0
  50. package/dist/features/shell/index.d.ts +1 -1
  51. package/dist/features/shell/index.js +5 -1
  52. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +44 -15
  53. package/dist/features/shipments/schemas/validation.d.ts +41 -0
  54. package/dist/features/shipments/schemas/validation.js +49 -0
  55. package/dist/features/store-extensions/schemas/google-config-form.d.ts +36 -0
  56. package/dist/features/store-extensions/schemas/google-config-form.js +24 -0
  57. package/dist/features/stores/actions/store-query-actions.js +5 -3
  58. package/dist/features/stores/repository/store.repository.d.ts +3 -1
  59. package/dist/features/stores/repository/store.repository.js +6 -2
  60. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +79 -0
  61. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +53 -22
  62. package/dist/index.d.ts +4 -0
  63. package/dist/index.js +2 -0
  64. package/dist/next/index.d.ts +2 -0
  65. package/dist/next/index.js +1 -0
  66. package/dist/react/hooks/useTabParam.d.ts +19 -0
  67. package/dist/react/hooks/useTabParam.js +65 -0
  68. package/dist/schemas/registry.d.ts +27 -3
  69. package/dist/styles.css +1 -1
  70. package/dist/tailwind-utilities.css +1 -1
  71. package/package.json +1 -1
@@ -6,13 +6,16 @@ import { useApiMutation } from "@mohasinac/appkit/client";
6
6
  import React, { useState } from "react";
7
7
  import { normalizeError } from "../../../errors/normalize";
8
8
  import { useQueryClient } from "@tanstack/react-query";
9
- import { Button, Div, Form, FormActions, Input, Label, Select, SideDrawer, Stack, Text, Textarea, useToast } from "../../../ui";
9
+ import { Button, Div, Label, SideDrawer, Stack, Text, Textarea, useToast } from "../../../ui";
10
10
  import { MediaImage } from "../../media";
11
11
  import { apiClient } from "../../../http";
12
12
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
13
13
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
14
14
  import { toCurrency } from "../hooks/useAdminListingData";
15
- import { adminOrderUpdateSchema } from "../schemas/admin-ops-forms";
15
+ import { adminOrderUpdateSchema, } from "../schemas/admin-ops-forms";
16
+ import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell";
17
+ import { useFormShellState, FormShellContext } from "../../../ui/forms";
18
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
16
19
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
17
20
  import { ValidationError } from "../../../errors/validation-error";
18
21
  const STATUS_OPTIONS = [
@@ -37,11 +40,28 @@ const CARRIER_OPTIONS = [
37
40
  export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, currentStatus, items, paymentProofUrl, paymentTransactionId, paymentMethod, paymentStatus, displayedUpiId, buyerReportedUpiId, paymentUpiMismatch, buyerMarkedPaid, buyerFraudAgreementAccepted, paymentReviewOutcome, addons, }) {
38
41
  const queryClient = useQueryClient();
39
42
  const { showToast } = useToast();
40
- const [status, setStatus] = React.useState(currentStatus ?? "pending");
41
- const [trackingNumber, setTrackingNumber] = React.useState("");
42
- const [carrier, setCarrier] = React.useState("");
43
- const [notes, setNotes] = React.useState("");
44
- const [refundAmount, setRefundAmount] = React.useState("");
43
+ const [draft, setDraft] = React.useState({
44
+ status: (currentStatus ?? "pending"),
45
+ trackingNumber: "",
46
+ carrier: "",
47
+ notes: "",
48
+ });
49
+ /*
50
+ * `refundAmount` carries a `when` in the schema — it is only meaningful on a
51
+ * refunding status — so the control appears and disappears with the status
52
+ * select, and `visibleValues()` drops the value when it disappears. The page
53
+ * used to gate the input with the same condition written a second time in
54
+ * JSX; now the schema is the single place that rule lives.
55
+ */
56
+ const sections = React.useMemo(() => buildSectionsFromSchema(adminOrderUpdateSchema, {
57
+ options: { status: STATUS_OPTIONS, carrier: CARRIER_OPTIONS },
58
+ }), []);
59
+ const nav = useSectionFormNav(sections, draft, { scope: "admin:order-editor" });
60
+ const form = useFormShellState(adminOrderUpdateSchema, {
61
+ sections: nav.sectionMeta,
62
+ onGoToSection: nav.goToSection,
63
+ fieldToSectionIndex: nav.fieldToSectionIndex,
64
+ });
45
65
  const [isVerifyingPayment, setIsVerifyingPayment] = useState(false);
46
66
  /*
47
67
  * 🛑 `reviewNote` belongs to the PAYMENT REVIEW actions, not to "Save
@@ -69,26 +89,30 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
69
89
  const needsVerification = isManualPayment && !isVerified && !isRejected && Boolean(paymentProofUrl);
70
90
  React.useEffect(() => {
71
91
  if (open) {
72
- setStatus(currentStatus ?? "pending");
73
- setTrackingNumber("");
74
- setCarrier("");
75
- setNotes("");
76
- setRefundAmount("");
92
+ setDraft({
93
+ status: (currentStatus ?? "pending"),
94
+ trackingNumber: "",
95
+ carrier: "",
96
+ notes: "",
97
+ });
77
98
  setReviewNote("");
78
99
  }
79
100
  }, [open, currentStatus]);
80
101
  const saveMutation = useApiMutation({
81
102
  errorMessage: "Failed to update order.",
82
103
  mutationFn: async () => {
104
+ // Built from `visibleValues`, so a refund amount typed and then hidden by
105
+ // a status change cannot travel with the request.
106
+ const v = visibleValues(adminOrderUpdateSchema, draft);
83
107
  const payload = {
84
- status,
85
- notes: notes || undefined,
108
+ status: v.status,
109
+ notes: v.notes || undefined,
86
110
  };
87
- if (trackingNumber)
88
- payload.trackingNumber = trackingNumber;
89
- if (carrier)
90
- payload.carrier = carrier;
91
- if (refundAmount.trim()) {
111
+ if (v.trackingNumber)
112
+ payload.trackingNumber = v.trackingNumber;
113
+ if (v.carrier)
114
+ payload.carrier = v.carrier;
115
+ if (v.refundAmount !== undefined && String(v.refundAmount).trim() !== "") {
92
116
  /*
93
117
  * Parsed through the schema, which coerces, requires > 0 and caps the
94
118
  * magnitude. It used to be `parseFloat` guarded by `!isNaN && > 0` with
@@ -96,7 +120,7 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
96
120
  * save reported success with nothing recorded. A refund that does not
97
121
  * parse must stop the save, not disappear from it.
98
122
  */
99
- const parsed = adminOrderUpdateSchema.shape.refundAmount.safeParse(refundAmount);
123
+ const parsed = adminOrderUpdateSchema.shape.refundAmount.safeParse(v.refundAmount);
100
124
  if (!parsed.success) {
101
125
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Enter the refund amount as a number.");
102
126
  }
@@ -183,8 +207,15 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
183
207
  setIsRejectingFraud(false);
184
208
  }
185
209
  };
186
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: orderLabel ? `Order: ${orderLabel}` : "Update Order", children: _jsxs(Form, { schema: adminOrderUpdateSchema, onSubmit: (e) => {
187
- e.preventDefault();
188
- saveMutation.mutate();
189
- }, spacing: "md", padding: "md", children: [_jsx(FormErrorSummary, {}), addons && _jsx(OrderAddonBadges, { order: addons, variant: "detail" }), items && items.length > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Label, { size: "sm", weight: "medium", color: "primary", children: ["Items (", items.length, ")"] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: items.map((item, i) => (_jsxs(Div, { layout: "flex", align: "center", gap: "3", padding: "sm", children: [_jsx(Div, { className: "h-10 w-10 shrink-0", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: item.image, alt: item.title, size: "thumbnail" }) }), _jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { size: "sm", className: "truncate", weight: "medium", children: item.title }), _jsxs(Text, { size: "xs", color: "muted", children: ["Qty: ", item.quantity, " \u00D7 ", toCurrency(item.unitPrice)] })] }), _jsx(Text, { size: "sm", className: "shrink-0", weight: "semibold", children: toCurrency(item.totalPrice) })] }, item.productId || i))) })] })), _jsx(Select, { label: "Order status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Input, { label: "Tracking number (optional)", value: trackingNumber, onChange: (e) => setTrackingNumber(e.target.value), placeholder: "e.g. DEL1234567890IN" }), _jsx(Select, { label: "Carrier (optional)", options: CARRIER_OPTIONS, value: carrier, onValueChange: setCarrier }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Internal note (optional)" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Reason for status change, escalation notes\u2026" })] }), (status === "refunded" || status === "return_requested") && (_jsx(Input, { label: "Refund amount \u20B9 (optional)", type: "number", min: "0", step: "0.01", value: refundAmount, onChange: (e) => setRefundAmount(e.target.value), placeholder: "e.g. 499.00" })), isManualPayment && (_jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Payment Proof" }), isRejected && (_jsx(Text, { size: "xs", className: "text-error", weight: "medium", children: "Payment rejected as fraud \u2014 no further action available here." })), isReuploadRequested && !isVerified && (_jsx(Text, { size: "xs", className: "text-warning", weight: "medium", children: "Re-upload requested \u2014 waiting for the buyer to submit a new proof." })), paymentProofUrl ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Div, { border: "default", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: paymentProofUrl, alt: "Payment screenshot", size: "card" }) }), paymentTransactionId && (_jsxs(Text, { size: "xs", color: "muted", children: ["UTR: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: paymentTransactionId })] })), needsVerification && (_jsxs(Stack, { gap: "xs", children: [(displayedUpiId || buyerReportedUpiId) && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Expected UPI: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: displayedUpiId || "—" }), " · ", "Buyer reported: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerReportedUpiId || "—" })] }), paymentUpiMismatch && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-error/20", surface: "danger-surface", children: _jsx(Text, { size: "xs", className: "text-error", weight: "semibold", children: "UPI mismatch \u2014 buyer-reported ID doesn't match the one shown for this order." }) }))] })), _jsxs(Text, { size: "xs", color: "muted", children: ["Buyer marked as paid: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerMarkedPaid ? "Yes" : "No" }), " · ", "Fraud agreement accepted: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerFraudAgreementAccepted ? "Yes" : "No" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Review note" }), _jsxs(Text, { size: "xs", color: "muted", children: ["Sent with ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Request re-upload" }), " or", " ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Reject as fraud" }), " below. Not saved by \u201CSave changes\u201D."] }), _jsx(Textarea, { value: reviewNote, onChange: (e) => handleReviewNoteChange(e.target.value), rows: 2, "aria-invalid": reviewNoteError ? true : undefined, placeholder: "e.g. Screenshot is blurry \u2014 amount not readable / UPI ID doesn't match order" }), reviewNoteError && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: reviewNoteError }))] }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["verify-payment"], onClick: handleVerifyPayment, isLoading: isVerifyingPayment, disabled: isVerifyingPayment, variant: "primary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["request-payment-reupload"], onClick: handleRequestReupload, isLoading: isRequestingReupload, disabled: isRequestingReupload, variant: "secondary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["reject-payment-fraud"], onClick: handleRejectFraud, isLoading: isRejectingFraud, disabled: isRejectingFraud, variant: "danger", className: "w-full" })] })), isVerified && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-success/20", surface: "success-surface", children: _jsx(Text, { size: "xs", className: "text-success", weight: "medium", children: "Payment verified" }) }))] })) : (_jsx(Text, { size: "xs", color: "faint", children: "No proof uploaded yet." }))] })), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !orderId || saveMutation.isPending, children: "Save changes" })] })] }) }));
210
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: orderLabel ? `Order: ${orderLabel}` : "Update Order", children: _jsx(FormShellContext.Provider, { value: form.shellCtx, children: _jsxs(Stack, { gap: "md", padding: "md", children: [_jsx(FormErrorSummary, {}), addons && _jsx(OrderAddonBadges, { order: addons, variant: "detail" }), items && items.length > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Label, { size: "sm", weight: "medium", color: "primary", children: ["Items (", items.length, ")"] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: items.map((item, i) => (_jsxs(Div, { layout: "flex", align: "center", gap: "3", padding: "sm", children: [_jsx(Div, { className: "h-10 w-10 shrink-0", rounded: "md", overflow: "hidden", children: _jsx(MediaImage, { src: item.image, alt: item.title, size: "thumbnail" }) }), _jsxs(Div, { className: "min-w-0 flex-1", children: [_jsx(Text, { size: "sm", className: "truncate", weight: "medium", children: item.title }), _jsxs(Text, { size: "xs", color: "muted", children: ["Qty: ", item.quantity, " \u00D7 ", toCurrency(item.unitPrice)] })] }), _jsx(Text, { size: "sm", className: "shrink-0", weight: "semibold", children: toCurrency(item.totalPrice) })] }, item.productId || i))) })] })), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
211
+ // SectionForm surfaces errors and then submits regardless, so the
212
+ // guard is here.
213
+ form.clearErrors();
214
+ const parsed = adminOrderUpdateSchema.safeParse(visibleValues(adminOrderUpdateSchema, draft));
215
+ if (!parsed.success) {
216
+ applyZodIssues(parsed.error.issues, form.setFieldError);
217
+ return;
218
+ }
219
+ saveMutation.mutate();
220
+ }, onValidationChange: () => form.validate(draft), schema: adminOrderUpdateSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Save changes", cancelLabel: "Cancel", onCancel: onClose, isLoading: saveMutation.isPending }), isManualPayment && (_jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Payment Proof" }), isRejected && (_jsx(Text, { size: "xs", className: "text-error", weight: "medium", children: "Payment rejected as fraud \u2014 no further action available here." })), isReuploadRequested && !isVerified && (_jsx(Text, { size: "xs", className: "text-warning", weight: "medium", children: "Re-upload requested \u2014 waiting for the buyer to submit a new proof." })), paymentProofUrl ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Div, { border: "default", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: paymentProofUrl, alt: "Payment screenshot", size: "card" }) }), paymentTransactionId && (_jsxs(Text, { size: "xs", color: "muted", children: ["UTR: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: paymentTransactionId })] })), needsVerification && (_jsxs(Stack, { gap: "xs", children: [(displayedUpiId || buyerReportedUpiId) && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Expected UPI: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: displayedUpiId || "—" }), " · ", "Buyer reported: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerReportedUpiId || "—" })] }), paymentUpiMismatch && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-error/20", surface: "danger-surface", children: _jsx(Text, { size: "xs", className: "text-error", weight: "semibold", children: "UPI mismatch \u2014 buyer-reported ID doesn't match the one shown for this order." }) }))] })), _jsxs(Text, { size: "xs", color: "muted", children: ["Buyer marked as paid: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerMarkedPaid ? "Yes" : "No" }), " · ", "Fraud agreement accepted: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerFraudAgreementAccepted ? "Yes" : "No" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Review note" }), _jsxs(Text, { size: "xs", color: "muted", children: ["Sent with ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Request re-upload" }), " or", " ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Reject as fraud" }), " below. Not saved by \u201CSave changes\u201D."] }), _jsx(Textarea, { value: reviewNote, onChange: (e) => handleReviewNoteChange(e.target.value), rows: 2, "aria-invalid": reviewNoteError ? true : undefined, placeholder: "e.g. Screenshot is blurry \u2014 amount not readable / UPI ID doesn't match order" }), reviewNoteError && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: reviewNoteError }))] }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["verify-payment"], onClick: handleVerifyPayment, isLoading: isVerifyingPayment, disabled: isVerifyingPayment, variant: "primary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["request-payment-reupload"], onClick: handleRequestReupload, isLoading: isRequestingReupload, disabled: isRequestingReupload, variant: "secondary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["reject-payment-fraud"], onClick: handleRejectFraud, isLoading: isRejectingFraud, disabled: isRejectingFraud, variant: "danger", className: "w-full" })] })), isVerified && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-success/20", surface: "success-surface", children: _jsx(Text, { size: "xs", className: "text-success", weight: "medium", children: "Payment verified" }) }))] })) : (_jsx(Text, { size: "xs", color: "faint", children: "No proof uploaded yet." }))] }))] }) }) }));
190
221
  }
@@ -75,7 +75,17 @@ export function AdminPrizeDrawsView({ children, ...props }) {
75
75
  const config = {
76
76
  portal: "admin",
77
77
  title: "Prize Draws",
78
- searchPlaceholder: "Search prize draws by name or store…",
78
+ search: {
79
+ placeholder: "Search prize draws by name, brand or tag…",
80
+ /*
81
+ * 🛑 "or store" was a promise the corpus cannot keep.
82
+ * `buildProductSearchTxt` indexes title, description, brand, brandSlug,
83
+ * category names, tags, features, condition and card/grading details —
84
+ * and NO store name or id. Typing a seller's name returned an empty list
85
+ * that reads as "this seller runs no prize draws".
86
+ */
87
+ fields: ["title", "description", "brand", "categoryNames", "tags", "features"],
88
+ },
79
89
  emptyLabel: "No prize draws found",
80
90
  filterKeys: ["status"],
81
91
  defaultSort: sortBy("createdAt", "DESC"),
@@ -1,15 +1,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- // audit-form-mutation-hook-multi-ok: saveMutation is the single combined save
4
- // across all tabs; deleteMutation is a separate destructive action, not a
5
- // second per-tab save.
6
- import { Row } from "@mohasinac/appkit/ui";
7
3
  import { useApiMutation } from "@mohasinac/appkit/client";
8
4
  import React from "react";
9
5
  import { z } from "zod";
10
6
  import { useQuery } from "@tanstack/react-query";
11
- import { Button, Card, CardBody, ConfirmDeleteModal, Div, PaginatedSelect, Form, Stack, StackedViewShell, Tabs, TabsContent, TabsList, TabsTrigger, Text, useToast, Show, StickyToolbar, } from "../../../ui";
7
+ import { Button, Card, CardBody, ConfirmDeleteModal, Div, PaginatedSelect, Stack, StackedViewShell, Tabs, TabsContent, TabsList, TabsTrigger, Text, useToast, Show, StickyToolbar, } from "../../../ui";
12
8
  import { useFormShellState, applyZodIssues, FormErrorSummary, FormShellContext } from "../../../ui/forms";
9
+ import { SectionForm, useSectionFormNav } from "../../shell";
13
10
  // Admin form-input validation — deliberately a small local schema, not the
14
11
  // broader `productItemSchema` (that one requires `id`/`price`/`status` as
15
12
  // non-optional, since it's the full read/display shape used across many
@@ -119,12 +116,6 @@ export function AdminProductEditorView({ productId, onSaved, onDeleted, embedded
119
116
  const [mode, setMode] = React.useState("standard");
120
117
  const [deleteOpen, setDeleteOpen] = React.useState(false);
121
118
  const { showToast } = useToast();
122
- const { shellCtx, setFieldError, validate } = useFormShellState(adminProductFormSchema);
123
- // Live validation — re-run on every product change, not just on submit.
124
- React.useEffect(() => {
125
- validate(product);
126
- // eslint-disable-next-line react-hooks/exhaustive-deps
127
- }, [product, validate]);
128
119
  const productQuery = useQuery({
129
120
  queryKey: ["admin", "product", productId],
130
121
  queryFn: async () => {
@@ -189,13 +180,82 @@ export function AdminProductEditorView({ productId, onSaved, onDeleted, embedded
189
180
  },
190
181
  });
191
182
  const isSubmitting = saveMutation.isPending || productQuery.isLoading;
192
- const actionSidebar = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Card, { variant: "outlined", padding: "md", spacing: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: isEdit ? product.status ?? "—" : "New" }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "submit", form: "product-editor-form", className: "w-full", isLoading: isSubmitting, disabled: !product.title || isSubmitting, children: isEdit ? "Save changes" : "Create product" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", className: "w-full", isLoading: deleteMutation.isPending, onClick: () => setDeleteOpen(true), children: "Delete product" }))] }) }));
193
- const formContent = (_jsxs(Form, { id: "product-editor-form", shellCtx: shellCtx, onSubmit: (e) => {
194
- e.preventDefault();
195
- saveMutation.mutate();
196
- }, spacing: "lg", children: [_jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Listing Type" }), _jsxs(Tabs, { value: mode, onChange: handleModeChange, children: [_jsxs(TabsList, { children: [(!enabledListingTypes || enabledListingTypes.includes("standard")) && (_jsx(TabsTrigger, { value: "standard", children: "Standard" })), (!enabledListingTypes || enabledListingTypes.includes("auction")) && (_jsx(TabsTrigger, { value: "auction", children: "Auction" })), (!enabledListingTypes || enabledListingTypes.includes("pre-order")) && (_jsx(TabsTrigger, { value: "preorder", children: "Pre-order" })), (!enabledListingTypes || enabledListingTypes.includes("prize-draw")) && (_jsx(TabsTrigger, { value: "prize-draw", children: "Prize Draw" })), (!enabledListingTypes || enabledListingTypes.includes("classified")) && (_jsx(TabsTrigger, { value: "classified", children: "Classified" })), (!enabledListingTypes || enabledListingTypes.includes("digital-code")) && (_jsx(TabsTrigger, { value: "digital-code", children: "Digital Code" })), (!enabledListingTypes || enabledListingTypes.includes("live")) && (_jsx(TabsTrigger, { value: "live", children: "Live Item" }))] }), _jsx(TabsContent, { value: "standard" }), _jsx(TabsContent, { value: "auction" }), _jsx(TabsContent, { value: "preorder" }), _jsx(TabsContent, { value: "prize-draw" }), _jsx(TabsContent, { value: "classified" }), _jsx(TabsContent, { value: "digital-code" }), _jsx(TabsContent, { value: "live" })] })] }), _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Classification" }), _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: "Store" }), _jsx(PaginatedSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct, renderGroupSettings: isEdit && productId
197
- ? (p) => (_jsx(GroupSettingsPanel, { productId: productId, productSlug: p.slug ?? productId, groupId: p.groupId, isGroupParent: p.isGroupParent, groupParentSlug: p.groupParentSlug, groupChildSlugs: p.groupChildSlugs, groupTitle: p.groupTitle, isAuction: modeFromProduct(p) === "auction", storeProductsEndpoint: ADMIN_ENDPOINTS.PRODUCTS, onGroupChanged: () => productQuery.refetch() }))
198
- : undefined, renderGroupJoinField: ({ label, value, onChange, disabled }) => (_jsx(GroupInlineSelect, { scope: "admin", value: value ?? "", onChange: (id) => onChange(id || undefined), disabled: disabled, label: label, placeholder: "None (standalone product)", allowCreate: false })), renderCategorySelector: ({ label, value, onChange, disabled }) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: label }), _jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type category name\u2026", noResultsText: "No categories found", ariaLabel: label, disabled: disabled, createLabel: "Category", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })), renderBrandSelector: (args) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: args.label }), _jsx(PaginatedSelect, { value: args.multi ? null : (args.value || null), onChange: (v) => args.onValueChange(v ?? ""), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type brand name\u2026", noResultsText: "No brands found", ariaLabel: args.label, disabled: args.disabled, createLabel: "Brand", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(BrandQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })) })] })] }), _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Inventory" }), _jsx(BarcodeField, { value: product.barcodeId ?? "", onChange: (v) => setProduct((prev) => ({ ...prev, barcodeId: v || undefined })), onScan: (v) => setProduct((prev) => ({ ...prev, barcodeId: v })), helperText: "Leave blank to auto-generate. Scan a pre-printed sticker to link it." })] }), _jsx(FormErrorSummary, {}), _jsxs(Row, { gap: "3", className: "lg:hidden", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !product.title || isSubmitting, children: isEdit ? "Save changes" : "Create product" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => setDeleteOpen(true), children: "Delete product" }))] })] }, "product-form"));
183
+ /*
184
+ * 🛑 `ProductForm` is a FIELD GROUP, not a form — its parent owns the submit,
185
+ * which is why it renders inside a section here rather than mounting a
186
+ * SectionForm of its own. `SellerProductShell` composes the same child the
187
+ * same way; nesting one SectionForm inside another would give the page two
188
+ * action rows and two mobile bars.
189
+ */
190
+ const sections = React.useMemo(() => [
191
+ {
192
+ id: "type",
193
+ label: "Listing type",
194
+ required: true,
195
+ fields: ["listingType"],
196
+ render: () => (_jsx(Stack, { gap: "md", children: _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Listing Type" }), _jsxs(Tabs, { value: mode, onChange: handleModeChange, children: [_jsxs(TabsList, { children: [(!enabledListingTypes || enabledListingTypes.includes("standard")) && (_jsx(TabsTrigger, { value: "standard", children: "Standard" })), (!enabledListingTypes || enabledListingTypes.includes("auction")) && (_jsx(TabsTrigger, { value: "auction", children: "Auction" })), (!enabledListingTypes || enabledListingTypes.includes("pre-order")) && (_jsx(TabsTrigger, { value: "preorder", children: "Pre-order" })), (!enabledListingTypes || enabledListingTypes.includes("prize-draw")) && (_jsx(TabsTrigger, { value: "prize-draw", children: "Prize Draw" })), (!enabledListingTypes || enabledListingTypes.includes("classified")) && (_jsx(TabsTrigger, { value: "classified", children: "Classified" })), (!enabledListingTypes || enabledListingTypes.includes("digital-code")) && (_jsx(TabsTrigger, { value: "digital-code", children: "Digital Code" })), (!enabledListingTypes || enabledListingTypes.includes("live")) && (_jsx(TabsTrigger, { value: "live", children: "Live Item" }))] }), _jsx(TabsContent, { value: "standard" }), _jsx(TabsContent, { value: "auction" }), _jsx(TabsContent, { value: "preorder" }), _jsx(TabsContent, { value: "prize-draw" }), _jsx(TabsContent, { value: "classified" }), _jsx(TabsContent, { value: "digital-code" }), _jsx(TabsContent, { value: "live" })] })] }) })),
197
+ },
198
+ {
199
+ id: "classification",
200
+ label: "Details",
201
+ required: true,
202
+ fields: ["title", "price", "storeId", "categorySlug", "brandSlug"],
203
+ render: () => (_jsx(Stack, { gap: "md", children: _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Classification" }), _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: "Store" }), _jsx(PaginatedSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct, renderGroupSettings: isEdit && productId
204
+ ? (p) => (_jsx(GroupSettingsPanel, { productId: productId, productSlug: p.slug ?? productId, groupId: p.groupId, isGroupParent: p.isGroupParent, groupParentSlug: p.groupParentSlug, groupChildSlugs: p.groupChildSlugs, groupTitle: p.groupTitle, isAuction: modeFromProduct(p) === "auction", storeProductsEndpoint: ADMIN_ENDPOINTS.PRODUCTS, onGroupChanged: () => productQuery.refetch() }))
205
+ : undefined, renderGroupJoinField: ({ label, value, onChange, disabled }) => (_jsx(GroupInlineSelect, { scope: "admin", value: value ?? "", onChange: (id) => onChange(id || undefined), disabled: disabled, label: label, placeholder: "None (standalone product)", allowCreate: false })), renderCategorySelector: ({ label, value, onChange, disabled }) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: label }), _jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type category name\u2026", noResultsText: "No categories found", ariaLabel: label, disabled: disabled, createLabel: "Category", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })), renderBrandSelector: (args) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: args.label }), _jsx(PaginatedSelect, { value: args.multi ? null : (args.value || null), onChange: (v) => args.onValueChange(v ?? ""), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type brand name\u2026", noResultsText: "No brands found", ariaLabel: args.label, disabled: args.disabled, createLabel: "Brand", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(BrandQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })) })] })] }) })),
206
+ },
207
+ {
208
+ id: "inventory",
209
+ label: "Inventory",
210
+ fields: ["barcodeId"],
211
+ render: () => (_jsx(Stack, { gap: "md", children: _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Inventory" }), _jsx(BarcodeField, { value: product.barcodeId ?? "", onChange: (v) => setProduct((prev) => ({ ...prev, barcodeId: v || undefined })), onScan: (v) => setProduct((prev) => ({ ...prev, barcodeId: v })), helperText: "Leave blank to auto-generate. Scan a pre-printed sticker to link it." })] }) })),
212
+ },
213
+ ], [product, mode, enabledListingTypes, isEdit, productId]);
214
+ const nav = useSectionFormNav(sections, product, { scope: "admin:product-editor" });
215
+ /*
216
+ * Declared AFTER the sections, so the error summary's jump links and the
217
+ * per-section error badges have a field map to resolve against. Wired the
218
+ * other way round it still validates and still lists the errors — it just
219
+ * cannot say which panel each one is in, which is the whole point of the
220
+ * summary on a form this long.
221
+ */
222
+ const { shellCtx, setFieldError, validate } = useFormShellState(adminProductFormSchema, {
223
+ sections: nav.sectionMeta,
224
+ onGoToSection: nav.goToSection,
225
+ fieldToSectionIndex: nav.fieldToSectionIndex,
226
+ });
227
+ // Live validation — re-run on every product change, not just on submit.
228
+ React.useEffect(() => {
229
+ validate(product);
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ }, [product, validate]);
232
+ /*
233
+ * ONE submit path. The sidebar button used `form="product-editor-form"` to
234
+ * reach across the two-panel layout into the form element; with no form
235
+ * element to associate with, both callers call this instead.
236
+ */
237
+ const handleSubmit = React.useCallback(() => {
238
+ const parsed = adminProductFormSchema.safeParse(product);
239
+ if (!parsed.success) {
240
+ applyZodIssues(parsed.error.issues, setFieldError);
241
+ return;
242
+ }
243
+ saveMutation.mutate();
244
+ }, [product, saveMutation, setFieldError]);
245
+ const actionSidebar = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Card, { variant: "outlined", padding: "md", spacing: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: isEdit ? product.status ?? "—" : "New" }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", className: "w-full", onClick: handleSubmit, isLoading: isSubmitting, disabled: !product.title || isSubmitting, children: isEdit ? "Save changes" : "Create product" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", className: "w-full", isLoading: deleteMutation.isPending, onClick: () => setDeleteOpen(true), children: "Delete product" }))] }) }));
246
+ const formContent = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(SectionForm, { sections: sections, values: product,
247
+ /*
248
+ * The sections write through `setProduct` directly - `ProductForm` and
249
+ * the type tabs each own their own state updates - so this exists only
250
+ * for anything a section chooses to route generically.
251
+ */
252
+ onChange: (partial) => setProduct((prev) => ({ ...prev, ...partial })), onSubmit: handleSubmit, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: isEdit ? "Save changes" : "Create product", isLoading: isSubmitting, destructiveAction: isEdit
253
+ ? {
254
+ label: "Delete product",
255
+ onClick: () => setDeleteOpen(true),
256
+ disabled: deleteMutation.isPending,
257
+ }
258
+ : undefined }) }));
199
259
  if (embedded) {
200
260
  return _jsx(Div, { className: `${__O.yAuto} ${__P.p4}`, children: formContent });
201
261
  }
@@ -6,9 +6,11 @@ import React from "react";
6
6
  import { useQuery, useQueryClient } from "@tanstack/react-query";
7
7
  import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
8
8
  import { CategoryInlineSelect } from "../../seller/components/CategoryInlineSelect";
9
- import { Button, Checkbox, Div, Form, FormActions, Input, Modal, Row, Select, Stack, Text, Textarea, useToast } from "../../../ui";
9
+ import { Button, Checkbox, Div, Input, Modal, Row, Select, Stack, Text, Textarea, useToast } from "../../../ui";
10
10
  import { FormErrorSummary, applyZodIssues } from "../../../ui/forms";
11
- import { homepageSectionFormSchema } from "../schemas/homepage-section-form";
11
+ import { homepageSectionFormSchema, } from "../schemas/homepage-section-form";
12
+ import { SectionForm, useSectionFormNav } from "../../shell";
13
+ import { useFormShellState, FormShellContext } from "../../../ui/forms";
12
14
  import { apiClient } from "../../../http";
13
15
  import { ADMIN_ENDPOINTS } from "../../../constants";
14
16
  import { ROUTES } from "../../../next/routing/route-map";
@@ -952,34 +954,89 @@ export function AdminSectionsView({ children }) {
952
954
  }));
953
955
  const hasReorderChanges = reorderDraft.some((item) => sectionOrderMap.get(item.id) !== item.order);
954
956
  const canUndoReorderChanges = reorderUndoStack.length > 0;
955
- return (_jsxs(_Fragment, { children: [_jsxs(Div, { paddingX: "x-sm-md", padding: "y-md", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "3", children: [_jsxs(Div, { children: [_jsx(Text, { size: "base", weight: "semibold", color: "primary", children: "Homepage Sections" }), _jsx(Text, { size: "sm", color: "muted", children: "Manage homepage sections and their display order" })] }), _jsx(Row, { align: "center", gap: "sm", children: _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => setIsModalOpen(true), children: "Manage Sections" }) })] }), 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: rows, isLoading: isLoading, emptyLabel: "No sections found" })] }), _jsxs(Stack, { className: `mt-4 ${__P.p4}`, gap: "3", rounded: "xl", surface: "default", border: "default", children: [_jsxs(Row, { align: "center", justify: "between", gap: "3", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: "Reorder Sections" }), _jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: reindexDraft, disabled: reorderSections.isPending || reorderDraft.length === 0, children: "Reindex 1..N" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: undoReorderChange, disabled: reorderSections.isPending || !canUndoReorderChanges, children: "Undo unsaved" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: resetToServerOrder, disabled: reorderSections.isPending || !hasReorderChanges, children: "Reset to server" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => reorderSections.mutate(), disabled: !hasReorderChanges || reorderSections.isPending, children: reorderSections.isPending ? "Saving order..." : "Save order" })] })] }), reorderDraft.length === 0 ? (_jsx(Text, { size: "sm", color: "muted", children: "No sections to reorder." })) : (_jsx(Stack, { gap: "sm", children: reorderDraft.map((item, index) => (_jsxs(Div, { layout: "grid", gap: "2", align: "center", className: "grid-cols-[auto_1fr_auto_auto_auto]", rounded: "md", padding: "xs", border: "default", draggable: true, onDragStart: () => setDragIndex(index), onDragOver: (event) => event.preventDefault(), onDrop: () => handleReorderDrop(index), onDragEnd: () => setDragIndex(null), children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "\u2261" }), _jsxs(Text, { size: "sm", color: "primary", children: [item.type, " #", item.order] }), _jsx(Input, { type: "number", min: 1, max: reorderDraft.length, value: String(item.order), onChange: (event) => updateReorderItemOrder(index, Number(event.target.value) || 1), className: "w-24" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => moveReorderItem(index, -1), disabled: index === 0, children: "Up" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => moveReorderItem(index, 1), disabled: index === reorderDraft.length - 1, children: "Down" })] }, `reorder-${item.id}`))) }))] }), _jsx(Modal, { isOpen: isModalOpen, onClose: () => setIsModalOpen(false), title: "Manage Homepage Section", size: "lg", children: _jsx(Form, { schema: homepageSectionFormSchema, onSubmit: (event) => event.preventDefault(), spacing: "md", children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FormErrorSummary, {}), _jsx(Select, { label: "Mode", value: mode, onValueChange: (value) => setMode(value), options: [
956
- { label: "Create new section", value: "create" },
957
- { label: "Edit existing section", value: "edit" },
958
- ] }), mode === "edit" ? (_jsx(Select, { label: "Section", value: selectedSectionId, onValueChange: setSelectedSectionId, placeholder: "Select section", options: sections.map((section) => ({
959
- label: `${section.type} (#${section.order})`,
960
- value: section.id,
961
- })) })) : null, _jsx(Select, { label: "Section type", value: sectionType, onValueChange: (value) => {
962
- setSectionType(value);
963
- if (mode === "create") {
964
- setSelectedSectionId("");
965
- }
966
- }, options: SECTION_TYPE_OPTIONS.map((type) => ({
967
- label: type,
968
- value: type,
969
- })), disabled: mode === "edit" }), _jsx(Input, { label: "Order", type: "number", value: order, onChange: (event) => setOrder(event.target.value), placeholder: "Leave empty to auto-place" }), _jsx(Select, { label: "Enabled", value: enabled ? "true" : "false", onValueChange: (value) => setEnabled(value === "true"), options: [
970
- { label: "Enabled", value: "true" },
971
- { label: "Disabled", value: "false" },
972
- ] }), isTypedBuilder ? renderTypedBuilder() : null, _jsx(Textarea, { label: isTypedBuilder ? "Generated config (JSON preview)" : "Section config (JSON)", value: configJson, onChange: (event) => setConfigJson(event.target.value), rows: 10, readOnly: isTypedBuilder, helperText: isTypedBuilder
973
- ? "This JSON is generated from typed controls above."
974
- : "Provide section config JSON for this section type." }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => setIsModalOpen(false), children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", disabled: saveSection.isPending, onClick: () => {
975
- clearErrors();
976
- const parsed = homepageSectionFormSchema.safeParse({
977
- sectionType, order, enabled, configJson,
978
- });
979
- if (!parsed.success) {
980
- applyZodIssues(parsed.error.issues, setFieldError);
981
- return;
982
- }
983
- saveSection.mutate();
984
- }, children: saveSection.isPending ? "Saving..." : mode === "create" ? "Create section" : "Update section" })] })] })) }) })] }));
957
+ /**
958
+ * The modal's fields, as three hand-written sections.
959
+ *
960
+ * The envelope (type / order / enabled) could derive from
961
+ * `homepageSectionFormSchema`, but the builder cannot: it is 22 discriminated
962
+ * shapes rendered by `renderTypedBuilder()`, and `configJson` is annotated
963
+ * `kind: "list"`, which has no control. So all three are hand-written and the
964
+ * schema stays what VALIDATES rather than what renders — the
965
+ * `AdminCategoryEditorView` arrangement.
966
+ *
967
+ * The builder section is `when`-gated on the chosen type having one, which is
968
+ * what makes it disappear for a raw-JSON section type instead of rendering an
969
+ * empty panel.
970
+ */
971
+ const modalSections = React.useMemo(() => [
972
+ {
973
+ id: "basics",
974
+ label: "Section",
975
+ required: true,
976
+ fields: ["sectionType", "order", "enabled"],
977
+ render: () => (_jsxs(Stack, { gap: "md", children: [_jsx(Select, { label: "Mode", value: mode, onValueChange: (value) => setMode(value), options: [
978
+ { label: "Create new section", value: "create" },
979
+ { label: "Edit existing section", value: "edit" },
980
+ ] }), mode === "edit" ? (_jsx(Select, { label: "Section", value: selectedSectionId, onValueChange: setSelectedSectionId, placeholder: "Select section", options: sections.map((section) => ({
981
+ label: `${section.type} (#${section.order})`,
982
+ value: section.id,
983
+ })) })) : null, _jsx(Select, { label: "Section type", value: sectionType, onValueChange: (value) => {
984
+ setSectionType(value);
985
+ if (mode === "create") {
986
+ setSelectedSectionId("");
987
+ }
988
+ }, options: SECTION_TYPE_OPTIONS.map((type) => ({
989
+ label: type,
990
+ value: type,
991
+ })), disabled: mode === "edit" }), _jsx(Input, { label: "Order", type: "number", value: order, onChange: (event) => setOrder(event.target.value), placeholder: "Leave empty to auto-place" }), _jsx(Select, { label: "Enabled", value: enabled ? "true" : "false", onValueChange: (value) => setEnabled(value === "true"), options: [
992
+ { label: "Enabled", value: "true" },
993
+ { label: "Disabled", value: "false" },
994
+ ] })] })),
995
+ },
996
+ {
997
+ id: "builder",
998
+ label: "Content",
999
+ when: () => isTypedBuilder,
1000
+ render: () => _jsx(_Fragment, { children: renderTypedBuilder() }),
1001
+ },
1002
+ {
1003
+ id: "config",
1004
+ label: isTypedBuilder ? "Generated config" : "Section config (JSON)",
1005
+ fields: ["configJson"],
1006
+ render: () => (_jsx(Textarea, { label: isTypedBuilder ? "Generated config (JSON preview)" : "Section config (JSON)", value: configJson, onChange: (event) => setConfigJson(event.target.value), rows: 10, readOnly: isTypedBuilder, helperText: isTypedBuilder
1007
+ ? "This JSON is generated from typed controls above."
1008
+ : "Provide section config JSON for this section type." })),
1009
+ },
1010
+ ], [
1011
+ mode, selectedSectionId, sections, sectionType, order, enabled,
1012
+ isTypedBuilder, configJson, renderTypedBuilder,
1013
+ ]);
1014
+ /** What the nav and the error summary key on. */
1015
+ const draft = React.useMemo(() => ({ sectionType, order, enabled, configJson }), [sectionType, order, enabled, configJson]);
1016
+ const nav = useSectionFormNav(modalSections, draft, { scope: "admin:homepage-sections" });
1017
+ const form = useFormShellState(homepageSectionFormSchema, {
1018
+ sections: nav.sectionMeta,
1019
+ onGoToSection: nav.goToSection,
1020
+ fieldToSectionIndex: nav.fieldToSectionIndex,
1021
+ });
1022
+ return (_jsxs(_Fragment, { children: [_jsxs(Div, { paddingX: "x-sm-md", padding: "y-md", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "3", children: [_jsxs(Div, { children: [_jsx(Text, { size: "base", weight: "semibold", color: "primary", children: "Homepage Sections" }), _jsx(Text, { size: "sm", color: "muted", children: "Manage homepage sections and their display order" })] }), _jsx(Row, { align: "center", gap: "sm", children: _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => setIsModalOpen(true), children: "Manage Sections" }) })] }), 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: rows, isLoading: isLoading, emptyLabel: "No sections found" })] }), _jsxs(Stack, { className: `mt-4 ${__P.p4}`, gap: "3", rounded: "xl", surface: "default", border: "default", children: [_jsxs(Row, { align: "center", justify: "between", gap: "3", children: [_jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: "Reorder Sections" }), _jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: reindexDraft, disabled: reorderSections.isPending || reorderDraft.length === 0, children: "Reindex 1..N" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: undoReorderChange, disabled: reorderSections.isPending || !canUndoReorderChanges, children: "Undo unsaved" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: resetToServerOrder, disabled: reorderSections.isPending || !hasReorderChanges, children: "Reset to server" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", onClick: () => reorderSections.mutate(), disabled: !hasReorderChanges || reorderSections.isPending, children: reorderSections.isPending ? "Saving order..." : "Save order" })] })] }), reorderDraft.length === 0 ? (_jsx(Text, { size: "sm", color: "muted", children: "No sections to reorder." })) : (_jsx(Stack, { gap: "sm", children: reorderDraft.map((item, index) => (_jsxs(Div, { layout: "grid", gap: "2", align: "center", className: "grid-cols-[auto_1fr_auto_auto_auto]", rounded: "md", padding: "xs", border: "default", draggable: true, onDragStart: () => setDragIndex(index), onDragOver: (event) => event.preventDefault(), onDrop: () => handleReorderDrop(index), onDragEnd: () => setDragIndex(null), children: [_jsx(Text, { size: "sm", weight: "semibold", color: "muted", children: "\u2261" }), _jsxs(Text, { size: "sm", color: "primary", children: [item.type, " #", item.order] }), _jsx(Input, { type: "number", min: 1, max: reorderDraft.length, value: String(item.order), onChange: (event) => updateReorderItemOrder(index, Number(event.target.value) || 1), className: "w-24" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => moveReorderItem(index, -1), disabled: index === 0, children: "Up" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => moveReorderItem(index, 1), disabled: index === reorderDraft.length - 1, children: "Down" })] }, `reorder-${item.id}`))) }))] }), _jsx(Modal, { isOpen: isModalOpen, onClose: () => setIsModalOpen(false), title: "Manage Homepage Section", size: "lg", children: _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: modalSections, values: draft,
1023
+ /*
1024
+ * Each control writes through its own setter — `setMode`,
1025
+ * `setSectionType`, `setOrder` — and several of them do more than
1026
+ * store a value (choosing a type in create mode also clears the
1027
+ * selected section). So this routes nothing generically.
1028
+ */
1029
+ onChange: () => undefined, onSubmit: () => {
1030
+ form.clearErrors();
1031
+ const parsed = homepageSectionFormSchema.safeParse(draft);
1032
+ if (!parsed.success) {
1033
+ applyZodIssues(parsed.error.issues, form.setFieldError);
1034
+ return;
1035
+ }
1036
+ saveSection.mutate();
1037
+ }, onValidationChange: () => form.validate(draft), schema: homepageSectionFormSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: saveSection.isPending
1038
+ ? "Saving..."
1039
+ : mode === "create"
1040
+ ? "Create section"
1041
+ : "Update section", cancelLabel: "Cancel", onCancel: () => setIsModalOpen(false), isLoading: saveSection.isPending })] }) })] }));
985
1042
  }