EventBuzz_Console/src/app/components/ticketing/EventTicketingPage.tsx

1309 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}