import React, { useEffect, useMemo, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; import { loadRazorpayScript } from "../services/api/subscription"; import QRCode from "qrcode"; import { publicEventTicketsApi, type PublicTicketCatalogItem, type PublicFormField, type PublicCheckoutOptions, type TicketQuote, } from "../services/api/publicEventTickets"; import { buildUpiPayUri } from "../utils/upiPayUri"; import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput"; /** ISO 3166-2:IN style codes for billing / tax */ const INDIAN_STATES = [ { code: "AN", name: "Andaman & Nicobar" }, { code: "AP", name: "Andhra Pradesh" }, { code: "AR", name: "Arunachal Pradesh" }, { code: "AS", name: "Assam" }, { code: "BR", name: "Bihar" }, { code: "CH", name: "Chandigarh" }, { code: "CT", name: "Chhattisgarh" }, { code: "DL", name: "Delhi" }, { code: "GA", name: "Goa" }, { code: "GJ", name: "Gujarat" }, { code: "HR", name: "Haryana" }, { code: "HP", name: "Himachal Pradesh" }, { code: "JK", name: "Jammu & Kashmir" }, { code: "JH", name: "Jharkhand" }, { code: "KA", name: "Karnataka" }, { code: "KL", name: "Kerala" }, { code: "LA", name: "Ladakh" }, { code: "MP", name: "Madhya Pradesh" }, { code: "MH", name: "Maharashtra" }, { code: "MN", name: "Manipur" }, { code: "ML", name: "Meghalaya" }, { code: "MZ", name: "Mizoram" }, { code: "NL", name: "Nagaland" }, { code: "OR", name: "Odisha" }, { code: "PY", name: "Puducherry" }, { code: "PB", name: "Punjab" }, { code: "RJ", name: "Rajasthan" }, { code: "SK", name: "Sikkim" }, { code: "TN", name: "Tamil Nadu" }, { code: "TG", name: "Telangana" }, { code: "TR", name: "Tripura" }, { code: "UP", name: "Uttar Pradesh" }, { code: "UT", name: "Uttarakhand" }, { code: "WB", name: "West Bengal" }, ]; type AttendeeRow = { firstName: string; lastName: string; email: string; phone: string; organization: string; designation: string; displayName: string; formData: Record; }; function emptyAttendee(): AttendeeRow { return { firstName: "", lastName: "", email: "", phone: "", organization: "", designation: "", displayName: "", formData: {}, }; } function isCoreField(f: PublicFormField) { return f.mapsTo === "email" || f.mapsTo === "firstName" || f.mapsTo === "lastName"; } /** Razorpay modal closed without completing payment — not an error to alert. */ const PAYMENT_CANCELLED_MESSAGE = "__eb_payment_cancelled__"; function formatMinor(minor: number, currency: string) { return `${(minor / 100).toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; } /** Visual tokens aligned with Figma (Abiir / product detail reference — node 123:8668). */ const T = { ink: "#1a1a18", accent: "#c17d5c", body: "#5a5550", muted: "#7a7068", cream: "#ede8e0", pageBg: "#f8f4ef", card: "#ffffff", border: "rgba(26,26,24,0.12)", borderHairline: "rgba(26,26,24,0.08)", primary: "#7a2340", express: "#5a31f5", success: "#8a9e85", serif: '"Cormorant Garamond", "Georgia", serif', sans: '"Raleway", system-ui, sans-serif', } as const; function fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result ?? "")); r.onerror = () => reject(r.error ?? new Error("read failed")); r.readAsDataURL(file); }); } export function EventTicketCheckoutView() { const { eventId } = useParams<{ eventId: string }>(); const [searchParams] = useSearchParams(); const ticketIdFromQuery = useMemo(() => { const a = searchParams.get("ticket_id")?.trim(); const b = searchParams.get("ticketId")?.trim(); return a || b || ""; }, [searchParams]); const [summary, setSummary] = useState<{ name: string; subtitle?: string; startDate?: string; location?: string } | null>(null); const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [urlTicketError, setUrlTicketError] = useState(null); const [ticketId, setTicketId] = useState(""); const [quantity, setQuantity] = useState(1); const [billingState, setBillingState] = useState(""); const [purchaserEmail, setPurchaserEmail] = useState(""); const [attendees, setAttendees] = useState([emptyAttendee()]); const [quoteTotal, setQuoteTotal] = useState(null); const [quoteBreakdown, setQuoteBreakdown] = useState(null); const [quoteBusy, setQuoteBusy] = useState(false); const [payBusy, setPayBusy] = useState(false); const [done, setDone] = useState(false); const [payMode, setPayMode] = useState<"loading" | "online" | "manual" | "both" | "none">("loading"); /** When payMode is "both", whether the buyer uses Razorpay or manual UPI for this order. */ const [checkoutChannel, setCheckoutChannel] = useState<"online" | "manual">("online"); const [manualUpi, setManualUpi] = useState(null); const isManualCheckout = useMemo(() => { if (payMode === "manual") return true; if (payMode === "both") return checkoutChannel === "manual"; return false; }, [payMode, checkoutChannel]); const [proofFile, setProofFile] = useState(null); const [buyerNote, setBuyerNote] = useState(""); const [manualDone, setManualDone] = useState(false); const [dynamicQrDataUrl, setDynamicQrDataUrl] = useState(null); const selected = useMemo(() => tickets.find((t) => t._id === ticketId), [tickets, ticketId]); /** Valid `ticket_id` / `ticketId` in URL: hide the card grid and use this ticket only. */ const hideTicketChooser = Boolean( ticketIdFromQuery && tickets.some((t) => t._id === ticketIdFromQuery), ); useEffect(() => { if (!eventId) return; let cancelled = false; setLoading(true); setErr(null); setUrlTicketError(null); setPayMode("loading"); void Promise.all([ publicEventTicketsApi.getEventSummary(eventId), publicEventTicketsApi.listTickets(eventId), publicEventTicketsApi.checkoutOptions(eventId), ]) .then(([s, tix, opts]) => { if (cancelled) return; setSummary(s); setTickets(tix); if (opts.onlineAvailable && opts.manualUpi) { setPayMode("both"); setCheckoutChannel("online"); setManualUpi(opts.manualUpi); } else if (opts.onlineAvailable) { setPayMode("online"); setManualUpi(null); } else if (opts.manualUpi) { setPayMode("manual"); setManualUpi(opts.manualUpi); } else { setPayMode("none"); setManualUpi(null); } const fromQ = ticketIdFromQuery; if (fromQ) { const found = tix.some((t) => t._id === fromQ); if (found) { setTicketId(fromQ); } else { setUrlTicketError("This ticket link is invalid or no longer available."); if (tix[0]) setTicketId(tix[0]._id); } } else if (tix[0]) { setTicketId(tix[0]._id); } }) .catch((e) => { if (!cancelled) setErr(e instanceof Error ? e.message : "Failed to load"); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [eventId, ticketIdFromQuery]); useEffect(() => { setAttendees((prev) => { const next: AttendeeRow[] = []; for (let i = 0; i < quantity; i++) { next.push(prev[i] ? { ...prev[i], formData: { ...prev[i].formData } } : emptyAttendee()); } return next; }); }, [quantity]); useEffect(() => { if (!eventId || !ticketId || payMode === "loading" || payMode === "none") return; let cancelled = false; setQuoteBusy(true); const t = selected; const needState = Boolean(t?.requiresBillingState); const payload = { ticketId, quantity, billingStateCode: needState ? billingState : billingState || undefined, }; const req = isManualCheckout ? publicEventTicketsApi.manualQuote(eventId, payload) : publicEventTicketsApi.quote(eventId, payload); void req .then((q) => { if (!cancelled) { setQuoteTotal(q.totalMinor); setQuoteBreakdown(q); } }) .catch(() => { if (!cancelled) { setQuoteTotal(null); setQuoteBreakdown(null); } }) .finally(() => { if (!cancelled) setQuoteBusy(false); }); return () => { cancelled = true; }; }, [eventId, ticketId, quantity, billingState, selected, payMode, isManualCheckout]); useEffect(() => { if (!isManualCheckout || !manualUpi || manualUpi.manualQrMode !== "dynamic") { setDynamicQrDataUrl(null); return; } const pa = manualUpi.upiId?.trim(); const eventName = (manualUpi.eventName || summary?.name || "").trim(); if (!pa || !selected?.name || !eventName || quoteTotal == null) { setDynamicQrDataUrl(null); return; } const pn = `${selected.name} · ${eventName}`.slice(0, 99); const am = (quoteTotal / 100).toFixed(2); const uri = buildUpiPayUri({ pa, pn, am }); if (!uri) { setDynamicQrDataUrl(null); return; } let cancelled = false; void QRCode.toDataURL(uri, { margin: 1, width: 280, errorCorrectionLevel: "M" }).then((url) => { if (!cancelled) setDynamicQrDataUrl(url); }); return () => { cancelled = true; }; }, [isManualCheckout, manualUpi, selected?.name, summary?.name, quoteTotal]); const updateAttendee = (i: number, patch: Partial) => { setAttendees((rows) => { const next = [...rows]; next[i] = { ...next[i], ...patch }; return next; }); }; const updateFormData = (i: number, label: string, value: string) => { setAttendees((rows) => { const next = [...rows]; const fd = { ...next[i].formData, [label]: value }; next[i] = { ...next[i], formData: fd }; return next; }); }; const pay = async () => { if (!eventId || !ticketId || quoteTotal == null) return; const pe = purchaserEmail.trim(); if (!pe) { window.alert("Enter purchaser email (receipts)."); return; } for (let i = 0; i < attendees.length; i++) { const a = attendees[i]; if (!a.firstName.trim() || !a.lastName.trim() || !a.email.trim()) { window.alert(`Complete name and email for attendee ${i + 1}.`); return; } } const emails = attendees.map((a) => a.email.trim().toLowerCase()); if (new Set(emails).size !== emails.length) { window.alert("Each attendee must have a unique email."); return; } if (selected?.requiresBillingState && !billingState) { window.alert("Select billing state for tax."); return; } setPayBusy(true); try { if (isManualCheckout) { if (!proofFile) { window.alert("Attach a screenshot of your UPI payment."); setPayBusy(false); return; } const screenshotDataUrl = await fileToDataUrl(proofFile); await publicEventTicketsApi.manualSubmit(eventId, { ticketId, quantity, billingStateCode: billingState || undefined, purchaserEmail: pe, expectedTotalMinor: quoteTotal, screenshotDataUrl, buyerNote: buyerNote.trim() || undefined, attendees: attendees.map((a) => ({ firstName: a.firstName.trim(), lastName: a.lastName.trim(), email: a.email.trim(), phone: a.phone.trim() || undefined, organization: a.organization.trim() || undefined, designation: a.designation.trim() || undefined, displayName: a.displayName.trim() || undefined, formData: a.formData, })), }); setManualDone(true); return; } const idempotencyKey = `tix-${eventId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const order = await publicEventTicketsApi.checkout(eventId, { ticketId, quantity, billingStateCode: billingState || undefined, purchaserEmail: pe, expectedTotalMinor: quoteTotal, idempotencyKey, attendees: attendees.map((a) => ({ firstName: a.firstName.trim(), lastName: a.lastName.trim(), email: a.email.trim(), phone: a.phone.trim() || undefined, organization: a.organization.trim() || undefined, designation: a.designation.trim() || undefined, displayName: a.displayName.trim() || undefined, formData: a.formData, })), }); await loadRazorpayScript(); 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; name?: 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: summary?.name || "Event", description: selected?.name || "Ticket", handler: (response: RazorpayResponse) => { invoked = true; void publicEventTicketsApi .verifyPayment(eventId, { 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: T.primary }, prefill: { email: pe, name: `${attendees[0]?.firstName || ""} ${attendees[0]?.lastName || ""}`.trim() || undefined, }, }); rzp.on("payment.failed", (response: { error: { description?: string } }) => { reject(new Error(response.error?.description || "Payment failed")); }); rzp.open(); }); setDone(true); } catch (e) { const msg = e instanceof Error ? e.message : "Checkout failed"; if (msg !== PAYMENT_CANCELLED_MESSAGE) { window.alert(msg); } } finally { setPayBusy(false); } }; if (!eventId) { return

Invalid link.

; } if (loading) { return (
Loading…
); } if (err || !tickets.length) { return (

Tickets

Unavailable

{err || "There are no active tickets for this event."}

); } if (!loading && payMode === "none") { return (

Event

{summary?.name || "Event"}

Ticketing is not available yet: the organiser has not connected their Razorpay account for tickets or enabled UPI checkout for this event.

); } const showExtraFields = (fields: PublicFormField[] | null | undefined) => (fields || []).filter((f) => f.label && !isCoreField(f)); const inputStyle: React.CSSProperties = { width: "100%", padding: "12px 14px", borderRadius: 0, border: `1px solid ${T.border}`, fontFamily: T.sans, fontSize: "0.9375rem", color: T.ink, background: T.card, boxSizing: "border-box", }; const labelStyle: React.CSSProperties = { display: "block", fontSize: "0.6875rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", color: T.muted, marginBottom: 8, }; return (

Event tickets

{summary?.name || "Event"}

{summary?.subtitle ?

{summary.subtitle}

: null} {(summary?.startDate || summary?.location) && (

{summary?.startDate ? new Date(summary.startDate).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : ""} {summary?.location ? ` · ${summary.location}` : ""}

)}
{manualDone ? (

Submitted

We received your proof

The organiser will review your payment and confirm your registration. You will get an email when your ticket is approved.

) : done ? (

Confirmed

Thank you

Your payment was successful. You should receive a confirmation email shortly.

) : ( <> {urlTicketError && (
{urlTicketError}
)} {(payMode === "both" || payMode === "online") && (

Payment method

{payMode === "both" ? <>

Pay with the organiser's Razorpay checkout, or use the UPI details they set up for this event.

:

You will pay through the organiser's connected Razorpay account (not a platform wallet).

}
)} {!hideTicketChooser && (

Choose a ticket

Select one option. You can change your choice before paying.

{tickets.map((t) => { const isSelected = ticketId === t._id; return ( ); })}
)}
{selected && ( <>

Your selection

{selected.name}

{selected.description && (

{selected.description}

)} {selected.keyPoints?.length ?

Key details

    {selected.keyPoints.map((k, i) => (
  • {k}
  • ))}
: null} )}
Quantity
{quantity}
Tickets available

Complete checkout to secure your spot.

{selected?.requiresBillingState && ( <> )} setPurchaserEmail(e.target.value)} placeholder="you@example.com" style={{ ...inputStyle, marginBottom: 0 }} />
{attendees.map((a, i) => (

Attendee {i + 1}

updateAttendee(i, { firstName: e.target.value })} style={{ ...inputStyle }} /> updateAttendee(i, { lastName: e.target.value })} style={{ ...inputStyle }} /> updateAttendee(i, { email: e.target.value })} style={{ ...inputStyle, gridColumn: "1 / -1" }} />
{showExtraFields(selected?.registrationFormFields).map((f) => (
{f.type === "select" || f.type === "radio" ? ( f.type === "select" && f.allowOther ? (
updateFormData(i, f.label!, v)} options={f.options || []} allowOther otherLabel={f.otherLabel} required={!!f.required} inputStyle={{ ...inputStyle, marginBottom: 0 }} />
) : ( ) ) : ( updateFormData(i, f.label!, e.target.value)} style={{ ...inputStyle, marginTop: 4 }} /> )}
))}
))}
Total {quoteBusy ? "· updating…" : ""}
{quoteTotal != null ? formatMinor(quoteTotal, selected?.currency || quoteBreakdown?.currency || "INR") : "—"}
{quoteBreakdown && quoteTotal != null ?
Line items
Subtotal {quoteBreakdown.quantity > 1 ? ({quoteBreakdown.quantity} tickets) : null} {formatMinor(quoteBreakdown.baseMinor, quoteBreakdown.currency)}
{quoteBreakdown.taxLines.map((line, i) => (
{line.label} {line.rateBps > 0 ? {" "} ({(line.rateBps / 100).toLocaleString("en-IN", { maximumFractionDigits: 2 })}%) : null} {formatMinor(line.amountMinor, quoteBreakdown.currency)}
))}
: null}
{isManualCheckout && manualUpi && (

Pay with UPI

Pay the total amount above in your UPI app (ticket and tax only). Then attach your payment screenshot below.

{(manualUpi.manualQrMode === "dynamic" ? manualUpi.upiId : manualUpi.payeeName || manualUpi.upiId) ?

{manualUpi.manualQrMode === "upload" && manualUpi.payeeName ? {manualUpi.payeeName} : null} {manualUpi.upiId ? {manualUpi.manualQrMode === "upload" && manualUpi.payeeName ? " · " : ""} {manualUpi.upiId} : null}

: null} {manualUpi.instructions ?

{manualUpi.instructions}

: null}
{manualUpi.manualQrMode === "dynamic" ? dynamicQrDataUrl ? UPI QR :

Choose ticket, quantity, and tax — your QR updates with the exact amount.

: manualUpi.upiQrImageUrl ? UPI QR : null}
)} {isManualCheckout && (
setProofFile(e.target.files?.[0] ?? null)} style={{ marginBottom: 16, fontFamily: T.sans, fontSize: "0.875rem" }} />