GRV-Summit-Site/components/payment/PaymentForm.tsx
“kirukib” 1a710aa3c6
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
first commit + project setup
2026-05-20 11:57:21 +03:00

216 lines
7.4 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.

"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-[#1f3d7e] bg-[#1f3d7e]/5 ring-2 ring-[#1f3d7e]"
: "border-border hover:border-[#1f3d7e]/40",
t.soldOut && "opacity-50"
)}
>
<p className="font-semibold">{t.name}</p>
<p className="mt-1 text-lg font-bold text-[#1f3d7e]">${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-[#ffb300] ring-2 ring-[#ffb300]"
: "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-[#1f3d7e]">${total} USD</span>
</div>
</div>
<Button
type="submit"
className="w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/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>
);
}