407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
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<ReturnType<typeof publicEventTicketsApi.checkoutOptions>>['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<TicketQuote | null>(null);
|
|
const [quoteError, setQuoteError] = useState<string | null>(null);
|
|
const [payError, setPayError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [checkoutChannel, setCheckoutChannel] = useState<'online' | 'manual'>('online');
|
|
const [onlineAvailable, setOnlineAvailable] = useState(false);
|
|
const [manualUpi, setManualUpi] = useState<ManualUpi | null>(null);
|
|
const [screenshotDataUrl, setScreenshotDataUrl] = useState('');
|
|
const [buyerNote, setBuyerNote] = useState('');
|
|
const [dynamicQrDataUrl, setDynamicQrDataUrl] = useState<string | null>(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<void>((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 (
|
|
<div className="form-panel">
|
|
<p className="form-error">No tickets are available for this form.</p>
|
|
<button type="button" className="form-submit" onClick={onBack}>
|
|
Back
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="form-panel form-ticket-step">
|
|
<p className="form-ticket-step-label">Step 2 of 2 — Choose your ticket</p>
|
|
<div className="form-ticket-list">
|
|
{tickets.map((t) => {
|
|
const active = t._id === ticketId;
|
|
return (
|
|
<button
|
|
key={t._id}
|
|
type="button"
|
|
className={`form-ticket-card${active ? ' form-ticket-card--active' : ''}`}
|
|
onClick={() => setTicketId(t._id)}
|
|
>
|
|
<span className="form-ticket-card-name">{t.name}</span>
|
|
<span className="form-ticket-card-price">
|
|
{formatTicketPrice(t.basePriceMinor, t.currency || 'INR')}
|
|
</span>
|
|
{t.description?.trim() ? (
|
|
<span className="form-ticket-card-desc">{t.description.trim()}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{selected?.requiresBillingState ? (
|
|
<label className="form-field">
|
|
<span className="form-label">Billing state</span>
|
|
<select
|
|
className="form-input"
|
|
value={billingState}
|
|
onChange={(e) => setBillingState(e.target.value)}
|
|
>
|
|
<option value="">Select state…</option>
|
|
{INDIAN_STATES.map((s) => (
|
|
<option key={s.code} value={s.code}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
|
|
{payMode === 'both' ? (
|
|
<div className="form-ticket-channels">
|
|
<button
|
|
type="button"
|
|
className={`form-ticket-channel${checkoutChannel === 'online' ? ' form-ticket-channel--active' : ''}`}
|
|
onClick={() => setCheckoutChannel('online')}
|
|
>
|
|
Card / UPI (Razorpay)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`form-ticket-channel${checkoutChannel === 'manual' ? ' form-ticket-channel--active' : ''}`}
|
|
onClick={() => setCheckoutChannel('manual')}
|
|
>
|
|
Manual UPI
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{isManualCheckout && manualUpi ? (
|
|
<div className="form-ticket-manual">
|
|
{manualUpi.upiId ? <p className="form-ticket-upi-id">UPI ID: {manualUpi.upiId}</p> : null}
|
|
{manualUpi.instructions ? <p className="form-desc">{manualUpi.instructions}</p> : null}
|
|
<div className="form-ticket-qr-wrap">
|
|
{manualUpi.manualQrMode === 'dynamic' ? (
|
|
dynamicQrDataUrl ? (
|
|
<img src={dynamicQrDataUrl} alt="UPI QR" className="form-ticket-qr" />
|
|
) : (
|
|
<p className="form-desc">Select a ticket to generate the UPI QR with the exact amount.</p>
|
|
)
|
|
) : manualUpi.upiQrImageUrl ? (
|
|
<img src={manualUpi.upiQrImageUrl} alt="UPI QR" className="form-ticket-qr" />
|
|
) : null}
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="form-label">Payment screenshot</span>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="form-input"
|
|
onChange={(e) => onScreenshot(e.target.files?.[0] || null)}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="form-label">Note (optional)</span>
|
|
<input
|
|
className="form-input"
|
|
value={buyerNote}
|
|
onChange={(e) => setBuyerNote(e.target.value)}
|
|
placeholder="Transaction reference"
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{quoteError ? <p className="form-error">{quoteError}</p> : null}
|
|
{quote ? (
|
|
<p className="form-ticket-total">
|
|
Total: <strong>{formatTicketPrice(quote.totalMinor, quote.currency)}</strong>
|
|
{quote.taxMinor > 0 ? (
|
|
<span className="form-ticket-tax"> (incl. tax {formatTicketPrice(quote.taxMinor, quote.currency)})</span>
|
|
) : null}
|
|
</p>
|
|
) : null}
|
|
{payError ? <p className="form-error">{payError}</p> : null}
|
|
|
|
<div className="form-ticket-actions">
|
|
<button type="button" className="form-submit form-submit--secondary" onClick={onBack} disabled={busy}>
|
|
Back
|
|
</button>
|
|
<button type="button" className="form-submit" onClick={() => void pay()} disabled={busy || !quote || payMode === 'none'}>
|
|
{busy ? 'Processing…' : isManualCheckout ? 'Submit payment proof' : 'Pay & register'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|