@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.
- package/dist/_internal/server/features/products/list-public.d.ts +18 -0
- package/dist/_internal/server/features/products/list-public.js +27 -9
- package/dist/client.d.ts +9 -0
- package/dist/client.js +12 -0
- package/dist/features/account/components/UserBidsView.js +6 -1
- package/dist/features/account/components/UserOrdersView.js +8 -1
- package/dist/features/account/components/UserReturnsView.js +6 -1
- package/dist/features/admin/components/AdminAdsView.js +36 -34
- package/dist/features/admin/components/AdminAuditLogView.js +12 -1
- package/dist/features/admin/components/AdminBundlesView.js +5 -1
- package/dist/features/admin/components/AdminCarouselEditorView.js +78 -10
- package/dist/features/admin/components/AdminFaqsView.js +10 -1
- package/dist/features/admin/components/AdminNotificationsView.js +7 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +55 -24
- package/dist/features/admin/components/AdminPrizeDrawsView.js +11 -1
- package/dist/features/admin/components/AdminProductEditorView.js +78 -18
- package/dist/features/admin/components/AdminSectionsView.js +89 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +96 -44
- package/dist/features/admin/components/AdminStoresView.js +13 -1
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +58 -43
- package/dist/features/admin/components/AdminSupportTicketsView.js +11 -1
- package/dist/features/admin/components/AdminTeamView.js +11 -1
- package/dist/features/admin/components/AdminTesterChecklistView.js +6 -1
- package/dist/features/admin/components/AdminUserEditorView.js +65 -82
- package/dist/features/admin/components/analytics/AdminPageViewsReportView.js +6 -1
- package/dist/features/admin/constants/admin-page-tabs.d.ts +36 -0
- package/dist/features/admin/constants/admin-page-tabs.js +45 -0
- package/dist/features/admin/schemas/admin-ops-forms.d.ts +81 -0
- package/dist/features/admin/schemas/admin-ops-forms.js +123 -1
- package/dist/features/admin/schemas/admin-user-form.d.ts +78 -3
- package/dist/features/admin/schemas/admin-user-form.js +96 -7
- package/dist/features/admin/schemas/homepage-section-form.d.ts +9 -0
- package/dist/features/layout/PageTabs.d.ts +17 -0
- package/dist/features/layout/PageTabs.js +45 -0
- package/dist/features/products/components/GroupSettingsPanel.js +62 -16
- package/dist/features/search/action-index/tab-entries.d.ts +18 -0
- package/dist/features/search/action-index/tab-entries.js +128 -0
- package/dist/features/seller/components/SellerBundlesView.js +5 -1
- package/dist/features/seller/components/SellerGoogleReviewsView.js +31 -19
- package/dist/features/seller/components/SellerOrdersView.js +57 -19
- package/dist/features/seller/components/SellerProductsView.js +10 -1
- package/dist/features/seller/components/SellerReviewsView.d.ts +2 -1
- package/dist/features/seller/components/SellerReviewsView.js +54 -19
- package/dist/features/seller/constants/store-tabs.d.ts +64 -0
- package/dist/features/seller/constants/store-tabs.js +60 -0
- package/dist/features/seller/schemas/order-forms.d.ts +33 -0
- package/dist/features/seller/schemas/order-forms.js +75 -0
- package/dist/features/seller/schemas/review-forms.d.ts +37 -0
- package/dist/features/seller/schemas/review-forms.js +82 -0
- package/dist/features/shell/index.d.ts +1 -1
- package/dist/features/shell/index.js +5 -1
- package/dist/features/shipments/components/AdminShipmentLotItemsView.js +44 -15
- package/dist/features/shipments/schemas/validation.d.ts +41 -0
- package/dist/features/shipments/schemas/validation.js +49 -0
- package/dist/features/store-extensions/schemas/google-config-form.d.ts +36 -0
- package/dist/features/store-extensions/schemas/google-config-form.js +24 -0
- package/dist/features/stores/actions/store-query-actions.js +5 -3
- package/dist/features/stores/repository/store.repository.d.ts +3 -1
- package/dist/features/stores/repository/store.repository.js +6 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +79 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +53 -22
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/react/hooks/useTabParam.d.ts +19 -0
- package/dist/react/hooks/useTabParam.js +65 -0
- package/dist/schemas/registry.d.ts +27 -3
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- 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,
|
|
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 [
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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,
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
}
|