Some checks failed
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Has been cancelled
Centralize primary, secondary, tertiary, and neutral tokens and apply them across theme variables and UI components. Co-authored-by: Cursor <cursoragent@cursor.com>
216 lines
7.4 KiB
TypeScript
216 lines
7.4 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { ticketTiers, paymentMethods } from "@/content/tickets";
|
||
import { calculateTotal } from "@/lib/payment";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { DataConsentField } from "@/components/forms/DataConsentField";
|
||
import { dataConsent } from "@/content/consent";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
export function PaymentForm() {
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
const initialTicket = searchParams.get("ticket") ?? ticketTiers[0].id;
|
||
|
||
const [ticketId, setTicketId] = useState(initialTicket);
|
||
const [quantity, setQuantity] = useState(1);
|
||
const [paymentMethod, setPaymentMethod] = useState<"card" | "bank">("card");
|
||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||
const [error, setError] = useState("");
|
||
const [consent, setConsent] = useState(false);
|
||
|
||
const tier = ticketTiers.find((t) => t.id === ticketId) ?? ticketTiers[0];
|
||
const total = useMemo(() => calculateTotal(ticketId, quantity), [ticketId, quantity]);
|
||
|
||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
if (!consent) {
|
||
setError(dataConsent.errorMessage);
|
||
return;
|
||
}
|
||
setStatus("loading");
|
||
setError("");
|
||
|
||
const form = e.currentTarget;
|
||
const data = new FormData(form);
|
||
|
||
try {
|
||
const res = await fetch("/api/payment", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
ticketId,
|
||
quantity,
|
||
paymentMethod,
|
||
name: data.get("name"),
|
||
email: data.get("email"),
|
||
company: data.get("company") || undefined,
|
||
phone: data.get("phone") || undefined,
|
||
consent: true,
|
||
}),
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok || !json.ok) {
|
||
throw new Error(json.error || "Payment failed");
|
||
}
|
||
router.push(`/payment/success?order=${json.orderId}&total=${json.totalUsd}`);
|
||
} catch (err) {
|
||
setStatus("error");
|
||
setError(err instanceof Error ? err.message : "Payment failed");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={onSubmit} className="grid gap-8 lg:grid-cols-5">
|
||
<div className="space-y-4 lg:col-span-3">
|
||
<div className="space-y-2">
|
||
<Label>Ticket type</Label>
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
{ticketTiers.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
disabled={t.soldOut}
|
||
onClick={() => setTicketId(t.id)}
|
||
className={cn(
|
||
"rounded-xl border p-4 text-left transition-colors",
|
||
ticketId === t.id
|
||
? "border-[#30614c] bg-[#30614c]/5 ring-2 ring-[#30614c]"
|
||
: "border-border hover:border-[#30614c]/40",
|
||
t.soldOut && "opacity-50"
|
||
)}
|
||
>
|
||
<p className="font-semibold">{t.name}</p>
|
||
<p className="mt-1 text-lg font-bold text-[#30614c]">${t.priceUsd}</p>
|
||
{t.soldOut && <p className="text-xs text-destructive">Sold out</p>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="quantity">Quantity</Label>
|
||
<Select
|
||
value={String(quantity)}
|
||
onValueChange={(v) => setQuantity(Number(v))}
|
||
>
|
||
<SelectTrigger id="quantity">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{[1, 2, 3, 4, 5].map((n) => (
|
||
<SelectItem key={n} value={String(n)}>
|
||
{n}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>Payment method</Label>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
{paymentMethods.map((m) => (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
onClick={() => setPaymentMethod(m.id)}
|
||
className={cn(
|
||
"rounded-xl border p-4 text-left",
|
||
paymentMethod === m.id
|
||
? "border-[#37a47a] ring-2 ring-[#37a47a]"
|
||
: "border-border"
|
||
)}
|
||
>
|
||
<p className="font-medium">{m.label}</p>
|
||
<p className="text-sm text-muted-foreground">{m.description}</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">Full name</Label>
|
||
<Input id="name" name="name" required />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="email">Email</Label>
|
||
<Input id="email" name="email" type="email" required />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="company">Company (optional)</Label>
|
||
<Input id="company" name="company" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="phone">Phone (optional)</Label>
|
||
<Input id="phone" name="phone" />
|
||
</div>
|
||
</div>
|
||
|
||
<DataConsentField
|
||
id="payment-consent"
|
||
checked={consent}
|
||
onCheckedChange={setConsent}
|
||
variant="payment"
|
||
/>
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
</div>
|
||
|
||
<Card className="h-fit lg:col-span-2">
|
||
<CardHeader>
|
||
<CardTitle>Order summary</CardTitle>
|
||
<CardDescription>{tier.description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||
{tier.features.map((f) => (
|
||
<li key={f}>· {f}</li>
|
||
))}
|
||
</ul>
|
||
<div className="border-t pt-4">
|
||
<div className="flex justify-between text-sm">
|
||
<span>
|
||
{tier.name} × {quantity}
|
||
</span>
|
||
<span>${tier.priceUsd * quantity}</span>
|
||
</div>
|
||
<div className="mt-2 flex justify-between text-lg font-bold">
|
||
<span>Total</span>
|
||
<span className="text-[#30614c]">${total} USD</span>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="submit"
|
||
className="w-full rounded-full bg-[#37a47a] text-[#ffffff] hover:bg-[#37a47a]/90"
|
||
disabled={status === "loading" || tier.soldOut}
|
||
>
|
||
{status === "loading"
|
||
? "Processing…"
|
||
: paymentMethod === "card"
|
||
? "Pay now"
|
||
: "Request invoice"}
|
||
</Button>
|
||
<p className="text-center text-xs text-muted-foreground">
|
||
Payments are processed securely. v1 records your order for follow-up.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</form>
|
||
);
|
||
}
|