@mohasinac/appkit 2.7.44 → 2.7.46
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/checkout/actions.js +22 -3
- package/dist/_internal/server/features/products/actions.js +7 -0
- package/dist/_internal/server/features/refunds/actions.js +44 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/features/account/components/AddressForm.js +23 -1
- package/dist/features/admin/components/AdminBlogEditorView.js +18 -11
- package/dist/features/admin/components/AdminBrandEditorView.js +18 -11
- package/dist/features/admin/components/AdminCarouselEditorView.js +18 -8
- package/dist/features/admin/components/AdminCategoryEditorView.js +18 -11
- package/dist/features/admin/components/AdminCouponEditorView.js +18 -11
- package/dist/features/admin/components/AdminFaqEditorView.js +18 -11
- package/dist/features/admin/components/AdminSublistingCategoryEditorView.js +18 -11
- package/dist/features/contact/email.d.ts +8 -0
- package/dist/features/contact/email.js +60 -0
- package/dist/features/seller/components/SellerProductShell.js +27 -3
- package/dist/features/shell/StepForm.d.ts +6 -2
- package/dist/features/shell/StepForm.js +13 -10
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/Toast.d.ts +4 -1
- package/dist/ui/components/Toast.js +3 -3
- package/dist/ui/forms/FormShell.d.ts +19 -1
- package/dist/ui/forms/FormShell.js +92 -0
- package/dist/ui/forms/index.d.ts +2 -2
- package/dist/ui/forms/index.js +1 -1
- package/package.json +2 -1
|
@@ -33,7 +33,7 @@ import { cartIsDigitalOnly } from "../../../shared/listing-types/cart-shipping";
|
|
|
33
33
|
* verifies in a micro-transaction. Fire-and-forget safe (logs on exhaustion
|
|
34
34
|
* but never fails the already-created order).
|
|
35
35
|
*/
|
|
36
|
-
async function claimDigitalCodeForOrder(db, productId, orderId, userId) {
|
|
36
|
+
async function claimDigitalCodeForOrder(db, productId, orderId, userId, opts) {
|
|
37
37
|
const codesRef = db
|
|
38
38
|
.collection(PRODUCT_COLLECTION)
|
|
39
39
|
.doc(productId)
|
|
@@ -44,6 +44,7 @@ async function claimDigitalCodeForOrder(db, productId, orderId, userId) {
|
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
46
|
const codeRef = snap.docs[0].ref;
|
|
47
|
+
let claimed = false;
|
|
47
48
|
await db.runTransaction(async (tx) => {
|
|
48
49
|
const latest = await tx.get(codeRef);
|
|
49
50
|
if (!latest.exists || latest.data()?.status !== "available")
|
|
@@ -55,7 +56,17 @@ async function claimDigitalCodeForOrder(db, productId, orderId, userId) {
|
|
|
55
56
|
claimedAt: new Date(),
|
|
56
57
|
updatedAt: new Date(),
|
|
57
58
|
});
|
|
59
|
+
claimed = true;
|
|
58
60
|
});
|
|
61
|
+
if (claimed && opts?.userEmail) {
|
|
62
|
+
const { sendDigitalCodeClaimedEmail } = await import("../../../../features/contact/server");
|
|
63
|
+
sendDigitalCodeClaimedEmail({
|
|
64
|
+
to: opts.userEmail,
|
|
65
|
+
userName: opts.userName ?? "there",
|
|
66
|
+
productTitle: opts.productTitle ?? "your product",
|
|
67
|
+
orderId,
|
|
68
|
+
}).catch((e) => serverLogger.error("claimDigitalCode: email failed", e));
|
|
69
|
+
}
|
|
59
70
|
}
|
|
60
71
|
/**
|
|
61
72
|
* SB-UNI-O — Live-item jurisdiction guard.
|
|
@@ -503,7 +514,11 @@ export async function createCheckoutOrderAction(input) {
|
|
|
503
514
|
// SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
|
|
504
515
|
// order is already persisted so a claim failure is logged, not thrown).
|
|
505
516
|
if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
|
|
506
|
-
claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid
|
|
517
|
+
claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid, {
|
|
518
|
+
userEmail: userEmail || undefined,
|
|
519
|
+
userName,
|
|
520
|
+
productTitle: firstItem.productTitle,
|
|
521
|
+
}).catch((e) => serverLogger.error("claimDigitalCode", e));
|
|
507
522
|
}
|
|
508
523
|
for (const code of groupCouponCodes) {
|
|
509
524
|
const entry = couponUsageAccumulator.get(code);
|
|
@@ -878,7 +893,11 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
878
893
|
// SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
|
|
879
894
|
// order is already persisted so a claim failure is logged, not thrown).
|
|
880
895
|
if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
|
|
881
|
-
claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid
|
|
896
|
+
claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid, {
|
|
897
|
+
userEmail: userEmail || undefined,
|
|
898
|
+
userName,
|
|
899
|
+
productTitle: firstItem.productTitle,
|
|
900
|
+
}).catch((e) => serverLogger.error("claimDigitalCode", e));
|
|
882
901
|
}
|
|
883
902
|
if (userEmail) {
|
|
884
903
|
emailsToSend.push({
|
|
@@ -63,6 +63,13 @@ export async function setProductStatusAction(input) {
|
|
|
63
63
|
throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
|
|
64
64
|
const product = await assertProductOwnership(parsed.data.productId, user.uid);
|
|
65
65
|
assertStatusTransition(product.status, parsed.data.status);
|
|
66
|
+
// SB-UNI-O — live listings must be admin-verified before a seller can publish them.
|
|
67
|
+
if (parsed.data.status === "published" &&
|
|
68
|
+
(product.listingType ?? "standard") === "live" &&
|
|
69
|
+
!product.liveItem?.vendorVerified &&
|
|
70
|
+
user.role !== "admin") {
|
|
71
|
+
throw new ValidationError("Live listings require admin verification before publishing. Contact support to request verification.");
|
|
72
|
+
}
|
|
66
73
|
return productRepository.update(parsed.data.productId, { status: parsed.data.status });
|
|
67
74
|
}
|
|
68
75
|
export async function setProductFeaturedAction(input) {
|
|
@@ -17,7 +17,10 @@ import { randomUUID } from "crypto";
|
|
|
17
17
|
import { getProviders } from "../../../../contracts/registry";
|
|
18
18
|
import { orderRepository } from "../../../..";
|
|
19
19
|
import { NotFoundError, ValidationError } from "../../../../errors";
|
|
20
|
+
import { serverLogger } from "../../../../monitoring";
|
|
20
21
|
import { applyRefundDeductionAction } from "../payouts/actions";
|
|
22
|
+
import { getAdminDb } from "../../../../providers/db-firebase";
|
|
23
|
+
import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore";
|
|
21
24
|
export async function processRefundAction(input) {
|
|
22
25
|
const order = await orderRepository.findById(input.orderId);
|
|
23
26
|
if (!order)
|
|
@@ -31,6 +34,26 @@ export async function processRefundAction(input) {
|
|
|
31
34
|
if (order.isNonRefundable) {
|
|
32
35
|
throw new ValidationError("This order is marked non-refundable and cannot be refunded.");
|
|
33
36
|
}
|
|
37
|
+
// SB-UNI-N — digital-code refund gate: block if code is already claimed (revealed).
|
|
38
|
+
const digitalCodeItems = (order.items ?? []).filter((i) => (i.listingType ?? "standard") === "digital-code");
|
|
39
|
+
if (digitalCodeItems.length > 0) {
|
|
40
|
+
const db = getAdminDb();
|
|
41
|
+
for (const item of digitalCodeItems) {
|
|
42
|
+
const codesSnap = await db
|
|
43
|
+
.collection(PRODUCT_COLLECTION)
|
|
44
|
+
.doc(item.productId)
|
|
45
|
+
.collection(PRODUCT_CODES_SUBCOLLECTION)
|
|
46
|
+
.where("orderId", "==", input.orderId)
|
|
47
|
+
.limit(1)
|
|
48
|
+
.get();
|
|
49
|
+
if (!codesSnap.empty) {
|
|
50
|
+
const codeStatus = codesSnap.docs[0].data()?.status;
|
|
51
|
+
if (codeStatus === "claimed") {
|
|
52
|
+
throw new ValidationError("The digital code for this order has already been revealed and cannot be refunded automatically. Please contact support.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
34
57
|
const refundId = randomUUID();
|
|
35
58
|
const now = new Date();
|
|
36
59
|
let razorpayRefundId;
|
|
@@ -62,6 +85,27 @@ export async function processRefundAction(input) {
|
|
|
62
85
|
: {}),
|
|
63
86
|
};
|
|
64
87
|
await orderRepository.postRefundEvent(input.orderId, event, isFull);
|
|
88
|
+
// SB-UNI-N — revoke any available (unclaimed) digital codes linked to this order.
|
|
89
|
+
if (digitalCodeItems.length > 0) {
|
|
90
|
+
const db = getAdminDb();
|
|
91
|
+
for (const item of digitalCodeItems) {
|
|
92
|
+
db.collection(PRODUCT_COLLECTION)
|
|
93
|
+
.doc(item.productId)
|
|
94
|
+
.collection(PRODUCT_CODES_SUBCOLLECTION)
|
|
95
|
+
.where("orderId", "==", input.orderId)
|
|
96
|
+
.where("status", "==", "available")
|
|
97
|
+
.limit(1)
|
|
98
|
+
.get()
|
|
99
|
+
.then((snap) => {
|
|
100
|
+
if (!snap.empty) {
|
|
101
|
+
snap.docs[0].ref
|
|
102
|
+
.update({ status: "revoked", updatedAt: new Date() })
|
|
103
|
+
.catch((e) => serverLogger.error("refund: code revoke failed", e));
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
.catch((e) => serverLogger.error("refund: code query failed", e));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
65
109
|
// Fire-and-forget: deduct from the store's pending payout if one exists.
|
|
66
110
|
// Failure here must not roll back the already-posted refund event.
|
|
67
111
|
if (order.storeId) {
|
package/dist/client.d.ts
CHANGED
|
@@ -57,8 +57,8 @@ export { OtpInput } from "./ui/components/OtpInput";
|
|
|
57
57
|
export type { OtpInputProps } from "./ui/components/OtpInput";
|
|
58
58
|
export { DateInput, DateRangeInput } from "./ui/components/DateInput";
|
|
59
59
|
export type { DateInputProps, DateRangeInputProps } from "./ui/components/DateInput";
|
|
60
|
-
export type { FormShellProps, FormShellStep, FormShellContextValue } from "./ui/forms";
|
|
61
|
-
export { FormShell, useFormShell } from "./ui/forms";
|
|
60
|
+
export type { FormShellProps, FormShellProviderProps, FormShellStep, FormShellContextValue, UseFormShellStateResult } from "./ui/forms";
|
|
61
|
+
export { FormShell, FormShellProvider, FormShellContext, useFormShell, useFormShellState } from "./ui/forms";
|
|
62
62
|
export type { FieldInputProps } from "./ui/forms";
|
|
63
63
|
export { FieldInput } from "./ui/forms";
|
|
64
64
|
export type { FieldSelectProps } from "./ui/forms";
|
package/dist/client.js
CHANGED
|
@@ -123,7 +123,7 @@ export { Checkbox } from "./ui/components/Checkbox";
|
|
|
123
123
|
export { Input } from "./ui/components/Input";
|
|
124
124
|
export { OtpInput } from "./ui/components/OtpInput";
|
|
125
125
|
export { DateInput, DateRangeInput } from "./ui/components/DateInput";
|
|
126
|
-
export { FormShell, useFormShell } from "./ui/forms";
|
|
126
|
+
export { FormShell, FormShellProvider, FormShellContext, useFormShell, useFormShellState } from "./ui/forms";
|
|
127
127
|
export { FieldInput } from "./ui/forms";
|
|
128
128
|
export { FieldSelect } from "./ui/forms";
|
|
129
129
|
export { FieldCheckbox } from "./ui/forms";
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { Button, Checkbox, FormField, FormGroup } from "../../../ui";
|
|
5
|
+
import { useFormShellState } from "../../../ui/forms";
|
|
5
6
|
import { THEME_CONSTANTS } from "../../../tokens";
|
|
6
7
|
const DEFAULT_LABELS = {
|
|
7
8
|
label: "Address Label",
|
|
@@ -33,6 +34,7 @@ export function AddressForm({ initialData, onSubmit, onCancel, isLoading = false
|
|
|
33
34
|
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
|
|
34
35
|
const mergedPlaceholders = { ...DEFAULT_PLACEHOLDERS, ...placeholders };
|
|
35
36
|
const effectiveSubmitLabel = submitLabel ?? mergedLabels.save;
|
|
37
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState();
|
|
36
38
|
const [formData, setFormData] = useState({
|
|
37
39
|
label: initialData?.label || "",
|
|
38
40
|
fullName: initialData?.fullName || "",
|
|
@@ -47,10 +49,30 @@ export function AddressForm({ initialData, onSubmit, onCancel, isLoading = false
|
|
|
47
49
|
});
|
|
48
50
|
const handleChange = (field, value) => {
|
|
49
51
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
52
|
+
if (typeof value === "string" && value.trim())
|
|
53
|
+
setFieldError(field, null);
|
|
50
54
|
};
|
|
51
55
|
const handleSubmit = async (e) => {
|
|
52
56
|
e.preventDefault();
|
|
57
|
+
clearErrors();
|
|
58
|
+
const errs = {};
|
|
59
|
+
if (!formData.fullName.trim())
|
|
60
|
+
errs["fullName"] = "Full name is required";
|
|
61
|
+
if (!formData.phone.trim())
|
|
62
|
+
errs["phone"] = "Phone number is required";
|
|
63
|
+
if (!formData.addressLine1.trim())
|
|
64
|
+
errs["addressLine1"] = "Address is required";
|
|
65
|
+
if (!formData.city.trim())
|
|
66
|
+
errs["city"] = "City is required";
|
|
67
|
+
if (!formData.state.trim())
|
|
68
|
+
errs["state"] = "State is required";
|
|
69
|
+
if (!formData.postalCode.trim())
|
|
70
|
+
errs["postalCode"] = "Postal code is required";
|
|
71
|
+
if (Object.keys(errs).length > 0) {
|
|
72
|
+
Object.entries(errs).forEach(([k, v]) => setFieldError(k, v));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
53
75
|
await onSubmit(formData);
|
|
54
76
|
};
|
|
55
|
-
return (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsx(FormField, { label: mergedLabels.label, name: "label", type: "text", value: formData.label, onChange: (value) => handleChange("label", value), placeholder: mergedPlaceholders.label, required: true }), _jsx(FormField, { label: mergedLabels.fullName, name: "fullName", type: "text", value: formData.fullName, onChange: (value) => handleChange("fullName", value), placeholder: mergedPlaceholders.fullName, required: true }), _jsx(FormField, { label: mergedLabels.phone, name: "phone", type: "tel", value: formData.phone, onChange: (value) => handleChange("phone", value), placeholder: mergedPlaceholders.phone, required: true }), _jsx(FormField, { label: mergedLabels.addressLine1, name: "addressLine1", type: "text", value: formData.addressLine1, onChange: (value) => handleChange("addressLine1", value), placeholder: mergedPlaceholders.addressLine1, required: true }), _jsx(FormField, { label: mergedLabels.addressLine2, name: "addressLine2", type: "text", value: formData.addressLine2, onChange: (value) => handleChange("addressLine2", value), placeholder: mergedPlaceholders.addressLine2 }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { label: mergedLabels.city, name: "city", type: "text", value: formData.city, onChange: (value) => handleChange("city", value), placeholder: mergedPlaceholders.city, required: true }), _jsx(FormField, { label: mergedLabels.state, name: "state", type: "text", value: formData.state, onChange: (value) => handleChange("state", value), placeholder: mergedPlaceholders.state, required: true }), _jsx(FormField, { label: mergedLabels.postalCode, name: "postalCode", type: "text", value: formData.postalCode, onChange: (value) => handleChange("postalCode", value), placeholder: mergedPlaceholders.postalCode, required: true })] }), _jsx(FormField, { label: mergedLabels.country, name: "country", type: "text", value: formData.country, onChange: (value) => handleChange("country", value), placeholder: mergedPlaceholders.country, required: true }), _jsx(Checkbox, { checked: formData.isDefault, onChange: (e) => handleChange("isDefault", e.target.checked), label: mergedLabels.setDefault }), _jsxs("div", { className: `flex items-center justify-start ${THEME_CONSTANTS.spacing.gap.xs} pt-2`, "data-section": "addressform-div-223", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isLoading, children: mergedLabels.cancel }), _jsx(Button, { type: "submit", variant: "primary", disabled: isLoading, children: isLoading ? mergedLabels.loading : effectiveSubmitLabel })] })] }));
|
|
77
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsx(FormField, { label: mergedLabels.label, name: "label", type: "text", value: formData.label, onChange: (value) => handleChange("label", value), placeholder: mergedPlaceholders.label, required: true }), _jsx(FormField, { label: mergedLabels.fullName, name: "fullName", type: "text", value: formData.fullName, onChange: (value) => handleChange("fullName", value), placeholder: mergedPlaceholders.fullName, required: true, error: shellCtx.errors["fullName"] }), _jsx(FormField, { label: mergedLabels.phone, name: "phone", type: "tel", value: formData.phone, onChange: (value) => handleChange("phone", value), placeholder: mergedPlaceholders.phone, required: true, error: shellCtx.errors["phone"] }), _jsx(FormField, { label: mergedLabels.addressLine1, name: "addressLine1", type: "text", value: formData.addressLine1, onChange: (value) => handleChange("addressLine1", value), placeholder: mergedPlaceholders.addressLine1, required: true, error: shellCtx.errors["addressLine1"] }), _jsx(FormField, { label: mergedLabels.addressLine2, name: "addressLine2", type: "text", value: formData.addressLine2, onChange: (value) => handleChange("addressLine2", value), placeholder: mergedPlaceholders.addressLine2 }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { label: mergedLabels.city, name: "city", type: "text", value: formData.city, onChange: (value) => handleChange("city", value), placeholder: mergedPlaceholders.city, required: true, error: shellCtx.errors["city"] }), _jsx(FormField, { label: mergedLabels.state, name: "state", type: "text", value: formData.state, onChange: (value) => handleChange("state", value), placeholder: mergedPlaceholders.state, required: true, error: shellCtx.errors["state"] }), _jsx(FormField, { label: mergedLabels.postalCode, name: "postalCode", type: "text", value: formData.postalCode, onChange: (value) => handleChange("postalCode", value), placeholder: mergedPlaceholders.postalCode, required: true, error: shellCtx.errors["postalCode"] })] }), _jsx(FormField, { label: mergedLabels.country, name: "country", type: "text", value: formData.country, onChange: (value) => handleChange("country", value), placeholder: mergedPlaceholders.country, required: true }), _jsx(Checkbox, { checked: formData.isDefault, onChange: (e) => handleChange("isDefault", e.target.checked), label: mergedLabels.setDefault }), _jsxs("div", { className: `flex items-center justify-start ${THEME_CONSTANTS.spacing.gap.xs} pt-2`, "data-section": "addressform-div-223", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isLoading, children: mergedLabels.cancel }), _jsx(Button, { type: "submit", variant: "primary", disabled: isLoading, children: isLoading ? mergedLabels.loading : effectiveSubmitLabel })] })] }));
|
|
56
78
|
}
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
5
5
|
import { Button, Form, Input, RichTextEditor, Select, StackedViewShell, TagInput, Text, Toggle, useToast } from "../../../ui";
|
|
6
|
+
import { FieldInput, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
6
7
|
import { ImageUpload } from "../../media/upload/ImageUpload";
|
|
7
8
|
import { useMediaUpload } from "../../media";
|
|
8
9
|
import { apiClient } from "../../../http";
|
|
@@ -57,6 +58,7 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
57
58
|
const [metaDescription, setMetaDescription] = React.useState("");
|
|
58
59
|
const { showToast } = useToast();
|
|
59
60
|
const { upload } = useMediaUpload();
|
|
61
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState();
|
|
60
62
|
// --- load existing post (edit mode) ---
|
|
61
63
|
const postQuery = useQuery({
|
|
62
64
|
queryKey: ["admin", "blog", postId],
|
|
@@ -146,17 +148,22 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
146
148
|
});
|
|
147
149
|
const isSubmitting = saveMutation.isPending || postQuery.isLoading;
|
|
148
150
|
const canSave = Boolean(title);
|
|
149
|
-
const formSection = (_jsxs(Form, { onSubmit: (e) => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
151
|
+
const formSection = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Form, { onSubmit: (e) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
clearErrors();
|
|
154
|
+
if (!title.trim()) {
|
|
155
|
+
setFieldError("title", "Title is required");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
saveMutation.mutate();
|
|
159
|
+
}, className: "space-y-5", children: [_jsx(FieldInput, { name: "title", label: "Title", value: title, onChange: (v) => handleTitleChange(v), required: true, placeholder: "e.g. How to Grade Pok\u00E9mon Cards" }), _jsx(Input, { label: "Slug", value: slug, onChange: (e) => {
|
|
160
|
+
setSlug(e.target.value);
|
|
161
|
+
setSlugManual(true);
|
|
162
|
+
}, placeholder: "blog-how-to-grade-pokemon-cards", helperText: "Auto-generated from title. Must start with 'blog-'." }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsx(Select, { label: "Category", options: CATEGORY_OPTIONS, value: category, onValueChange: (v) => setCategory(v) }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: (v) => setStatus(v) })] }), _jsx(Input, { label: "Excerpt", value: excerpt, onChange: (e) => setExcerpt(e.target.value), placeholder: "Short summary shown in listings and cards" }), _jsx(ImageUpload, { label: "Cover image", currentImage: coverImage, onUpload: (file) => upload(file, "blog", true, { type: "blog-cover", title: title || slug, category }), onChange: setCoverImage }), _jsxs("div", { className: "space-y-1", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Content" }), _jsx(RichTextEditor, { value: content, onChange: setContent, placeholder: "Write your article here...", minHeightClassName: "min-h-[320px]" })] }), _jsx(TagInput, { label: "Tags", value: tags, onChange: setTags, placeholder: "e.g. pokemon, grading, tcg" }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsx(Input, { label: "Author name", value: authorName, onChange: (e) => setAuthorName(e.target.value), placeholder: "Author display name" }), _jsx(Input, { label: "Publish date (optional)", value: publishedAt, onChange: (e) => setPublishedAt(e.target.value), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Toggle, { label: "Featured post", checked: isFeatured, onChange: setIsFeatured }), _jsx(Input, { label: "Meta title (optional)", value: metaTitle, onChange: (e) => setMetaTitle(e.target.value), placeholder: "Defaults to post title" }), _jsx(Input, { label: "Meta description (optional)", value: metaDescription, onChange: (e) => setMetaDescription(e.target.value), placeholder: "SEO description \u2014 max 160 chars", maxLength: 160 }), _jsxs("div", { className: "flex gap-3 pt-2", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !canSave || isSubmitting, children: isEdit ? "Save changes" : "Create post" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => {
|
|
163
|
+
if (confirm("Delete this post? This cannot be undone.")) {
|
|
164
|
+
deleteMutation.mutate();
|
|
165
|
+
}
|
|
166
|
+
}, children: "Delete post" }))] })] }, "blog-form") }));
|
|
160
167
|
if (embedded) {
|
|
161
168
|
return _jsx("div", { className: "overflow-y-auto p-4", children: formSection });
|
|
162
169
|
}
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
5
5
|
import { Button, Form, Input, StackedViewShell, Toggle, useToast, } from "../../../ui";
|
|
6
|
+
import { FieldInput, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
6
7
|
import { ImageUpload } from "../../media/upload/ImageUpload";
|
|
7
8
|
import { useMediaUpload } from "../../media";
|
|
8
9
|
import { apiClient } from "../../../http";
|
|
@@ -23,6 +24,7 @@ export function AdminBrandEditorView({ brandId, onSaved, onDeleted, embedded, ..
|
|
|
23
24
|
const [isActive, setIsActive] = React.useState(true);
|
|
24
25
|
const [displayOrder, setDisplayOrder] = React.useState("");
|
|
25
26
|
const { showToast } = useToast();
|
|
27
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState();
|
|
26
28
|
const brandQuery = useQuery({
|
|
27
29
|
queryKey: ["admin", "brand", brandId],
|
|
28
30
|
queryFn: async () => {
|
|
@@ -88,17 +90,22 @@ export function AdminBrandEditorView({ brandId, onSaved, onDeleted, embedded, ..
|
|
|
88
90
|
});
|
|
89
91
|
const { upload } = useMediaUpload();
|
|
90
92
|
const isSubmitting = saveMutation.isPending || brandQuery.isLoading;
|
|
91
|
-
const formSection = (_jsxs(Form, { onSubmit: (e) => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
const formSection = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Form, { onSubmit: (e) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
clearErrors();
|
|
96
|
+
if (!name.trim()) {
|
|
97
|
+
setFieldError("name", "Brand name is required");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
saveMutation.mutate();
|
|
101
|
+
}, className: "space-y-4", children: [_jsxs("div", { className: "grid sm:grid-cols-2 gap-4", children: [_jsx(FieldInput, { name: "name", label: "Brand name", value: name, onChange: (v) => handleNameChange(v), required: true, placeholder: "e.g. Hot Wheels" }), _jsx(Input, { label: "Slug", value: slug, onChange: (e) => {
|
|
102
|
+
setSlug(e.target.value);
|
|
103
|
+
setSlugManual(true);
|
|
104
|
+
}, placeholder: "brand-hot-wheels", helperText: "Auto-generated from name. Must start with 'brand-'." })] }), _jsx(Input, { label: "Description", value: description, onChange: (e) => setDescription(e.target.value), placeholder: "Brief description of the brand" }), _jsxs("div", { className: "grid sm:grid-cols-2 gap-4", children: [_jsx(ImageUpload, { label: "Logo", currentImage: logoURL, onUpload: (file) => upload(file, "brands", true, { type: "brand-logo", brand: name || slug }), onChange: setLogoURL }), _jsx(ImageUpload, { label: "Banner", currentImage: bannerURL, onUpload: (file) => upload(file, "brands", true, { type: "brand-banner", brand: name || slug }), onChange: setBannerURL })] }), _jsxs("div", { className: "grid sm:grid-cols-2 gap-4", children: [_jsx(Input, { label: "Website", value: website, onChange: (e) => setWebsite(e.target.value), placeholder: "https://brand.com", type: "url" }), _jsx(Input, { label: "Display order", value: displayOrder, onChange: (e) => setDisplayOrder(e.target.value), type: "number", min: 0, placeholder: "0" })] }), _jsx(Toggle, { label: "Active", checked: isActive, onChange: setIsActive }), _jsxs("div", { className: "flex gap-3 pt-2", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !name || isSubmitting, children: isEdit ? "Save changes" : "Create brand" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => {
|
|
105
|
+
if (confirm("Delete this brand? This cannot be undone.")) {
|
|
106
|
+
deleteMutation.mutate();
|
|
107
|
+
}
|
|
108
|
+
}, children: "Delete brand" }))] })] }, "brand-form") }));
|
|
102
109
|
if (embedded) {
|
|
103
110
|
return _jsx("div", { className: "overflow-y-auto p-4", children: formSection });
|
|
104
111
|
}
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
5
5
|
import { Alert, Button, Form, FormActions, Input, Select, StackedViewShell, Toggle, Div, Text, Heading, Row, } from "../../../ui";
|
|
6
|
+
import { FieldInput, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
6
7
|
import { apiClient } from "../../../http";
|
|
7
8
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
9
|
const CLS_PANEL = "rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-4 space-y-3";
|
|
@@ -103,6 +104,7 @@ export function AdminCarouselEditorView({ slideId, onSaved, onDeleted, onCancel,
|
|
|
103
104
|
const [overlayBtnNewTab, setOverlayBtnNewTab] = React.useState(false);
|
|
104
105
|
const [errorMsg, setErrorMsg] = React.useState("");
|
|
105
106
|
const [successMsg, setSuccessMsg] = React.useState("");
|
|
107
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState();
|
|
106
108
|
const slideQuery = useQuery({
|
|
107
109
|
queryKey: ["admin", "carousel", slideId],
|
|
108
110
|
queryFn: async () => {
|
|
@@ -195,13 +197,21 @@ export function AdminCarouselEditorView({ slideId, onSaved, onDeleted, onCancel,
|
|
|
195
197
|
return (_jsx(StackedViewShell, { portal: "admin", ...rest, title: isEdit ? "Edit Carousel Slide" : "New Carousel Slide", sections: [
|
|
196
198
|
errorMsg ? _jsx(Alert, { variant: "error", children: errorMsg }, "err") : null,
|
|
197
199
|
successMsg ? _jsx(Alert, { variant: "success", children: successMsg }, "ok") : null,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Form, { onSubmit: (e) => {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
clearErrors();
|
|
203
|
+
if (!title.trim()) {
|
|
204
|
+
setFieldError("title", "Title is required");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
saveMutation.mutate();
|
|
208
|
+
}, className: "space-y-6", children: [_jsxs(Div, { className: CLS_PANEL, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Slide info" }), _jsx(FieldInput, { name: "title", label: "Title", value: title, onChange: (v) => setTitle(v), required: true, placeholder: "e.g. Hot Wheels RLC Exclusives" }), _jsx(Toggle, { label: "Active (visible on homepage)", checked: active, onChange: setActive }), _jsx(Input, { label: "Display order", type: "number", value: order, onChange: (e) => setOrder(e.target.value), min: 0, placeholder: "1" }), _jsx(Select, { label: "Slide height", value: height, onChange: (e) => setHeight(e.target.value), options: HEIGHT_OPTIONS }), _jsx(Input, { label: "Autoplay delay (ms)", type: "number", value: autoplayDelayMs, onChange: (e) => setAutoplayDelayMs(e.target.value), min: 1000, max: 15000, placeholder: "4000", helperText: "How long this slide shows before auto-advancing. Default: 4000 ms." })] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Background" }), _jsx(BackgroundEditor, { value: background, onChange: setBackground, prefix: "slide" })] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Overlay text (optional)" }), _jsx(Text, { className: "text-sm text-zinc-500", children: "Centred text layered over the background. Leave blank to use cards only." }), _jsx(Input, { label: "Heading", value: overlayTitle, onChange: (e) => setOverlayTitle(e.target.value), placeholder: "India's #1 Collectibles Marketplace" }), _jsx(Input, { label: "Subtitle", value: overlaySubtitle, onChange: (e) => setOverlaySubtitle(e.target.value), placeholder: "Pok\u00E9mon TCG \u00B7 Hot Wheels \u00B7 Beyblade X" }), _jsx(Input, { label: "Description", value: overlayDesc, onChange: (e) => setOverlayDesc(e.target.value), placeholder: "One sentence description..." }), _jsxs(Div, { className: "rounded-lg border border-zinc-200 dark:border-zinc-800 p-3 space-y-2", children: [_jsx(Text, { className: "text-sm font-medium", children: "CTA button" }), _jsx(Input, { label: "Button text", value: overlayBtnText, onChange: (e) => setOverlayBtnText(e.target.value), placeholder: "Shop Now" }), _jsx(Input, { label: "Button link", value: overlayBtnLink, onChange: (e) => setOverlayBtnLink(e.target.value), placeholder: "/products" }), _jsx(Select, { label: "Variant", value: overlayBtnVariant, onChange: (e) => setOverlayBtnVariant(e.target.value), options: VARIANT_OPTIONS.filter((v) => ["primary", "secondary", "outline"].includes(v.value)) }), _jsx(Toggle, { label: "Open in new tab", checked: overlayBtnNewTab, onChange: setOverlayBtnNewTab })] })] }), _jsxs(Div, { className: CLS_PANEL, children: [_jsxs(Row, { className: CLS_ROW_BETWEEN, children: [_jsx(Heading, { level: 3, className: CLS_SECTION_HEADING, children: "Cards (0\u20132)" }), cards.length < 2 && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
209
|
+
const usedZones = cards.map((c) => c.zone);
|
|
210
|
+
const freeZone = [1, 2, 3, 4, 5, 6].find((z) => !usedZones.includes(z)) ?? 1;
|
|
211
|
+
setCards([...cards, makeCard(freeZone)]);
|
|
212
|
+
}, children: "+ Add card" }))] }), cards.length === 0 && (_jsx(Text, { className: "text-sm text-zinc-400", children: "No cards \u2014 the overlay text covers the whole slide." })), cards.map((card, i) => (_jsx(CardEditor, { card: card, index: i, otherZones: occupiedZones.filter((z) => z !== card.zone), onChange: (updated) => setCards(cards.map((c, ci) => (ci === i ? updated : c))), onRemove: () => setCards(cards.filter((_, ci) => ci !== i)) }, card.id)))] }), _jsxs(FormActions, { children: [_jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !title || saveMutation.isPending, children: isEdit ? "Save changes" : "Create slide" }), onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, children: "Cancel" })), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => {
|
|
213
|
+
if (confirm("Delete this slide? This cannot be undone."))
|
|
214
|
+
deleteMutation.mutate();
|
|
215
|
+
}, children: "Delete slide" }))] })] }, "slide-form") }),
|
|
206
216
|
] }));
|
|
207
217
|
}
|
|
@@ -6,6 +6,7 @@ import { Button, Card, CardBody, ConfirmDeleteModal, Form, InlineCreateSelect, I
|
|
|
6
6
|
import { apiClient } from "../../../http";
|
|
7
7
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
8
|
import { CategoryQuickCreateForm } from "./CategoryQuickCreateForm";
|
|
9
|
+
import { FieldInput, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
9
10
|
async function loadCategoryOptions(query, page) {
|
|
10
11
|
const params = new URLSearchParams({ page: String(page), pageSize: "25" });
|
|
11
12
|
if (query)
|
|
@@ -38,6 +39,7 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
38
39
|
const [isActive, setIsActive] = React.useState(true);
|
|
39
40
|
const [showInMenu, setShowInMenu] = React.useState(true);
|
|
40
41
|
const { showToast } = useToast();
|
|
42
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState();
|
|
41
43
|
const categoryQuery = useQuery({
|
|
42
44
|
queryKey: ["admin", "category", categoryId],
|
|
43
45
|
queryFn: async () => {
|
|
@@ -101,17 +103,22 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
101
103
|
});
|
|
102
104
|
const isSubmitting = saveMutation.isPending || categoryQuery.isLoading;
|
|
103
105
|
const actionSidebar = (_jsxs(Card, { variant: "outlined", padding: "md", className: "space-y-3", children: [_jsx(Text, { className: "text-xs font-semibold uppercase tracking-widest text-zinc-500 dark:text-zinc-400", children: "Status" }), _jsx(Text, { className: "text-sm text-[var(--appkit-color-text-muted)]", children: isEdit ? (isActive ? "Active" : "Inactive") : "New" }), _jsx(Button, { type: "submit", form: "category-editor-form", className: "w-full", isLoading: isSubmitting, disabled: !name || isSubmitting, children: isEdit ? "Save changes" : "Create category" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", className: "w-full", isLoading: deleteMutation.isPending, onClick: () => setDeleteOpen(true), children: "Delete category" }))] }));
|
|
104
|
-
const formContent = (_jsxs(Form, { id: "category-editor-form", onSubmit: (e) => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
106
|
+
const formContent = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Form, { id: "category-editor-form", onSubmit: (e) => {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
clearErrors();
|
|
109
|
+
if (!name.trim()) {
|
|
110
|
+
setFieldError("name", "Category name is required");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
saveMutation.mutate();
|
|
114
|
+
}, className: "space-y-6", children: [_jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: "text-xs font-semibold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 mb-4", children: "Identity" }), _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "grid sm:grid-cols-2 gap-4", children: [_jsx(FieldInput, { name: "name", label: "Category name", value: name, onChange: (v) => handleNameChange(v), required: true, placeholder: "e.g. Toys & Games" }), _jsx(Input, { label: "Slug", value: slug, onChange: (e) => {
|
|
115
|
+
setSlug(e.target.value);
|
|
116
|
+
setSlugManual(true);
|
|
117
|
+
}, placeholder: "toys-and-games", helperText: "Auto-generated from name. Used in URLs." })] }), _jsx(Input, { label: "Description", value: description, onChange: (e) => setDescription(e.target.value), placeholder: "Brief description of the category" }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Parent category" }), _jsx(InlineCreateSelect, { value: parentId || null, onChange: (v) => setParentId(v ?? ""), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026 (leave empty for root)", searchPlaceholder: "Type category name\u2026", noResultsText: "No categories found", ariaLabel: "Parent category", createLabel: "Category", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, n) => { setParentId(id); onCreated({ value: id, label: n }); }, onCancel: onCancel })) }), _jsx(Text, { className: "text-xs text-neutral-500 dark:text-neutral-400", children: "Leave empty to create a root category." })] })] })] }), _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: "text-xs font-semibold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 mb-4", children: "Display" }), _jsxs("div", { className: "space-y-4", children: [_jsx(Input, { label: "Display order", value: order, onChange: (e) => setOrder(e.target.value), type: "number", min: 0, placeholder: "0" }), _jsx(Toggle, { label: "Active", checked: isActive, onChange: setIsActive }), _jsx(Toggle, { label: "Show in menu", checked: showInMenu, onChange: setShowInMenu })] })] }), _jsxs("div", { className: "flex gap-3 lg:hidden", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !name || isSubmitting, children: isEdit ? "Save changes" : "Create category" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => {
|
|
118
|
+
if (confirm("Delete this category? Products in this category will become uncategorized.")) {
|
|
119
|
+
deleteMutation.mutate();
|
|
120
|
+
}
|
|
121
|
+
}, children: "Delete category" }))] })] }, "cat-form") }));
|
|
115
122
|
if (embedded) {
|
|
116
123
|
return _jsx("div", { className: "overflow-y-auto p-4", children: formContent });
|
|
117
124
|
}
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
5
5
|
import { Button, Form, Input, Select, StackedViewShell, Toggle, useToast, } from "../../../ui";
|
|
6
|
+
import { FieldInput, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
6
7
|
import { apiClient } from "../../../http";
|
|
7
8
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
9
|
const TYPE_OPTIONS = [
|
|
@@ -60,6 +61,7 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
|
|
|
60
61
|
const [combinable, setCombinable] = React.useState(false);
|
|
61
62
|
const [appliesToAuctions, setAppliesToAuctions] = React.useState(false);
|
|
62
63
|
const { showToast } = useToast();
|
|
64
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState();
|
|
63
65
|
// --- load existing data (edit mode) ---
|
|
64
66
|
const couponQuery = useQuery({
|
|
65
67
|
queryKey: ["admin", "coupon", couponId],
|
|
@@ -169,17 +171,22 @@ export function AdminCouponEditorView({ couponId, onSaved, onDeleted, embedded,
|
|
|
169
171
|
: type === "fixed"
|
|
170
172
|
? "Discount amount (paise)"
|
|
171
173
|
: "Discount value";
|
|
172
|
-
const formSection = (_jsxs(Form, { onSubmit: (e) => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
174
|
+
const formSection = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Form, { onSubmit: (e) => {
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
clearErrors();
|
|
177
|
+
if (!name.trim()) {
|
|
178
|
+
setFieldError("name", "Campaign name is required");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
saveMutation.mutate();
|
|
182
|
+
}, className: "space-y-5", children: [_jsx(Select, { label: "Coupon type", options: TYPE_OPTIONS, value: type, onValueChange: (v) => setType(v), required: true }), _jsx(FieldInput, { name: "name", label: "Campaign name", value: name, onChange: (v) => handleNameChange(v), required: true, placeholder: "e.g. Summer Sale 20%" }), !isEdit && (_jsx(Input, { label: "Coupon code", value: code, onChange: (e) => {
|
|
183
|
+
setCode(toCouponCode(e.target.value));
|
|
184
|
+
setCodeManual(true);
|
|
185
|
+
}, required: true, placeholder: "e.g. SUMMER20", helperText: "Auto-generated from name. Uppercase alphanumeric + hyphens only." })), isEdit && (_jsx(Input, { label: "Coupon code", value: code, disabled: true, helperText: "Code cannot be changed after creation." })), _jsx(Input, { label: "Description (optional)", value: description, onChange: (e) => setDescription(e.target.value), placeholder: "Internal notes about this coupon" }), _jsx(CouponDiscountFields, { type: type, discountValue: discountValue, setDiscountValue: setDiscountValue, maxDiscount: maxDiscount, setMaxDiscount: setMaxDiscount, minPurchase: minPurchase, setMinPurchase: setMinPurchase, buyQty: buyQty, setBuyQty: setBuyQty, getQty: getQty, setGetQty: setGetQty, discountLabel: discountLabel }), _jsx(CouponValidityFields, { startDate: startDate, setStartDate: setStartDate, endDate: endDate, setEndDate: setEndDate, isActive: isActive, setIsActive: setIsActive, totalLimit: totalLimit, setTotalLimit: setTotalLimit, perUserLimit: perUserLimit, setPerUserLimit: setPerUserLimit, isEdit: isEdit, currentUsage: currentUsage }), _jsx(Toggle, { label: "First-time users only", checked: firstTimeOnly, onChange: setFirstTimeOnly }), _jsx(Toggle, { label: "Allow stacking with seller coupons", checked: combinable, onChange: setCombinable }), _jsx(Toggle, { label: "Applies to auctions", checked: appliesToAuctions, onChange: setAppliesToAuctions }), _jsxs("div", { className: "flex gap-3 pt-2", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !canSave || isSubmitting, children: isEdit ? "Save changes" : "Create coupon" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => {
|
|
186
|
+
if (confirm("Delete this coupon? This cannot be undone.")) {
|
|
187
|
+
deleteMutation.mutate();
|
|
188
|
+
}
|
|
189
|
+
}, children: "Delete coupon" }))] })] }, "coupon-form") }));
|
|
183
190
|
if (embedded) {
|
|
184
191
|
return _jsx("div", { className: "overflow-y-auto p-4", children: formSection });
|
|
185
192
|
}
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
5
5
|
import { Button, Form, Input, RichTextEditor, Select, StackedViewShell, TagInput, Text, Toggle, useToast } from "../../../ui";
|
|
6
|
+
import { FieldInput, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
6
7
|
import { apiClient } from "../../../http";
|
|
7
8
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
9
|
// --- Constants ---------------------------------------------------------------
|
|
@@ -38,6 +39,7 @@ export function AdminFaqEditorView({ faqId, onSaved, onDeleted, embedded, ...res
|
|
|
38
39
|
const [showOnHomepage, setShowOnHomepage] = React.useState(false);
|
|
39
40
|
const [showInFooter, setShowInFooter] = React.useState(false);
|
|
40
41
|
const { showToast } = useToast();
|
|
42
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState();
|
|
41
43
|
// --- load existing FAQ (edit mode) ---
|
|
42
44
|
const faqQuery = useQuery({
|
|
43
45
|
queryKey: ["admin", "faqs", faqId],
|
|
@@ -112,17 +114,22 @@ export function AdminFaqEditorView({ faqId, onSaved, onDeleted, embedded, ...res
|
|
|
112
114
|
});
|
|
113
115
|
const isSubmitting = saveMutation.isPending || faqQuery.isLoading;
|
|
114
116
|
const canSave = Boolean(question.trim()) && Boolean(answer.trim());
|
|
115
|
-
const formSection = (_jsxs(Form, { onSubmit: (e) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
const formSection = (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Form, { onSubmit: (e) => {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
clearErrors();
|
|
120
|
+
if (!question.trim()) {
|
|
121
|
+
setFieldError("question", "Question is required");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
saveMutation.mutate();
|
|
125
|
+
}, className: "space-y-5", children: [_jsx(FieldInput, { name: "question", label: "Question", value: question, onChange: (v) => handleQuestionChange(v), required: true, placeholder: "e.g. How does bidding work on LetItRip?" }), _jsx(Input, { label: "Slug", value: slug, onChange: (e) => {
|
|
126
|
+
setSlug(e.target.value);
|
|
127
|
+
setSlugManual(true);
|
|
128
|
+
}, placeholder: "faq-how-does-bidding-work", helperText: "Auto-generated from question. Must start with 'faq-'." }), _jsxs("div", { className: "space-y-1", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Answer" }), _jsx(RichTextEditor, { value: answer, onChange: setAnswer, placeholder: "Write a clear, helpful answer...", minHeightClassName: "min-h-[200px]" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsx(Select, { label: "Category", options: CATEGORY_OPTIONS, value: category, onValueChange: setCategory }), _jsx(Input, { label: "Display order", value: String(order), onChange: (e) => setOrder(parseInt(e.target.value, 10) || 0), type: "number", min: 0, helperText: "Lower = shown first within category." })] }), _jsx(Input, { label: "Priority", value: String(priority), onChange: (e) => setPriority(parseInt(e.target.value, 10) || 0), type: "number", min: 0, helperText: "Higher priority FAQs appear first in search results." }), _jsx(TagInput, { label: "Tags", value: tags, onChange: setTags, placeholder: "e.g. shipping, pokemon, returns" }), _jsxs("div", { className: "space-y-3 rounded-lg border border-zinc-200 dark:border-zinc-700 p-4", children: [_jsx(Text, { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Visibility" }), _jsx(Toggle, { label: "Active (visible to users)", checked: isActive, onChange: setIsActive }), _jsx(Toggle, { label: "Pinned (always shown at top)", checked: isPinned, onChange: setIsPinned }), _jsx(Toggle, { label: "Show on homepage FAQ section", checked: showOnHomepage, onChange: setShowOnHomepage }), _jsx(Toggle, { label: "Show in footer FAQ links", checked: showInFooter, onChange: setShowInFooter })] }), _jsxs("div", { className: "flex gap-3 pt-2", children: [_jsx(Button, { type: "submit", isLoading: isSubmitting, disabled: !canSave || isSubmitting, children: isEdit ? "Save changes" : "Create FAQ" }), isEdit && (_jsx(Button, { type: "button", variant: "danger", isLoading: deleteMutation.isPending, onClick: () => {
|
|
129
|
+
if (confirm("Delete this FAQ? This cannot be undone.")) {
|
|
130
|
+
deleteMutation.mutate();
|
|
131
|
+
}
|
|
132
|
+
}, children: "Delete FAQ" }))] })] }, "faq-form") }));
|
|
126
133
|
if (embedded) {
|
|
127
134
|
return _jsx("div", { className: "overflow-y-auto p-4", children: formSection });
|
|
128
135
|
}
|