306 lines
9.8 KiB
TypeScript
306 lines
9.8 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { CreditCard, Lock } from "lucide-react"
|
|
|
|
interface Props {
|
|
amount: number
|
|
eventName: string
|
|
eventId: string
|
|
qrSourceId: string | null
|
|
onComplete: (identity: {
|
|
donorName: string
|
|
donorEmail: string
|
|
donorPhone: string
|
|
giftAid: boolean
|
|
}) => void
|
|
}
|
|
|
|
function formatCardNumber(value: string): string {
|
|
const digits = value.replace(/\D/g, "").slice(0, 16)
|
|
return digits.replace(/(\d{4})(?=\d)/g, "$1 ")
|
|
}
|
|
|
|
function formatExpiry(value: string): string {
|
|
const digits = value.replace(/\D/g, "").slice(0, 4)
|
|
if (digits.length >= 3) return digits.slice(0, 2) + "/" + digits.slice(2)
|
|
return digits
|
|
}
|
|
|
|
function luhnCheck(num: string): boolean {
|
|
const digits = num.replace(/\D/g, "")
|
|
if (digits.length < 13) return false
|
|
let sum = 0
|
|
let alt = false
|
|
for (let i = digits.length - 1; i >= 0; i--) {
|
|
let n = parseInt(digits[i], 10)
|
|
if (alt) {
|
|
n *= 2
|
|
if (n > 9) n -= 9
|
|
}
|
|
sum += n
|
|
alt = !alt
|
|
}
|
|
return sum % 10 === 0
|
|
}
|
|
|
|
function getCardBrand(num: string): string {
|
|
const d = num.replace(/\D/g, "")
|
|
if (/^4/.test(d)) return "Visa"
|
|
if (/^5[1-5]/.test(d) || /^2[2-7]/.test(d)) return "Mastercard"
|
|
if (/^3[47]/.test(d)) return "Amex"
|
|
if (/^6(?:011|5)/.test(d)) return "Discover"
|
|
return ""
|
|
}
|
|
|
|
export function CardPaymentStep({ amount, eventName, eventId, qrSourceId, onComplete }: Props) {
|
|
const [cardNumber, setCardNumber] = useState("")
|
|
const [expiry, setExpiry] = useState("")
|
|
const [cvc, setCvc] = useState("")
|
|
const [name, setName] = useState("")
|
|
const [email, setEmail] = useState("")
|
|
const [giftAid, setGiftAid] = useState(false)
|
|
const [processing, setProcessing] = useState(false)
|
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
|
|
const expiryRef = useRef<HTMLInputElement>(null)
|
|
const cvcRef = useRef<HTMLInputElement>(null)
|
|
|
|
const pounds = (amount / 100).toFixed(2)
|
|
const brand = getCardBrand(cardNumber)
|
|
|
|
const validate = (): boolean => {
|
|
const errs: Record<string, string> = {}
|
|
const digits = cardNumber.replace(/\D/g, "")
|
|
|
|
if (!luhnCheck(digits)) errs.card = "Invalid card number"
|
|
if (digits.length < 13) errs.card = "Card number too short"
|
|
|
|
const expiryDigits = expiry.replace(/\D/g, "")
|
|
if (expiryDigits.length < 4) {
|
|
errs.expiry = "Invalid expiry"
|
|
} else {
|
|
const month = parseInt(expiryDigits.slice(0, 2), 10)
|
|
const year = parseInt("20" + expiryDigits.slice(2, 4), 10)
|
|
const now = new Date()
|
|
if (month < 1 || month > 12) errs.expiry = "Invalid month"
|
|
else if (year < now.getFullYear() || (year === now.getFullYear() && month < now.getMonth() + 1))
|
|
errs.expiry = "Card expired"
|
|
}
|
|
|
|
if (cvc.length < 3) errs.cvc = "Invalid CVC"
|
|
if (!name.trim()) errs.name = "Name required"
|
|
if (!email.includes("@")) errs.email = "Valid email required"
|
|
|
|
setErrors(errs)
|
|
return Object.keys(errs).length === 0
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (!validate()) return
|
|
setProcessing(true)
|
|
|
|
// Try real Stripe Checkout first
|
|
try {
|
|
const res = await fetch("/api/stripe/checkout", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
amountPence: amount,
|
|
donorName: name,
|
|
donorEmail: email,
|
|
donorPhone: "",
|
|
giftAid,
|
|
eventId,
|
|
qrSourceId,
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (data.mode === "live" && data.checkoutUrl) {
|
|
// Redirect to Stripe Checkout
|
|
window.location.href = data.checkoutUrl
|
|
return
|
|
}
|
|
|
|
// Simulated mode — fall through to onComplete
|
|
} catch {
|
|
// Fall through to simulated
|
|
}
|
|
|
|
// Simulated fallback
|
|
await new Promise((r) => setTimeout(r, 1500))
|
|
onComplete({
|
|
donorName: name,
|
|
donorEmail: email,
|
|
donorPhone: "",
|
|
giftAid,
|
|
})
|
|
}
|
|
|
|
const handleCardNumberChange = (value: string) => {
|
|
const formatted = formatCardNumber(value)
|
|
setCardNumber(formatted)
|
|
// Auto-advance to expiry when complete
|
|
if (formatted.replace(/\s/g, "").length === 16) {
|
|
expiryRef.current?.focus()
|
|
}
|
|
}
|
|
|
|
const handleExpiryChange = (value: string) => {
|
|
const formatted = formatExpiry(value)
|
|
setExpiry(formatted)
|
|
if (formatted.length === 5) {
|
|
cvcRef.current?.focus()
|
|
}
|
|
}
|
|
|
|
const isReady = cardNumber.replace(/\D/g, "").length >= 13 && expiry.length === 5 && cvc.length >= 3 && name.trim() && email.includes("@")
|
|
|
|
return (
|
|
<div className="max-w-md mx-auto pt-4 space-y-6">
|
|
<div className="text-center space-y-2">
|
|
<h1 className="text-2xl font-extrabold text-gray-900">Pay by Card</h1>
|
|
<p className="text-lg text-muted-foreground">
|
|
Pledge: <span className="font-bold text-foreground">£{pounds}</span>{" "}
|
|
for <span className="font-semibold text-foreground">{eventName}</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Card form */}
|
|
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
|
|
{/* Card number */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="card-number">Card Number</Label>
|
|
<div className="relative">
|
|
<CreditCard className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
<Input
|
|
id="card-number"
|
|
placeholder="1234 5678 9012 3456"
|
|
value={cardNumber}
|
|
onChange={(e) => handleCardNumberChange(e.target.value)}
|
|
inputMode="numeric"
|
|
autoComplete="cc-number"
|
|
className={`pl-11 font-mono text-base ${errors.card ? "border-red-500" : ""}`}
|
|
/>
|
|
{brand && (
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-trust-blue bg-trust-blue/10 px-2 py-0.5 rounded-full">
|
|
{brand}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{errors.card && <p className="text-xs text-red-500">{errors.card}</p>}
|
|
</div>
|
|
|
|
{/* Expiry + CVC row */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="expiry">Expiry</Label>
|
|
<Input
|
|
id="expiry"
|
|
ref={expiryRef}
|
|
placeholder="MM/YY"
|
|
value={expiry}
|
|
onChange={(e) => handleExpiryChange(e.target.value)}
|
|
inputMode="numeric"
|
|
autoComplete="cc-exp"
|
|
maxLength={5}
|
|
className={`font-mono text-base ${errors.expiry ? "border-red-500" : ""}`}
|
|
/>
|
|
{errors.expiry && <p className="text-xs text-red-500">{errors.expiry}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cvc">CVC</Label>
|
|
<Input
|
|
id="cvc"
|
|
ref={cvcRef}
|
|
placeholder="123"
|
|
value={cvc}
|
|
onChange={(e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
|
inputMode="numeric"
|
|
autoComplete="cc-csc"
|
|
maxLength={4}
|
|
className={`font-mono text-base ${errors.cvc ? "border-red-500" : ""}`}
|
|
/>
|
|
{errors.cvc && <p className="text-xs text-red-500">{errors.cvc}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cardholder name */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="card-name">Name on Card</Label>
|
|
<Input
|
|
id="card-name"
|
|
placeholder="J. Smith"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
autoComplete="cc-name"
|
|
className={errors.name ? "border-red-500" : ""}
|
|
/>
|
|
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="card-email">Email</Label>
|
|
<Input
|
|
id="card-email"
|
|
type="email"
|
|
placeholder="your@email.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
autoComplete="email"
|
|
inputMode="email"
|
|
className={errors.email ? "border-red-500" : ""}
|
|
/>
|
|
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Gift Aid */}
|
|
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
|
|
<input
|
|
type="checkbox"
|
|
checked={giftAid}
|
|
onChange={(e) => setGiftAid(e.target.checked)}
|
|
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
|
|
/>
|
|
<div>
|
|
<span className="font-semibold text-gray-900">Add Gift Aid</span>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
Boost your donation by 25% at no extra cost. You must be a UK taxpayer.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
{/* Pay button */}
|
|
<Button
|
|
size="xl"
|
|
className="w-full"
|
|
disabled={!isReady || processing}
|
|
onClick={handleSubmit}
|
|
>
|
|
{processing ? (
|
|
<span className="flex items-center gap-2">
|
|
<span className="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
Processing...
|
|
</span>
|
|
) : (
|
|
<>
|
|
<Lock className="h-5 w-5 mr-2" />
|
|
Pay £{pounds}
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
<Lock className="h-3 w-3" />
|
|
<span>Secured with 256-bit encryption</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|