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([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [lists, setLists] = useState([]); const [forms, setForms] = useState([]); const [panelOpen, setPanelOpen] = useState(false); const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [keyPoints, setKeyPoints] = useState([""]); 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([]); const [manualSettings, setManualSettings] = useState(null); const [manualLoading, setManualLoading] = useState(false); const [manualErr, setManualErr] = useState(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([]); const [ordersLoading, setOrdersLoading] = useState(false); const [enablementPayBusy, setEnablementPayBusy] = useState(false); const [onlineSettings, setOnlineSettings] = useState(null); const [onlineEnabled, setOnlineEnabled] = useState(false); const [onlineGatewayIds, setOnlineGatewayIds] = useState([]); const [onlineSaving, setOnlineSaving] = useState(false); const [onlineErr, setOnlineErr] = useState(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((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 (

Select an event to manage tickets.

); } if (!orgId) { return (

Your account needs an organisation to use ticketing.

); } return (

Event

Ticketing

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.

Open checkout
{checkoutUrl && ( {checkoutUrl} )}
{err && (

{err}

)}
{loading &&

Loading…

} {!loading && tickets.length === 0 && !err && (
No tickets yet. Add a ticket to publish it on the public checkout page.
)}
{tickets.map((t) => (

{t.name}{" "} {!t.isActive && (inactive)}

{minorToDisplay(t.basePriceMinor, t.currency)} {t.taxItems?.length ? ` · ${t.taxItems.length} tax row(s)` : ""}

{t.description && (

{t.description}

)}
{t.isActive && ( )}
))}