feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

View File

@@ -0,0 +1,305 @@
"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>
)
}