Eventbuzz_Web/src/components/forms/RegistrationTicketStep.tsx

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