EventBuzz_Console/src/views/EventTicketCheckoutView.tsx

1246 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string>;
};
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<string> {
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<PublicTicketCatalogItem[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [urlTicketError, setUrlTicketError] = useState<string | null>(null);
const [ticketId, setTicketId] = useState<string>("");
const [quantity, setQuantity] = useState(1);
const [billingState, setBillingState] = useState("");
const [purchaserEmail, setPurchaserEmail] = useState("");
const [attendees, setAttendees] = useState<AttendeeRow[]>([emptyAttendee()]);
const [quoteTotal, setQuoteTotal] = useState<number | null>(null);
const [quoteBreakdown, setQuoteBreakdown] = useState<TicketQuote | null>(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<PublicCheckoutOptions["manualUpi"]>(null);
const isManualCheckout = useMemo(() => {
if (payMode === "manual") return true;
if (payMode === "both") return checkoutChannel === "manual";
return false;
}, [payMode, checkoutChannel]);
const [proofFile, setProofFile] = useState<File | null>(null);
const [buyerNote, setBuyerNote] = useState("");
const [manualDone, setManualDone] = useState(false);
const [dynamicQrDataUrl, setDynamicQrDataUrl] = useState<string | null>(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<AttendeeRow>) => {
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<void>((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 <p style={{ padding: 24, fontFamily: "system-ui" }}>Invalid link.</p>;
}
if (loading) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: T.sans,
background: T.pageBg,
color: T.muted,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontSize: "0.75rem",
}}
>
Loading…
</div>
);
}
if (err || !tickets.length) {
return (
<div
style={{
minHeight: "100vh",
padding: "48px 24px",
fontFamily: T.sans,
maxWidth: 560,
margin: "0 auto",
background: T.pageBg,
color: T.body,
}}
>
<p style={{ fontSize: "0.75rem", fontWeight: 600, letterSpacing: "0.18em", textTransform: "uppercase", color: T.accent, margin: "0 0 12px" }}>
Tickets
</p>
<h1 style={{ fontFamily: T.serif, fontSize: "2.25rem", fontWeight: 400, color: T.ink, margin: "0 0 16px", lineHeight: 1.15 }}>
Unavailable
</h1>
<p style={{ margin: 0, lineHeight: 1.6 }}>{err || "There are no active tickets for this event."}</p>
</div>
);
}
if (!loading && payMode === "none") {
return (
<div
style={{
minHeight: "100vh",
padding: "48px 24px",
fontFamily: T.sans,
maxWidth: 560,
margin: "0 auto",
background: T.pageBg,
color: T.body,
}}
>
<p style={{ fontSize: "0.75rem", fontWeight: 600, letterSpacing: "0.18em", textTransform: "uppercase", color: T.accent, margin: "0 0 12px" }}>
Event
</p>
<h1 style={{ fontFamily: T.serif, fontSize: "2.5rem", fontWeight: 400, color: T.ink, margin: "0 0 16px", lineHeight: 1.1 }}>
{summary?.name || "Event"}
</h1>
<p style={{ margin: 0, lineHeight: 1.65 }}>
Ticketing is not available yet: the organiser has not connected their Razorpay account for tickets or enabled UPI checkout for this
event.
</p>
</div>
);
}
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 (
<div
style={{
minHeight: "100vh",
background: T.pageBg,
color: T.ink,
fontFamily: T.sans,
padding: "clamp(24px, 4vw, 51px) clamp(16px, 4vw, 51px) 64px",
}}
>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<header style={{ marginBottom: 36 }}>
<p
style={{
fontSize: "0.76rem",
fontWeight: 500,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: T.accent,
margin: "0 0 10px",
}}
>
Event tickets
</p>
<h1
style={{
fontFamily: T.serif,
fontSize: "clamp(2.25rem, 6vw, 3.25rem)",
fontWeight: 300,
lineHeight: 1.08,
letterSpacing: "-0.02em",
margin: "0 0 14px",
}}
>
{summary?.name || "Event"}
</h1>
{summary?.subtitle ?
<p style={{ margin: 0, color: T.body, fontSize: "1rem", fontWeight: 500, lineHeight: 1.55, maxWidth: 540 }}>
{summary.subtitle}
</p>
: null}
{(summary?.startDate || summary?.location) && (
<p style={{ margin: "14px 0 0", fontSize: "0.9rem", color: T.muted, fontWeight: 400 }}>
{summary?.startDate ? new Date(summary.startDate).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : ""}
{summary?.location ? ` · ${summary.location}` : ""}
</p>
)}
</header>
{manualDone ? (
<div
style={{
background: T.cream,
padding: "40px 28px",
borderLeft: `3px solid ${T.accent}`,
textAlign: "center",
}}
>
<p style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.2em", textTransform: "uppercase", color: T.accent, margin: "0 0 12px" }}>
Submitted
</p>
<h2 style={{ fontFamily: T.serif, fontSize: "2rem", fontWeight: 400, margin: "0 0 16px", color: T.ink }}>We received your proof</h2>
<p style={{ color: T.body, margin: 0, lineHeight: 1.65, maxWidth: 420, marginLeft: "auto", marginRight: "auto" }}>
The organiser will review your payment and confirm your registration. You will get an email when your ticket is approved.
</p>
</div>
) : done ? (
<div
style={{
background: T.cream,
padding: "40px 28px",
borderLeft: `3px solid ${T.success}`,
textAlign: "center",
}}
>
<p style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.2em", textTransform: "uppercase", color: T.success, margin: "0 0 12px" }}>
Confirmed
</p>
<h2 style={{ fontFamily: T.serif, fontSize: "2rem", fontWeight: 400, margin: "0 0 16px", color: T.ink }}>Thank you</h2>
<p style={{ color: T.body, margin: 0, lineHeight: 1.65 }}>Your payment was successful. You should receive a confirmation email shortly.</p>
</div>
) : (
<>
{urlTicketError && (
<div
style={{
marginBottom: 20,
padding: "16px 20px",
background: T.card,
border: `1px solid ${T.accent}`,
color: T.ink,
fontSize: "0.875rem",
lineHeight: 1.5,
}}
>
{urlTicketError}
</div>
)}
{(payMode === "both" || payMode === "online") && (
<section
style={{
background: T.card,
padding: "24px 22px",
border: `1px solid ${T.borderHairline}`,
marginBottom: 24,
}}
>
<h2 style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.22em", textTransform: "uppercase", color: T.accent, margin: "0 0 14px" }}>
Payment method
</h2>
{payMode === "both" ?
<>
<p style={{ fontSize: "0.9375rem", color: T.body, margin: "0 0 16px", lineHeight: 1.55, fontWeight: 500 }}>
Pay with the organiser&apos;s Razorpay checkout, or use the UPI details they set up for this event.
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
<button
type="button"
onClick={() => setCheckoutChannel("online")}
style={{
padding: "12px 20px",
borderRadius: 0,
border: "none",
background: checkoutChannel === "online" ? T.primary : T.cream,
color: checkoutChannel === "online" ? "#fff" : T.ink,
fontWeight: 600,
cursor: "pointer",
fontSize: "0.6875rem",
letterSpacing: "0.14em",
textTransform: "uppercase",
fontFamily: T.sans,
}}
>
Card / UPI (Razorpay)
</button>
<button
type="button"
onClick={() => setCheckoutChannel("manual")}
style={{
padding: "12px 20px",
borderRadius: 0,
border: "none",
background: checkoutChannel === "manual" ? T.express : T.cream,
color: checkoutChannel === "manual" ? "#fff" : T.ink,
fontWeight: 600,
cursor: "pointer",
fontSize: "0.6875rem",
letterSpacing: "0.14em",
textTransform: "uppercase",
fontFamily: T.sans,
}}
>
UPI (organiser QR)
</button>
</div>
</>
: <p style={{ fontSize: "0.9375rem", color: T.body, margin: 0, lineHeight: 1.55, fontWeight: 500 }}>
You will pay through the organiser&apos;s connected Razorpay account (not a platform wallet).
</p>}
</section>
)}
{!hideTicketChooser && (
<section style={{ marginBottom: 28 }}>
<h2 style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.22em", textTransform: "uppercase", color: T.accent, margin: "0 0 8px" }}>
Choose a ticket
</h2>
<p style={{ fontSize: "0.9375rem", color: T.body, margin: "0 0 20px", lineHeight: 1.55, fontWeight: 500 }}>
Select one option. You can change your choice before paying.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: 14,
}}
>
{tickets.map((t) => {
const isSelected = ticketId === t._id;
return (
<button
key={t._id}
type="button"
onClick={() => setTicketId(t._id)}
style={{
textAlign: "left",
cursor: "pointer",
padding: "20px 18px",
borderRadius: 0,
border: isSelected ? `2px solid ${T.primary}` : `1px solid ${T.border}`,
background: isSelected ? T.cream : T.card,
transition: "border-color 0.15s, background 0.15s",
display: "flex",
flexDirection: "column",
gap: 12,
minHeight: 140,
fontFamily: T.sans,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<span style={{ fontFamily: T.serif, fontSize: "1.35rem", fontWeight: 600, color: T.ink, lineHeight: 1.25 }}>{t.name}</span>
{isSelected && (
<span
style={{
flexShrink: 0,
fontSize: "0.625rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.12em",
color: "#fff",
background: T.primary,
padding: "6px 10px",
}}
>
Selected
</span>
)}
</div>
<div
style={{
fontFamily: T.serif,
fontSize: "1.75rem",
fontWeight: 400,
color: T.ink,
borderBottom: `1px solid ${T.borderHairline}`,
paddingBottom: 12,
}}
>
{(t.basePriceMinor / 100).toLocaleString("en-IN", { minimumFractionDigits: 2 })}{" "}
<span style={{ fontSize: "1rem", fontFamily: T.sans, fontWeight: 500 }}>{t.currency}</span>
<span style={{ fontSize: "0.75rem", fontFamily: T.sans, fontWeight: 500, color: T.muted, display: "block", marginTop: 6 }}>
{t.requiresBillingState ? "Tax depends on billing state" : "Tax per rules below"}
</span>
</div>
{t.description ?
<p
style={{
fontSize: "0.875rem",
color: T.body,
margin: 0,
lineHeight: 1.5,
fontWeight: 500,
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{t.description}
</p>
: null}
{t.keyPoints?.length ?
<ul style={{ fontSize: "0.75rem", color: T.ink, margin: 0, paddingLeft: 16, lineHeight: 1.45, fontWeight: 500 }}>
{t.keyPoints.slice(0, 4).map((k, i) => (
<li key={i}>{k}</li>
))}
{t.keyPoints.length > 4 ?
<li style={{ color: T.muted }}>+{t.keyPoints.length - 4} more…</li>
: null}
</ul>
: null}
</button>
);
})}
</div>
</section>
)}
<section
style={{
background: T.card,
padding: "24px 22px",
border: `1px solid ${T.borderHairline}`,
marginBottom: 20,
}}
>
{selected && (
<>
<h3 style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.22em", textTransform: "uppercase", color: T.accent, margin: "0 0 12px" }}>
Your selection
</h3>
<p style={{ fontFamily: T.serif, fontSize: "1.85rem", fontWeight: 400, color: T.ink, margin: "0 0 12px", lineHeight: 1.15 }}>
{selected.name}
</p>
{selected.description && (
<p style={{ fontSize: "0.9375rem", color: T.body, margin: "0 0 20px", lineHeight: 1.55, fontWeight: 500 }}>{selected.description}</p>
)}
{selected.keyPoints?.length ?
<div
style={{
background: T.cream,
borderLeft: `3px solid ${T.accent}`,
padding: "20px 20px 20px 22px",
marginBottom: 24,
}}
>
<p style={{ fontSize: "0.6875rem", fontWeight: 600, letterSpacing: "0.2em", textTransform: "uppercase", color: T.accent, margin: "0 0 14px" }}>
Key details
</p>
<ul style={{ fontSize: "0.875rem", color: T.body, margin: 0, paddingLeft: 18, lineHeight: 1.55, fontWeight: 500 }}>
{selected.keyPoints.map((k, i) => (
<li key={i} style={{ marginBottom: 6 }}>
{k}
</li>
))}
</ul>
</div>
: null}
</>
)}
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-end", gap: 16, marginBottom: 20 }}>
<div>
<span style={labelStyle}>Quantity</span>
<div
style={{
display: "flex",
alignItems: "stretch",
border: `1px solid ${T.border}`,
height: 52,
maxWidth: 140,
}}
>
<button
type="button"
aria-label="Decrease quantity"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
style={{
width: 44,
border: "none",
background: T.card,
cursor: "pointer",
fontSize: "1.25rem",
color: T.ink,
fontFamily: T.sans,
}}
>
−
</button>
<span
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 600,
fontSize: "1rem",
borderLeft: `1px solid ${T.border}`,
borderRight: `1px solid ${T.border}`,
}}
>
{quantity}
</span>
<button
type="button"
aria-label="Increase quantity"
onClick={() =>
setQuantity((q) => Math.min(selected?.maxPerOrder || 100, q + 1))
}
style={{
width: 44,
border: "none",
background: T.card,
cursor: "pointer",
fontSize: "1.25rem",
color: T.ink,
fontFamily: T.sans,
}}
>
+
</button>
</div>
</div>
<div style={{ flex: "1 1 200px", minWidth: 0 }}>
<span style={{ ...labelStyle, display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: T.success,
flexShrink: 0,
}}
/>
Tickets available
</span>
<p style={{ margin: 0, fontSize: "0.875rem", color: T.success, fontWeight: 500 }}>Complete checkout to secure your spot.</p>
</div>
</div>
{selected?.requiresBillingState && (
<>
<label style={labelStyle}>Billing state (tax)</label>
<select value={billingState} onChange={(e) => setBillingState(e.target.value)} style={{ ...inputStyle, marginBottom: 18 }}>
<option value="">Select state…</option>
{INDIAN_STATES.map((s) => (
<option key={s.code} value={s.code}>
{s.name} ({s.code})
</option>
))}
</select>
</>
)}
<label style={labelStyle}>Purchaser email (receipts)</label>
<input
type="email"
value={purchaserEmail}
onChange={(e) => setPurchaserEmail(e.target.value)}
placeholder="you@example.com"
style={{ ...inputStyle, marginBottom: 0 }}
/>
</section>
{attendees.map((a, i) => (
<section
key={i}
style={{
background: T.card,
padding: "22px 22px",
border: `1px solid ${T.borderHairline}`,
marginBottom: 16,
}}
>
<h3 style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.18em", textTransform: "uppercase", color: T.muted, margin: "0 0 16px" }}>
Attendee {i + 1}
</h3>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<input
placeholder="First name"
value={a.firstName}
onChange={(e) => updateAttendee(i, { firstName: e.target.value })}
style={{ ...inputStyle }}
/>
<input
placeholder="Last name"
value={a.lastName}
onChange={(e) => updateAttendee(i, { lastName: e.target.value })}
style={{ ...inputStyle }}
/>
<input
type="email"
placeholder="Email"
value={a.email}
onChange={(e) => updateAttendee(i, { email: e.target.value })}
style={{ ...inputStyle, gridColumn: "1 / -1" }}
/>
</div>
{showExtraFields(selected?.registrationFormFields).map((f) => (
<div key={f.label} style={{ marginTop: 14 }}>
<label style={{ ...labelStyle, textTransform: "none", letterSpacing: "0.04em", color: T.body }}>
{f.label}
{f.required ? " *" : ""}
</label>
{f.type === "select" || f.type === "radio" ? (
f.type === "select" && f.allowOther ? (
<div style={{ marginTop: 4 }}>
<SelectWithOtherInput
value={a.formData[f.label!] || ""}
onChange={(v) => updateFormData(i, f.label!, v)}
options={f.options || []}
allowOther
otherLabel={f.otherLabel}
required={!!f.required}
inputStyle={{ ...inputStyle, marginBottom: 0 }}
/>
</div>
) : (
<select
value={a.formData[f.label!] || ""}
onChange={(e) => updateFormData(i, f.label!, e.target.value)}
style={{ ...inputStyle, marginTop: 4 }}
>
<option value="">Choose…</option>
{(f.options || []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)
) : (
<input
type={f.type === "email" ? "email" : f.type === "phone" ? "tel" : "text"}
value={a.formData[f.label!] || ""}
onChange={(e) => updateFormData(i, f.label!, e.target.value)}
style={{ ...inputStyle, marginTop: 4 }}
/>
)}
</div>
))}
</section>
))}
<div
style={{
background: T.card,
padding: "24px 22px",
border: `1px solid ${T.borderHairline}`,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 16,
paddingBottom: quoteBreakdown && quoteTotal != null ? 18 : 0,
borderBottom: quoteBreakdown && quoteTotal != null ? `1px solid ${T.borderHairline}` : "none",
}}
>
<div>
<div style={{ fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.16em", textTransform: "uppercase", color: T.muted }}>
Total {quoteBusy ? "· updating…" : ""}
</div>
<div
style={{
fontFamily: T.serif,
fontSize: "2.35rem",
fontWeight: 400,
color: T.ink,
lineHeight: 1.1,
marginTop: 4,
}}
>
{quoteTotal != null ?
formatMinor(quoteTotal, selected?.currency || quoteBreakdown?.currency || "INR")
: "—"}
</div>
</div>
<button
type="button"
disabled={payBusy || quoteTotal == null || quoteBusy}
onClick={() => void pay()}
style={{
padding: "16px 36px",
borderRadius: 0,
border: "none",
background: T.primary,
color: "#fff",
fontWeight: 700,
fontSize: "0.8125rem",
letterSpacing: "0.18em",
textTransform: "uppercase",
fontFamily: T.sans,
cursor: payBusy ? "wait" : "pointer",
opacity: quoteTotal == null ? 0.45 : 1,
}}
>
{payBusy ? "Processing…" : isManualCheckout ? "Submit proof" : "Pay now"}
</button>
</div>
{quoteBreakdown && quoteTotal != null ?
<div style={{ marginTop: 20 }}>
<div
style={{
fontSize: "0.6875rem",
fontWeight: 600,
color: T.accent,
textTransform: "uppercase",
letterSpacing: "0.2em",
marginBottom: 12,
}}
>
Line items
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
fontSize: "0.9375rem",
color: T.ink,
fontWeight: 500,
}}
>
<span>
Subtotal
{quoteBreakdown.quantity > 1 ?
<span style={{ color: T.muted, fontWeight: 400 }}> ({quoteBreakdown.quantity} tickets)</span>
: null}
</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{formatMinor(quoteBreakdown.baseMinor, quoteBreakdown.currency)}
</span>
</div>
{quoteBreakdown.taxLines.map((line, i) => (
<div
key={`${line.label}-${i}`}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
fontSize: "0.9375rem",
color: T.ink,
fontWeight: 500,
}}
>
<span>
{line.label}
{line.rateBps > 0 ?
<span style={{ color: T.muted, fontWeight: 400 }}>
{" "}
({(line.rateBps / 100).toLocaleString("en-IN", { maximumFractionDigits: 2 })}%)
</span>
: null}
</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{formatMinor(line.amountMinor, quoteBreakdown.currency)}
</span>
</div>
))}
</div>
</div>
: null}
</div>
{isManualCheckout && manualUpi && (
<section
style={{
background: T.express,
padding: "28px 24px",
marginTop: 20,
borderRadius: 24,
}}
>
<h2 style={{ fontSize: "0.7rem", fontWeight: 700, margin: "0 0 10px", color: "rgba(255,255,255,0.92)", letterSpacing: "0.2em", textTransform: "uppercase" }}>
Pay with UPI
</h2>
<p style={{ fontSize: "0.9375rem", color: "rgba(255,255,255,0.95)", margin: "0 0 16px", lineHeight: 1.55, fontWeight: 500 }}>
Pay the <strong style={{ fontWeight: 700 }}>total amount above</strong> in your UPI app (ticket and tax only). Then attach your payment
screenshot below.
</p>
{(manualUpi.manualQrMode === "dynamic" ? manualUpi.upiId : manualUpi.payeeName || manualUpi.upiId) ?
<p style={{ fontSize: "0.875rem", margin: "0 0 10px", color: "#fff" }}>
{manualUpi.manualQrMode === "upload" && manualUpi.payeeName ?
<strong>{manualUpi.payeeName}</strong>
: null}
{manualUpi.upiId ?
<span style={{ opacity: 0.9 }}>
{manualUpi.manualQrMode === "upload" && manualUpi.payeeName ? " · " : ""}
{manualUpi.upiId}
</span>
: null}
</p>
: null}
{manualUpi.instructions ?
<p style={{ fontSize: "0.8125rem", color: "rgba(255,255,255,0.88)", whiteSpace: "pre-wrap", margin: "0 0 16px", lineHeight: 1.5 }}>
{manualUpi.instructions}
</p>
: null}
<div style={{ background: "#fff", padding: 16, display: "inline-block", borderRadius: 8 }}>
{manualUpi.manualQrMode === "dynamic" ?
dynamicQrDataUrl ?
<img src={dynamicQrDataUrl} alt="UPI QR" style={{ maxWidth: 280, width: "100%", height: "auto", display: "block" }} />
: <p style={{ fontSize: "0.875rem", color: T.muted, margin: 0, maxWidth: 260 }}>
Choose ticket, quantity, and tax — your QR updates with the exact amount.
</p>
: manualUpi.upiQrImageUrl ?
<img src={manualUpi.upiQrImageUrl} alt="UPI QR" style={{ maxWidth: 280, width: "100%", height: "auto", display: "block" }} />
: null}
</div>
</section>
)}
{isManualCheckout && (
<section
style={{
background: T.cream,
padding: "24px 22px",
borderLeft: `3px solid ${T.accent}`,
marginTop: 20,
}}
>
<label style={{ ...labelStyle, color: T.accent }}>Payment screenshot</label>
<input
type="file"
accept="image/*"
onChange={(e) => setProofFile(e.target.files?.[0] ?? null)}
style={{ marginBottom: 16, fontFamily: T.sans, fontSize: "0.875rem" }}
/>
<label style={labelStyle}>Note to organiser (optional)</label>
<textarea
value={buyerNote}
onChange={(e) => setBuyerNote(e.target.value)}
rows={3}
placeholder="UPI ref, payer name, etc."
style={{ ...inputStyle, fontFamily: T.sans, resize: "vertical" as const }}
/>
</section>
)}
</>
)}
</div>
</div>
);
}