1309 lines
54 KiB
TypeScript
1309 lines
54 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
||
import { Plus, Pencil, Trash2, Copy, ExternalLink } from "lucide-react";
|
||
import { useAuth } from "../../contexts/AuthContext";
|
||
import { useAppNav } from "../../contexts/AppNavContext";
|
||
import QRCode from "qrcode";
|
||
import {
|
||
eventTicketsApi,
|
||
eventsApi,
|
||
eventTicketingManualApi,
|
||
formsApi,
|
||
participantListsApi,
|
||
portfolioListOrganizationId,
|
||
subscriptionApi,
|
||
ticketManualOrdersApi,
|
||
uploadMediaFile,
|
||
loadRazorpayScript,
|
||
type EventTicketRecord,
|
||
type EventTicketTaxItem,
|
||
type ParticipantListRecord,
|
||
type FormWithSubmissionCount,
|
||
type TicketingManualPaymentSettings,
|
||
type TicketingOnlinePaymentSettings,
|
||
type TicketManualOrderRow,
|
||
} from "../../../services/api";
|
||
import { buildUpiPayUri } from "../../../utils/upiPayUri";
|
||
const labelStyle: React.CSSProperties = {
|
||
fontSize: "var(--text-xs)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
color: "var(--muted)",
|
||
fontWeight: "var(--font-weight-medium)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.06em",
|
||
};
|
||
|
||
function minorToDisplay(minor: number, currency: string) {
|
||
return `${currency} ${(minor / 100).toFixed(2)}`;
|
||
}
|
||
|
||
const PAYMENT_CANCELLED_MESSAGE = "__eb_payment_cancelled__";
|
||
|
||
export function EventTicketingPage() {
|
||
const { user } = useAuth();
|
||
const { selectedEventId } = useAppNav();
|
||
const orgId = portfolioListOrganizationId(user);
|
||
const [tickets, setTickets] = useState<EventTicketRecord[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [lists, setLists] = useState<ParticipantListRecord[]>([]);
|
||
const [forms, setForms] = useState<FormWithSubmissionCount[]>([]);
|
||
const [panelOpen, setPanelOpen] = useState(false);
|
||
const [editing, setEditing] = useState<EventTicketRecord | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [keyPoints, setKeyPoints] = useState<string[]>([""]);
|
||
const [basePrice, setBasePrice] = useState(""); // major units in UI
|
||
const [currency, setCurrency] = useState("INR");
|
||
const [sortOrder, setSortOrder] = useState(0);
|
||
const [maxPerOrder, setMaxPerOrder] = useState("");
|
||
const [participantListId, setParticipantListId] = useState("");
|
||
const [registrationFormId, setRegistrationFormId] = useState("");
|
||
const [taxItems, setTaxItems] = useState<EventTicketTaxItem[]>([]);
|
||
|
||
const [manualSettings, setManualSettings] = useState<TicketingManualPaymentSettings | null>(null);
|
||
const [manualLoading, setManualLoading] = useState(false);
|
||
const [manualErr, setManualErr] = useState<string | null>(null);
|
||
const [manualSaving, setManualSaving] = useState(false);
|
||
const [manualEnabled, setManualEnabled] = useState(false);
|
||
const [eventTitle, setEventTitle] = useState("");
|
||
const [manualQrMode, setManualQrMode] = useState<"upload" | "dynamic">("upload");
|
||
const [previewQrDataUrl, setPreviewQrDataUrl] = useState("");
|
||
const [manualQrUrl, setManualQrUrl] = useState("");
|
||
const [manualUpiId, setManualUpiId] = useState("");
|
||
const [manualPayee, setManualPayee] = useState("");
|
||
const [manualInstructions, setManualInstructions] = useState("");
|
||
const [pendingOrders, setPendingOrders] = useState<TicketManualOrderRow[]>([]);
|
||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||
const [enablementPayBusy, setEnablementPayBusy] = useState(false);
|
||
|
||
const [onlineSettings, setOnlineSettings] = useState<TicketingOnlinePaymentSettings | null>(null);
|
||
const [onlineEnabled, setOnlineEnabled] = useState(false);
|
||
const [onlineGatewayIds, setOnlineGatewayIds] = useState<string[]>([]);
|
||
const [onlineSaving, setOnlineSaving] = useState(false);
|
||
const [onlineErr, setOnlineErr] = useState<string | null>(null);
|
||
|
||
const canManageBilling = user?.role === "super_admin" || user?.role === "admin";
|
||
|
||
const checkoutUrl =
|
||
typeof window !== "undefined" && selectedEventId
|
||
? `${window.location.origin}/event/${selectedEventId}/tickets`
|
||
: "";
|
||
|
||
const load = useCallback(async () => {
|
||
if (!selectedEventId || !orgId) {
|
||
setTickets([]);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setErr(null);
|
||
try {
|
||
const [t, lr, fs] = await Promise.all([
|
||
eventTicketsApi.list(selectedEventId),
|
||
participantListsApi.list({ organization: orgId }),
|
||
formsApi.listForEvent(selectedEventId),
|
||
]);
|
||
setTickets(t);
|
||
if (lr.error) throw new Error(lr.error);
|
||
setLists((lr.data?.lists ?? []).filter((l) => l.type === "fixed"));
|
||
setForms(fs);
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : "Failed to load");
|
||
setTickets([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedEventId, orgId]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const loadManual = useCallback(async () => {
|
||
if (!selectedEventId) {
|
||
setManualSettings(null);
|
||
setOnlineSettings(null);
|
||
return;
|
||
}
|
||
setManualLoading(true);
|
||
setManualErr(null);
|
||
setOnlineErr(null);
|
||
try {
|
||
const [m, online, evRes] = await Promise.all([
|
||
eventTicketingManualApi.get(selectedEventId),
|
||
eventTicketingManualApi.getOnline(selectedEventId),
|
||
eventsApi.get(selectedEventId),
|
||
]);
|
||
setEventTitle(evRes.data?.name?.trim() || "");
|
||
setManualSettings(m);
|
||
setManualEnabled(m.enabled);
|
||
setManualQrMode(m.manualQrMode === "dynamic" ? "dynamic" : "upload");
|
||
setManualQrUrl(m.upiQrImageUrl || "");
|
||
setManualUpiId(m.upiId || "");
|
||
setManualPayee(m.payeeName || "");
|
||
setManualInstructions(m.instructions || "");
|
||
setOnlineSettings(online);
|
||
setOnlineEnabled(online.enabled);
|
||
setOnlineGatewayIds(online.gatewayIds || []);
|
||
const ord = await ticketManualOrdersApi.list(selectedEventId, "pending_review");
|
||
setPendingOrders(ord);
|
||
} catch (e) {
|
||
setManualErr(e instanceof Error ? e.message : "Failed to load UPI settings");
|
||
setManualSettings(null);
|
||
setOnlineSettings(null);
|
||
} finally {
|
||
setManualLoading(false);
|
||
}
|
||
}, [selectedEventId]);
|
||
|
||
useEffect(() => {
|
||
void loadManual();
|
||
}, [loadManual]);
|
||
|
||
useEffect(() => {
|
||
if (manualQrMode !== "dynamic" || !manualUpiId.trim() || !eventTitle.trim()) {
|
||
setPreviewQrDataUrl("");
|
||
return;
|
||
}
|
||
const ticketLabel = tickets[0]?.name || "Sample ticket";
|
||
const pn = `${ticketLabel} · ${eventTitle}`.slice(0, 99);
|
||
const baseMinor = tickets[0]?.basePriceMinor ?? 10000;
|
||
const am = (baseMinor / 100).toFixed(2);
|
||
const uri = buildUpiPayUri({ pa: manualUpiId.trim(), pn, am });
|
||
if (!uri) {
|
||
setPreviewQrDataUrl("");
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
void QRCode.toDataURL(uri, { margin: 1, width: 220, errorCorrectionLevel: "M" }).then((u) => {
|
||
if (!cancelled) setPreviewQrDataUrl(u);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [manualQrMode, manualUpiId, eventTitle, tickets]);
|
||
|
||
const refreshPendingOrders = async () => {
|
||
if (!selectedEventId) return;
|
||
setOrdersLoading(true);
|
||
try {
|
||
setPendingOrders(await ticketManualOrdersApi.list(selectedEventId, "pending_review"));
|
||
} catch {
|
||
/* ignore */
|
||
} finally {
|
||
setOrdersLoading(false);
|
||
}
|
||
};
|
||
|
||
const saveManualSettings = async () => {
|
||
if (!selectedEventId) return;
|
||
setManualSaving(true);
|
||
try {
|
||
const payload = {
|
||
enabled: manualEnabled,
|
||
manualQrMode,
|
||
upiQrImageUrl: manualQrUrl.trim(),
|
||
upiId: manualUpiId.trim(),
|
||
payeeName: manualPayee.trim(),
|
||
instructions: manualInstructions,
|
||
platformFeeInr: null,
|
||
};
|
||
const m = await eventTicketingManualApi.patch(selectedEventId, payload);
|
||
setManualSettings(m);
|
||
setManualEnabled(m.enabled);
|
||
setManualQrMode(m.manualQrMode === "dynamic" ? "dynamic" : "upload");
|
||
setManualQrUrl(m.upiQrImageUrl || "");
|
||
await refreshPendingOrders();
|
||
} catch (e) {
|
||
window.alert(e instanceof Error ? e.message : "Save failed");
|
||
} finally {
|
||
setManualSaving(false);
|
||
}
|
||
};
|
||
|
||
const saveOnlineSettings = async () => {
|
||
if (!selectedEventId) return;
|
||
setOnlineSaving(true);
|
||
setOnlineErr(null);
|
||
try {
|
||
const next = await eventTicketingManualApi.patchOnline(selectedEventId, {
|
||
enabled: onlineEnabled,
|
||
gatewayIds: onlineEnabled ? onlineGatewayIds : [],
|
||
});
|
||
setOnlineSettings(next);
|
||
setOnlineEnabled(next.enabled);
|
||
setOnlineGatewayIds(next.gatewayIds || []);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : "Save failed";
|
||
setOnlineErr(msg);
|
||
window.alert(msg);
|
||
} finally {
|
||
setOnlineSaving(false);
|
||
}
|
||
};
|
||
|
||
const toggleOnlineGateway = (id: string, supported: boolean) => {
|
||
if (!supported) return;
|
||
setOnlineGatewayIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||
};
|
||
|
||
const payEnablementFee = async () => {
|
||
if (!selectedEventId || !canManageBilling) return;
|
||
setEnablementPayBusy(true);
|
||
try {
|
||
await loadRazorpayScript();
|
||
const order = await eventTicketingManualApi.checkoutEnablementOrder(selectedEventId);
|
||
type RazorpayResponse = { razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string };
|
||
type RazorpayConstructor = new (opts: {
|
||
key: string;
|
||
amount: number;
|
||
currency: string;
|
||
order_id: string;
|
||
name: string;
|
||
description: string;
|
||
handler: (response: RazorpayResponse) => void;
|
||
modal?: { ondismiss?: () => void };
|
||
theme?: { color?: string };
|
||
prefill?: { email?: string };
|
||
}) => { open: () => void; on: (ev: string, fn: (r: { error: { description?: string } }) => void) => void };
|
||
|
||
const Rzp = (window as unknown as { Razorpay: RazorpayConstructor }).Razorpay;
|
||
await new Promise<void>((resolve, reject) => {
|
||
let invoked = false;
|
||
const rzp = new Rzp({
|
||
key: order.keyId,
|
||
amount: order.amount,
|
||
currency: order.currency,
|
||
order_id: order.orderId,
|
||
name: "EventBuzz",
|
||
description: "Manual UPI ticketing — enablement fee",
|
||
handler: (response: RazorpayResponse) => {
|
||
invoked = true;
|
||
void subscriptionApi
|
||
.checkoutVerify({
|
||
orderId: response.razorpay_order_id,
|
||
paymentId: response.razorpay_payment_id,
|
||
signature: response.razorpay_signature,
|
||
})
|
||
.then(() => resolve())
|
||
.catch((e) => reject(e instanceof Error ? e : new Error("Verification failed")));
|
||
},
|
||
modal: {
|
||
ondismiss: () => {
|
||
if (!invoked) reject(new Error(PAYMENT_CANCELLED_MESSAGE));
|
||
},
|
||
},
|
||
theme: { color: "#0B4DD1" },
|
||
prefill: user?.email ? { email: user.email } : undefined,
|
||
});
|
||
rzp.on("payment.failed", (response: { error: { description?: string } }) => {
|
||
reject(new Error(response.error?.description || "Payment failed"));
|
||
});
|
||
rzp.open();
|
||
});
|
||
await loadManual();
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : "Payment failed";
|
||
if (msg !== PAYMENT_CANCELLED_MESSAGE) {
|
||
window.alert(msg);
|
||
}
|
||
} finally {
|
||
setEnablementPayBusy(false);
|
||
}
|
||
};
|
||
|
||
const onQrFile = async (file: File | null) => {
|
||
if (!file || !selectedEventId || !orgId) return;
|
||
try {
|
||
const up = await uploadMediaFile(file, { eventId: selectedEventId, module: "ticketing-upi-qr" });
|
||
const u = up.url?.trim();
|
||
if (u) setManualQrUrl(u);
|
||
} catch (e) {
|
||
window.alert(e instanceof Error ? e.message : "Upload failed");
|
||
}
|
||
};
|
||
|
||
const openCreate = () => {
|
||
setEditing(null);
|
||
setName("");
|
||
setDescription("");
|
||
setKeyPoints([""]);
|
||
setBasePrice("");
|
||
setCurrency("INR");
|
||
setSortOrder(0);
|
||
setMaxPerOrder("");
|
||
setParticipantListId(lists[0]?._id || "");
|
||
setRegistrationFormId("");
|
||
setTaxItems([]);
|
||
setPanelOpen(true);
|
||
};
|
||
|
||
const openEdit = (t: EventTicketRecord) => {
|
||
setEditing(t);
|
||
setName(t.name);
|
||
setDescription(t.description || "");
|
||
setKeyPoints(t.keyPoints?.length ? t.keyPoints : [""]);
|
||
setBasePrice(String((t.basePriceMinor || 0) / 100));
|
||
setCurrency(t.currency || "INR");
|
||
setSortOrder(t.sortOrder ?? 0);
|
||
setMaxPerOrder(t.maxPerOrder != null ? String(t.maxPerOrder) : "");
|
||
setParticipantListId(t.participantList);
|
||
setRegistrationFormId(t.registrationForm || "");
|
||
setTaxItems(t.taxItems?.length ? [...t.taxItems] : []);
|
||
setPanelOpen(true);
|
||
};
|
||
|
||
const save = async () => {
|
||
if (!selectedEventId || !participantListId) return;
|
||
const major = parseFloat(basePrice);
|
||
if (!Number.isFinite(major) || major < 0) {
|
||
window.alert("Enter a valid price.");
|
||
return;
|
||
}
|
||
const basePriceMinor = Math.round(major * 100);
|
||
const payload = {
|
||
name: name.trim(),
|
||
description: description.trim(),
|
||
keyPoints: keyPoints.map((k) => k.trim()).filter(Boolean),
|
||
basePriceMinor,
|
||
currency,
|
||
sortOrder,
|
||
maxPerOrder: maxPerOrder.trim() ? parseInt(maxPerOrder, 10) : null,
|
||
taxItems,
|
||
participantList: participantListId,
|
||
registrationForm: registrationFormId.trim() || null,
|
||
isActive: true,
|
||
};
|
||
setSaving(true);
|
||
try {
|
||
if (editing) {
|
||
await eventTicketsApi.update(selectedEventId, editing._id, payload);
|
||
} else {
|
||
await eventTicketsApi.create(selectedEventId, payload);
|
||
}
|
||
setPanelOpen(false);
|
||
await load();
|
||
} catch (e) {
|
||
window.alert(e instanceof Error ? e.message : "Save failed");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const deactivate = async (t: EventTicketRecord) => {
|
||
if (!selectedEventId) return;
|
||
if (!window.confirm(`Deactivate “${t.name}”? It will no longer appear on the public checkout page.`)) return;
|
||
try {
|
||
await eventTicketsApi.remove(selectedEventId, t._id);
|
||
await load();
|
||
} catch (e) {
|
||
window.alert(e instanceof Error ? e.message : "Failed");
|
||
}
|
||
};
|
||
|
||
const copyLink = () => {
|
||
if (!checkoutUrl) return;
|
||
void navigator.clipboard.writeText(checkoutUrl).then(
|
||
() => window.alert("Checkout link copied."),
|
||
() => window.alert(checkoutUrl),
|
||
);
|
||
};
|
||
|
||
if (!selectedEventId) {
|
||
return (
|
||
<p style={{ fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Select an event to manage tickets.</p>
|
||
);
|
||
}
|
||
|
||
if (!orgId) {
|
||
return (
|
||
<p style={{ fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
||
Your account needs an organisation to use ticketing.
|
||
</p>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "12px", flexWrap: "wrap" }}>
|
||
<div>
|
||
<p style={labelStyle}>Event</p>
|
||
<h1
|
||
style={{
|
||
fontFamily: "var(--font-inter)",
|
||
fontSize: "var(--text-2xl)",
|
||
fontWeight: "var(--font-weight-medium)",
|
||
color: "var(--foreground)",
|
||
}}
|
||
>
|
||
Ticketing
|
||
</h1>
|
||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", marginTop: "6px", maxWidth: "560px" }}>
|
||
Sell tickets on the public checkout page. Buyers are added to the participant list you choose. Online card/UPI uses your
|
||
Razorpay (and other organisation gateways you enable below) when configured; you can also enable per-event UPI QR and
|
||
review payment screenshots
|
||
below.
|
||
</p>
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", alignItems: "flex-end" }}>
|
||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||
<button
|
||
type="button"
|
||
onClick={copyLink}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
padding: "7px 14px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--card)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
}}
|
||
>
|
||
<Copy size={14} /> Copy checkout link
|
||
</button>
|
||
<a
|
||
href={checkoutUrl || "#"}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
padding: "7px 14px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
color: "var(--primary)",
|
||
fontSize: "var(--text-sm)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
<ExternalLink size={14} /> Open checkout
|
||
</a>
|
||
<button
|
||
type="button"
|
||
onClick={openCreate}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "6px",
|
||
padding: "7px 14px",
|
||
borderRadius: "var(--radius-button)",
|
||
background: "var(--primary)",
|
||
color: "var(--primary-foreground)",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
fontWeight: "var(--font-weight-medium)",
|
||
}}
|
||
>
|
||
<Plus size={14} /> Add ticket
|
||
</button>
|
||
</div>
|
||
{checkoutUrl && (
|
||
<code
|
||
style={{
|
||
fontSize: "11px",
|
||
color: "var(--muted)",
|
||
maxWidth: "420px",
|
||
wordBreak: "break-all",
|
||
textAlign: "right",
|
||
}}
|
||
>
|
||
{checkoutUrl}
|
||
</code>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{err && (
|
||
<p style={{ color: "var(--destructive)", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>{err}</p>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))",
|
||
gap: "24px",
|
||
alignItems: "start",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "20px", minWidth: 0 }}>
|
||
{loading && <p style={{ color: "var(--muted)" }}>Loading…</p>}
|
||
{!loading && tickets.length === 0 && !err && (
|
||
<div
|
||
style={{
|
||
padding: "40px",
|
||
border: "2px dashed var(--border)",
|
||
borderRadius: "var(--radius-card)",
|
||
textAlign: "center",
|
||
color: "var(--muted)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
}}
|
||
>
|
||
No tickets yet. Add a ticket to publish it on the public checkout page.
|
||
</div>
|
||
)}
|
||
<div style={{ display: "grid", gap: "12px" }}>
|
||
{tickets.map((t) => (
|
||
<div
|
||
key={t._id}
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--radius-card)",
|
||
padding: "16px",
|
||
background: "var(--card)",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
gap: "12px",
|
||
flexWrap: "wrap",
|
||
opacity: t.isActive ? 1 : 0.65,
|
||
}}
|
||
>
|
||
<div>
|
||
<p style={{ fontWeight: "var(--font-weight-medium)", fontFamily: "var(--font-inter)", marginBottom: "4px" }}>
|
||
{t.name}{" "}
|
||
{!t.isActive && <span style={{ fontSize: "11px", color: "var(--muted)" }}>(inactive)</span>}
|
||
</p>
|
||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: "8px" }}>
|
||
{minorToDisplay(t.basePriceMinor, t.currency)}
|
||
{t.taxItems?.length ? ` · ${t.taxItems.length} tax row(s)` : ""}
|
||
</p>
|
||
{t.description && (
|
||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", maxWidth: "640px" }}>{t.description}</p>
|
||
)}
|
||
</div>
|
||
<div style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => openEdit(t)}
|
||
style={{
|
||
padding: "6px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--background)",
|
||
cursor: "pointer",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
fontSize: "var(--text-xs)",
|
||
}}
|
||
>
|
||
<Pencil size={14} /> Edit
|
||
</button>
|
||
{t.isActive && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void deactivate(t)}
|
||
style={{
|
||
padding: "6px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--destructive)",
|
||
color: "var(--destructive)",
|
||
background: "transparent",
|
||
cursor: "pointer",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: "4px",
|
||
fontSize: "var(--text-xs)",
|
||
}}
|
||
>
|
||
<Trash2 size={14} /> Deactivate
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<aside style={{ minWidth: 0 }}>
|
||
<section
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "var(--radius-card)",
|
||
padding: "16px",
|
||
background: "var(--card)",
|
||
position: "sticky",
|
||
top: 16,
|
||
}}
|
||
>
|
||
<h2
|
||
style={{
|
||
fontFamily: "var(--font-inter)",
|
||
fontSize: "var(--text-lg)",
|
||
fontWeight: "var(--font-weight-medium)",
|
||
margin: "0 0 8px",
|
||
}}
|
||
>
|
||
Online payment gateways
|
||
</h2>
|
||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: "0 0 12px" }}>
|
||
Choose organisation payment gateways (from Settings → Payments) that buyers can use when purchasing tickets for this
|
||
event.
|
||
</p>
|
||
{onlineErr ? <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{onlineErr}</p> : null}
|
||
{!manualLoading && onlineSettings ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", marginBottom: "20px" }}>
|
||
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "var(--text-sm)" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={onlineEnabled}
|
||
onChange={(e) => {
|
||
const on = e.target.checked;
|
||
setOnlineEnabled(on);
|
||
if (on && onlineGatewayIds.length === 0) {
|
||
const first = (onlineSettings.orgGateways || []).find(
|
||
(g) => g.isActive && g.ticketCheckoutSupported,
|
||
);
|
||
if (first) setOnlineGatewayIds([first.id]);
|
||
}
|
||
}}
|
||
/>
|
||
Enable online checkout for this event
|
||
</label>
|
||
|
||
{onlineEnabled ? (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
{(onlineSettings.orgGateways || []).length === 0 ? (
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||
No gateways on this organisation yet. Add Razorpay (or another provider) under{" "}
|
||
<strong>Settings → Payments</strong>, then return here to enable it for tickets.
|
||
</p>
|
||
) : (
|
||
(onlineSettings.orgGateways || []).map((g) => {
|
||
const supported = g.ticketCheckoutSupported && g.isActive;
|
||
return (
|
||
<label
|
||
key={g.id}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: "8px",
|
||
fontSize: "var(--text-sm)",
|
||
opacity: supported ? 1 : 0.55,
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={onlineGatewayIds.includes(g.id)}
|
||
disabled={!supported}
|
||
onChange={() => toggleOnlineGateway(g.id, supported)}
|
||
style={{ marginTop: 3 }}
|
||
/>
|
||
<span>
|
||
<strong>{g.label || g.provider}</strong>
|
||
{" · "}
|
||
{g.provider}
|
||
{g.isPrimary ? " · primary" : ""}
|
||
{g.testMode ? " · test" : " · live"}
|
||
{!g.isActive ? " · inactive" : ""}
|
||
{!g.ticketCheckoutSupported ? " · not available for tickets yet" : ""}
|
||
</span>
|
||
</label>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
) : (
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||
Online card/UPI via your connected gateway will not be offered at ticket checkout for this event.
|
||
</p>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
disabled={onlineSaving}
|
||
onClick={() => void saveOnlineSettings()}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
padding: "8px 16px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "none",
|
||
background: "var(--primary)",
|
||
color: "var(--primary-foreground)",
|
||
cursor: onlineSaving ? "wait" : "pointer",
|
||
}}
|
||
>
|
||
{onlineSaving ? "Saving…" : "Save online payment settings"}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
<h2
|
||
style={{
|
||
fontFamily: "var(--font-inter)",
|
||
fontSize: "var(--text-lg)",
|
||
fontWeight: "var(--font-weight-medium)",
|
||
margin: "0 0 8px",
|
||
}}
|
||
>
|
||
UPI / manual checkout
|
||
</h2>
|
||
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: "0 0 12px" }}>
|
||
Buyers pay you directly via UPI, then upload proof for you to approve. Can be used alone or alongside online gateways.
|
||
</p>
|
||
{manualErr && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{manualErr}</p>}
|
||
{manualLoading && <p style={{ color: "var(--muted)" }}>Loading payment settings…</p>}
|
||
{!manualLoading && manualSettings && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||
{!manualSettings.enablementPaid ?
|
||
<div
|
||
style={{
|
||
padding: "12px",
|
||
borderRadius: "8px",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--muted-background, rgba(0,0,0,0.04))",
|
||
}}
|
||
>
|
||
<p style={{ fontSize: "var(--text-sm)", margin: "0 0 10px", color: "var(--foreground)" }}>
|
||
Pay a one-time <strong>INR {manualSettings.enablementFeeInr?.toFixed?.(0) ?? manualSettings.enablementFeeInr}</strong>{" "}
|
||
enablement fee per event to turn on manual UPI checkout. The receipt is listed under{" "}
|
||
<strong>Settings → Billing</strong> with your other invoices.
|
||
</p>
|
||
{canManageBilling ?
|
||
<button
|
||
type="button"
|
||
disabled={enablementPayBusy}
|
||
onClick={() => void payEnablementFee()}
|
||
style={{
|
||
padding: "8px 16px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "none",
|
||
background: "var(--primary)",
|
||
color: "var(--primary-foreground)",
|
||
cursor: enablementPayBusy ? "wait" : "pointer",
|
||
fontSize: "var(--text-sm)",
|
||
}}
|
||
>
|
||
{enablementPayBusy ? "Processing…" : "Pay enablement fee"}
|
||
</button>
|
||
: (
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||
Only organisation administrators can complete this payment.
|
||
</p>
|
||
)}
|
||
</div>
|
||
: (
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||
Enablement fee paid
|
||
{manualSettings.enablementPaidAt ?
|
||
` · ${new Date(manualSettings.enablementPaidAt).toLocaleString()}`
|
||
: ""}
|
||
. You can configure and enable manual checkout below.
|
||
</p>
|
||
)}
|
||
|
||
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "var(--text-sm)" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={manualEnabled}
|
||
disabled={!manualSettings.enablementPaid && !manualEnabled}
|
||
onChange={(e) => setManualEnabled(e.target.checked)}
|
||
/>
|
||
Enable UPI / manual checkout for this event
|
||
</label>
|
||
{!manualSettings.enablementPaid && !manualEnabled ?
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||
Complete payment above before you can enable this option.
|
||
</p>
|
||
: null}
|
||
|
||
{manualEnabled && (
|
||
<>
|
||
<div
|
||
style={{
|
||
background: "#fffbeb",
|
||
border: "1px solid #fcd34d",
|
||
borderRadius: "8px",
|
||
padding: "12px",
|
||
fontSize: "var(--text-sm)",
|
||
color: "#92400e",
|
||
lineHeight: 1.45,
|
||
}}
|
||
>
|
||
<strong>Manual verification required.</strong> EventBuzz does not confirm money movement on UPI. You must check each
|
||
payment in your bank or UPI app, match it to the buyer’s screenshot, then approve or reject the ticket in the queue
|
||
below. Unmatched or fraudulent payments should be rejected.
|
||
</div>
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
<span style={labelStyle}>QR code</span>
|
||
<label style={{ display: "flex", alignItems: "flex-start", gap: "8px", fontSize: "var(--text-sm)" }}>
|
||
<input
|
||
type="radio"
|
||
name="eb-manual-qr-mode"
|
||
checked={manualQrMode === "upload"}
|
||
onChange={() => setManualQrMode("upload")}
|
||
style={{ marginTop: 3 }}
|
||
/>
|
||
<span>Upload a static QR image (or image URL)</span>
|
||
</label>
|
||
<label style={{ display: "flex", alignItems: "flex-start", gap: "8px", fontSize: "var(--text-sm)" }}>
|
||
<input
|
||
type="radio"
|
||
name="eb-manual-qr-mode"
|
||
checked={manualQrMode === "dynamic"}
|
||
onChange={() => setManualQrMode("dynamic")}
|
||
style={{ marginTop: 3 }}
|
||
/>
|
||
<span>
|
||
Generate QR from UPI ID: payee name is <em>ticket name · event name</em>; amount is the buyer’s order total at
|
||
checkout.
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{manualQrMode === "upload" ?
|
||
<>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>UPI QR image</span>
|
||
<input type="file" accept="image/*" onChange={(e) => void onQrFile(e.target.files?.[0] ?? null)} />
|
||
{manualQrUrl ?
|
||
<img src={manualQrUrl} alt="QR" style={{ maxWidth: 200, borderRadius: 8, border: "1px solid var(--border)" }} />
|
||
: null}
|
||
<input
|
||
value={manualQrUrl}
|
||
onChange={(e) => setManualQrUrl(e.target.value)}
|
||
placeholder="Or paste image URL"
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>UPI ID (optional, shown to buyers)</span>
|
||
<input
|
||
value={manualUpiId}
|
||
onChange={(e) => setManualUpiId(e.target.value)}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Payee name (optional)</span>
|
||
<input
|
||
value={manualPayee}
|
||
onChange={(e) => setManualPayee(e.target.value)}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
</>
|
||
: <>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>UPI ID (VPA) — required</span>
|
||
<input
|
||
value={manualUpiId}
|
||
onChange={(e) => setManualUpiId(e.target.value)}
|
||
placeholder="e.g. merchant@paytm"
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
<p style={{ fontSize: "11px", color: "var(--muted)", margin: 0 }}>
|
||
Preview uses your first ticket’s base price only. On the public page the QR updates with the exact total (ticket +
|
||
tax, no Eventbuzz add-on).
|
||
</p>
|
||
{previewQrDataUrl ?
|
||
<img
|
||
src={previewQrDataUrl}
|
||
alt="Sample UPI QR"
|
||
style={{ maxWidth: 220, borderRadius: 8, border: "1px solid var(--border)" }}
|
||
/>
|
||
: null}
|
||
</>
|
||
}
|
||
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Instructions for buyers</span>
|
||
<textarea
|
||
value={manualInstructions}
|
||
onChange={(e) => setManualInstructions(e.target.value)}
|
||
rows={3}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)", fontFamily: "inherit" }}
|
||
/>
|
||
</label>
|
||
<p style={{ fontSize: "11px", color: "var(--muted)", margin: 0, lineHeight: 1.45 }}>
|
||
Buyers pay ticket price plus tax only on this path. Eventbuzz is covered by the one-time manual UPI enablement fee you
|
||
pay per event (Billing).
|
||
</p>
|
||
</>
|
||
)}
|
||
|
||
{(manualSettings.enablementPaid || manualEnabled || manualSettings.enabled) ? (
|
||
<button
|
||
type="button"
|
||
disabled={manualSaving || (!manualSettings.enablementPaid && manualEnabled)}
|
||
onClick={() => void saveManualSettings()}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
padding: "8px 16px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "none",
|
||
background: "var(--primary)",
|
||
color: "var(--primary-foreground)",
|
||
cursor: manualSaving ? "wait" : "pointer",
|
||
}}
|
||
>
|
||
{manualSaving ? "Saving…" : manualEnabled ? "Save UPI settings" : "Save (disable UPI checkout)"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
<h3
|
||
style={{
|
||
fontFamily: "var(--font-inter)",
|
||
fontSize: "var(--text-base)",
|
||
marginTop: "24px",
|
||
marginBottom: "8px",
|
||
}}
|
||
>
|
||
Pending UPI proofs
|
||
</h3>
|
||
{ordersLoading ?
|
||
<p style={{ color: "var(--muted)" }}>Refreshing…</p>
|
||
: pendingOrders.length === 0 ?
|
||
<p style={{ color: "var(--muted)", fontSize: "var(--text-sm)" }}>No pending reviews.</p>
|
||
: (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||
{pendingOrders.map((o) => (
|
||
<div
|
||
key={o._id}
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
borderRadius: "8px",
|
||
padding: "12px",
|
||
display: "grid",
|
||
gap: "8px",
|
||
gridTemplateColumns: "1fr auto",
|
||
alignItems: "start",
|
||
}}
|
||
>
|
||
<div>
|
||
<p style={{ fontWeight: "var(--font-weight-medium)", margin: "0 0 4px" }}>
|
||
{o.ticket?.name || "Ticket"} · {o.purchaserEmail}
|
||
</p>
|
||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||
{o.quote && typeof o.quote.totalMinor === "number" ?
|
||
`${(o.quote.totalMinor as number) / 100} ${o.currency}`
|
||
: "—"}{" "}
|
||
· {o.createdAt ? new Date(o.createdAt).toLocaleString() : ""}
|
||
</p>
|
||
{o.buyerNote ? <p style={{ fontSize: "var(--text-sm)", margin: "8px 0 0" }}>{o.buyerNote}</p> : null}
|
||
{o.screenshotUrl ?
|
||
<a href={o.screenshotUrl} target="_blank" rel="noopener noreferrer" style={{ fontSize: "var(--text-sm)" }}>
|
||
View screenshot
|
||
</a>
|
||
: null}
|
||
</div>
|
||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
void (async () => {
|
||
if (!selectedEventId) return;
|
||
if (!window.confirm("Approve this payment and register attendees?")) return;
|
||
try {
|
||
await ticketManualOrdersApi.approve(selectedEventId, o._id);
|
||
await refreshPendingOrders();
|
||
await load();
|
||
} catch (e) {
|
||
window.alert(e instanceof Error ? e.message : "Approve failed");
|
||
}
|
||
})();
|
||
}}
|
||
style={{
|
||
padding: "6px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "none",
|
||
background: "var(--primary)",
|
||
color: "var(--primary-foreground)",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-xs)",
|
||
}}
|
||
>
|
||
Approve
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
void (async () => {
|
||
if (!selectedEventId) return;
|
||
const reason = window.prompt("Reject reason (optional)") ?? "";
|
||
try {
|
||
await ticketManualOrdersApi.reject(selectedEventId, o._id, reason);
|
||
await refreshPendingOrders();
|
||
} catch (e) {
|
||
window.alert(e instanceof Error ? e.message : "Reject failed");
|
||
}
|
||
})();
|
||
}}
|
||
style={{
|
||
padding: "6px 12px",
|
||
borderRadius: "var(--radius-button)",
|
||
border: "1px solid var(--destructive)",
|
||
color: "var(--destructive)",
|
||
background: "transparent",
|
||
cursor: "pointer",
|
||
fontSize: "var(--text-xs)",
|
||
}}
|
||
>
|
||
Reject
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
|
||
{panelOpen && (
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
background: "rgba(0,0,0,0.35)",
|
||
zIndex: 1000,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: "16px",
|
||
}}
|
||
onClick={() => !saving && setPanelOpen(false)}
|
||
>
|
||
<div
|
||
style={{
|
||
background: "var(--card)",
|
||
borderRadius: "var(--radius-card)",
|
||
maxWidth: "560px",
|
||
width: "100%",
|
||
maxHeight: "90vh",
|
||
overflow: "auto",
|
||
padding: "20px",
|
||
border: "1px solid var(--border)",
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h2 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-lg)", marginBottom: "16px" }}>
|
||
{editing ? "Edit ticket" : "New ticket"}
|
||
</h2>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Name</span>
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Description</span>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
rows={3}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)", fontFamily: "inherit" }}
|
||
/>
|
||
</label>
|
||
<div>
|
||
<span style={labelStyle}>Key points</span>
|
||
{keyPoints.map((kp, i) => (
|
||
<div key={i} style={{ display: "flex", gap: "8px", marginTop: "6px" }}>
|
||
<input
|
||
value={kp}
|
||
onChange={(e) => {
|
||
const next = [...keyPoints];
|
||
next[i] = e.target.value;
|
||
setKeyPoints(next);
|
||
}}
|
||
placeholder="Bullet point"
|
||
style={{ flex: 1, padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setKeyPoints(keyPoints.filter((_, j) => j !== i))}
|
||
style={{ padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "8px", background: "var(--background)" }}
|
||
>
|
||
−
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => setKeyPoints([...keyPoints, ""])}
|
||
style={{ marginTop: "8px", padding: "6px 12px", borderRadius: "8px", border: "1px dashed var(--border)", background: "transparent", cursor: "pointer" }}
|
||
>
|
||
+ Add point
|
||
</button>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Price ({currency})</span>
|
||
<input
|
||
value={basePrice}
|
||
onChange={(e) => setBasePrice(e.target.value)}
|
||
placeholder="0.00"
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Currency</span>
|
||
<input
|
||
value={currency}
|
||
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||
maxLength={3}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Sort order</span>
|
||
<input
|
||
type="number"
|
||
value={sortOrder}
|
||
onChange={(e) => setSortOrder(parseInt(e.target.value, 10) || 0)}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Max per order (optional)</span>
|
||
<input
|
||
value={maxPerOrder}
|
||
onChange={(e) => setMaxPerOrder(e.target.value)}
|
||
placeholder="Unlimited"
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Participant list (fixed)</span>
|
||
<select
|
||
value={participantListId}
|
||
onChange={(e) => setParticipantListId(e.target.value)}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
>
|
||
<option value="">Select list…</option>
|
||
{lists.map((l) => (
|
||
<option key={l._id} value={l._id}>
|
||
{l.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||
<span style={labelStyle}>Registration form (optional)</span>
|
||
<select
|
||
value={registrationFormId}
|
||
onChange={(e) => setRegistrationFormId(e.target.value)}
|
||
style={{ padding: "8px 10px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
>
|
||
<option value="">None</option>
|
||
{forms.map((f) => (
|
||
<option key={f._id} value={f._id}>
|
||
{f.name} ({f.type})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div>
|
||
<span style={labelStyle}>Tax items</span>
|
||
<p style={{ fontSize: "11px", color: "var(--muted)", margin: "4px 0 8px" }}>
|
||
Rate as percent (e.g. 18 for 18%). Leave states empty to apply everywhere; otherwise enter comma-separated codes (e.g. KA,MH).
|
||
</p>
|
||
{taxItems.map((ti, i) => (
|
||
<div key={i} style={{ display: "grid", gridTemplateColumns: "1fr 80px 1fr auto", gap: "8px", marginBottom: "8px" }}>
|
||
<input
|
||
placeholder="Label (e.g. GST)"
|
||
value={ti.label}
|
||
onChange={(e) => {
|
||
const next = [...taxItems];
|
||
next[i] = { ...next[i], label: e.target.value };
|
||
setTaxItems(next);
|
||
}}
|
||
style={{ padding: "8px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
<input
|
||
type="number"
|
||
placeholder="%"
|
||
value={ti.rateBps / 100}
|
||
onChange={(e) => {
|
||
const pct = parseFloat(e.target.value) || 0;
|
||
const next = [...taxItems];
|
||
next[i] = { ...next[i], rateBps: Math.round(pct * 100) };
|
||
setTaxItems(next);
|
||
}}
|
||
style={{ padding: "8px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
<input
|
||
placeholder="States or empty = all"
|
||
value={ti.appliesToStates.join(",")}
|
||
onChange={(e) => {
|
||
const next = [...taxItems];
|
||
next[i] = {
|
||
...next[i],
|
||
appliesToStates: e.target.value
|
||
.split(",")
|
||
.map((s) => s.trim().toUpperCase())
|
||
.filter(Boolean),
|
||
};
|
||
setTaxItems(next);
|
||
}}
|
||
style={{ padding: "8px", borderRadius: "8px", border: "1px solid var(--border)" }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTaxItems(taxItems.filter((_, j) => j !== i))}
|
||
style={{ padding: "8px", border: "1px solid var(--border)", borderRadius: "8px" }}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => setTaxItems([...taxItems, { label: "", rateBps: 0, appliesToStates: [] }])}
|
||
style={{ padding: "6px 12px", borderRadius: "8px", border: "1px dashed var(--border)", background: "transparent" }}
|
||
>
|
||
+ Tax row
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "20px" }}>
|
||
<button
|
||
type="button"
|
||
disabled={saving}
|
||
onClick={() => setPanelOpen(false)}
|
||
style={{ padding: "8px 16px", borderRadius: "8px", border: "1px solid var(--border)", background: "var(--background)" }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={saving || !name.trim() || !participantListId}
|
||
onClick={() => void save()}
|
||
style={{
|
||
padding: "8px 16px",
|
||
borderRadius: "8px",
|
||
border: "none",
|
||
background: "var(--primary)",
|
||
color: "var(--primary-foreground)",
|
||
opacity: saving ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{saving ? "Saving…" : "Save"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|