1246 lines
48 KiB
TypeScript
1246 lines
48 KiB
TypeScript
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'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'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>
|
||
);
|
||
}
|