import { useCallback, useEffect, useMemo, useState } from 'react'; import QRCode from 'qrcode'; import { loadRazorpayScript, publicEventTicketsApi, type PublicTicketCatalogItem, type TicketAttendeePayload, type TicketQuote, } from '../../api/publicEventTickets'; import { formatTicketPrice, INDIAN_STATES } from '../../utils/registrationTicketAttendee'; import { buildUpiPayUri } from '../../utils/upiPayUri'; const PAYMENT_CANCELLED_MESSAGE = 'Payment cancelled'; type ManualUpi = NonNullable< Awaited>['manualUpi'] >; type Props = { eventId: string; formId: string; eventName?: string; tickets: PublicTicketCatalogItem[]; attendee: TicketAttendeePayload; onBack: () => void; onPaid: () => void; onPendingReview: (message: string) => void; }; export function RegistrationTicketStep({ eventId, formId, eventName, tickets, attendee, onBack, onPaid, onPendingReview, }: Props) { const [ticketId, setTicketId] = useState(tickets[0]?._id || ''); const [billingState, setBillingState] = useState(''); const [quote, setQuote] = useState(null); const [quoteError, setQuoteError] = useState(null); const [payError, setPayError] = useState(null); const [busy, setBusy] = useState(false); const [checkoutChannel, setCheckoutChannel] = useState<'online' | 'manual'>('online'); const [onlineAvailable, setOnlineAvailable] = useState(false); const [manualUpi, setManualUpi] = useState(null); const [screenshotDataUrl, setScreenshotDataUrl] = useState(''); const [buyerNote, setBuyerNote] = useState(''); const [dynamicQrDataUrl, setDynamicQrDataUrl] = useState(null); const selected = useMemo(() => tickets.find((t) => t._id === ticketId) || null, [tickets, ticketId]); const payMode = useMemo(() => { if (onlineAvailable && manualUpi) return 'both'; if (onlineAvailable) return 'online'; if (manualUpi) return 'manual'; return 'none'; }, [onlineAvailable, manualUpi]); const isManualCheckout = payMode === 'manual' || (payMode === 'both' && checkoutChannel === 'manual'); useEffect(() => { let cancelled = false; void publicEventTicketsApi .checkoutOptions(eventId) .then((opts) => { if (cancelled) return; setOnlineAvailable(!!opts.onlineAvailable); setManualUpi(opts.manualUpi || null); if (!opts.onlineAvailable && opts.manualUpi) setCheckoutChannel('manual'); }) .catch(() => { if (!cancelled) { setOnlineAvailable(false); setManualUpi(null); } }); return () => { cancelled = true; }; }, [eventId]); useEffect(() => { if (!ticketId || !selected) { setQuote(null); return; } if (selected.requiresBillingState && !billingState) { setQuote(null); setQuoteError(null); return; } let cancelled = false; setQuoteError(null); const run = async () => { try { const q = isManualCheckout ? await publicEventTicketsApi.manualQuote(eventId, { ticketId, quantity: 1, billingStateCode: billingState || undefined, }) : await publicEventTicketsApi.quote(eventId, { ticketId, quantity: 1, billingStateCode: billingState || undefined, }); if (!cancelled) setQuote(q); } catch (e) { if (!cancelled) { setQuote(null); setQuoteError(e instanceof Error ? e.message : 'Could not load price'); } } }; void run(); return () => { cancelled = true; }; }, [eventId, ticketId, billingState, selected, isManualCheckout]); useEffect(() => { if (!isManualCheckout || !manualUpi || manualUpi.manualQrMode !== 'dynamic') { setDynamicQrDataUrl(null); return; } const pa = manualUpi.upiId?.trim(); const evName = (manualUpi.eventName || eventName || '').trim(); const quoteTotal = quote?.totalMinor; if (!pa || !selected?.name || !evName || quoteTotal == null) { setDynamicQrDataUrl(null); return; } const pn = `${selected.name} · ${evName}`.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, eventName, quote?.totalMinor]); const onScreenshot = useCallback((file: File | null) => { if (!file) { setScreenshotDataUrl(''); return; } const reader = new FileReader(); reader.onload = () => setScreenshotDataUrl(typeof reader.result === 'string' ? reader.result : ''); reader.readAsDataURL(file); }, []); const pay = async () => { setPayError(null); if (!selected || !quote) { setPayError('Select a ticket and wait for the price.'); return; } if (selected.requiresBillingState && !billingState) { setPayError('Select your billing state.'); return; } if (!attendee.email || !attendee.firstName || !attendee.lastName) { setPayError('Registration details are incomplete. Go back and fill required fields.'); return; } if (payMode === 'none') { setPayError('Payment is not available for this event yet.'); return; } if (isManualCheckout && !screenshotDataUrl) { setPayError('Upload a payment screenshot to continue.'); return; } setBusy(true); try { const attendees = [attendee]; const pe = attendee.email; if (isManualCheckout) { const res = await publicEventTicketsApi.manualSubmit(eventId, { ticketId, quantity: 1, billingStateCode: billingState || undefined, purchaserEmail: pe, sourceFormId: formId, expectedTotalMinor: quote.totalMinor, screenshotDataUrl, buyerNote: buyerNote.trim() || undefined, attendees, }); onPendingReview(res.message || 'Submitted for review.'); return; } const order = await publicEventTicketsApi.checkout(eventId, { ticketId, quantity: 1, billingStateCode: billingState || undefined, purchaserEmail: pe, sourceFormId: formId, expectedTotalMinor: quote.totalMinor, idempotencyKey: `reg-${formId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, attendees, }); 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: eventName || 'Event', description: selected.name, 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: '#0f172a' }, prefill: { email: pe, name: `${attendee.firstName} ${attendee.lastName}`.trim(), }, }); rzp.on('payment.failed', (response: { error: { description?: string } }) => { reject(new Error(response.error?.description || 'Payment failed')); }); rzp.open(); }); onPaid(); } catch (e) { const msg = e instanceof Error ? e.message : 'Checkout failed'; if (msg !== PAYMENT_CANCELLED_MESSAGE) setPayError(msg); } finally { setBusy(false); } }; if (!tickets.length) { return (

No tickets are available for this form.

); } return (

Step 2 of 2 — Choose your ticket

{tickets.map((t) => { const active = t._id === ticketId; return ( ); })}
{selected?.requiresBillingState ? ( ) : null} {payMode === 'both' ? (
) : null} {isManualCheckout && manualUpi ? (
{manualUpi.upiId ?

UPI ID: {manualUpi.upiId}

: null} {manualUpi.instructions ?

{manualUpi.instructions}

: null}
{manualUpi.manualQrMode === 'dynamic' ? ( dynamicQrDataUrl ? ( UPI QR ) : (

Select a ticket to generate the UPI QR with the exact amount.

) ) : manualUpi.upiQrImageUrl ? ( UPI QR ) : null}
) : null} {quoteError ?

{quoteError}

: null} {quote ? (

Total: {formatTicketPrice(quote.totalMinor, quote.currency)} {quote.taxMinor > 0 ? ( (incl. tax {formatTicketPrice(quote.taxMinor, quote.currency)}) ) : null}

) : null} {payError ?

{payError}

: null}
); }