- Remove FPX payment rail entirely (Malaysian, not UK) - Add volunteer portal (/v/[code]) with live pledge tracking - Add public event page (/e/[slug]) with progress bar + social proof - Add fundraiser leaderboard (/dashboard/events/[id]/leaderboard) - Add WhatsApp share buttons on confirmation, bank instructions, volunteer view - Enhanced Gift Aid UX with +25% bonus display and HMRC declaration text - Gift Aid report export (HMRC-ready CSV filter) - Volunteer view link + WhatsApp share on QR code cards - Updated home page: 4 personas, 3 UK payment rails, 8 features - Public event API endpoint with privacy-safe donor name truncation - Volunteer API with stats, conversion rate, auto-refresh
227 lines
8.6 KiB
TypeScript
227 lines
8.6 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { formatPence } from "@/lib/utils"
|
|
import { Plus, QrCode, Calendar, MapPin, Target } from "lucide-react"
|
|
import Link from "next/link"
|
|
|
|
interface EventSummary {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
eventDate: string | null
|
|
location: string | null
|
|
goalAmount: number | null
|
|
status: string
|
|
pledgeCount: number
|
|
qrSourceCount: number
|
|
totalPledged: number
|
|
totalCollected: number
|
|
}
|
|
|
|
export default function EventsPage() {
|
|
const [events, setEvents] = useState<EventSummary[]>([])
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetch("/api/events", { headers: { "x-org-id": "demo" } })
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (Array.isArray(data)) setEvents(data)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
const [creating, setCreating] = useState(false)
|
|
const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
|
|
|
|
const handleCreate = async () => {
|
|
setCreating(true)
|
|
try {
|
|
const res = await fetch("/api/events", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
|
|
body: JSON.stringify({
|
|
...form,
|
|
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
|
|
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined,
|
|
}),
|
|
})
|
|
if (res.ok) {
|
|
const event = await res.json()
|
|
setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
|
|
setShowCreate(false)
|
|
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
|
|
}
|
|
} catch {
|
|
// handle error
|
|
}
|
|
setCreating(false)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-extrabold text-gray-900">Events</h1>
|
|
<p className="text-muted-foreground mt-1">Manage your fundraising events and QR codes</p>
|
|
</div>
|
|
<Button onClick={() => setShowCreate(true)}>
|
|
<Plus className="h-4 w-4 mr-2" /> New Event
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Event cards */}
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
{events.map((event) => {
|
|
const progress = event.goalAmount ? Math.round((event.totalPledged / event.goalAmount) * 100) : 0
|
|
|
|
return (
|
|
<Card key={event.id} className="hover:shadow-md transition-shadow">
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg">{event.name}</CardTitle>
|
|
<CardDescription className="flex items-center gap-3 mt-1">
|
|
{event.eventDate && (
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="h-3 w-3" />
|
|
{new Date(event.eventDate).toLocaleDateString("en-GB")}
|
|
</span>
|
|
)}
|
|
{event.location && (
|
|
<span className="flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
{event.location}
|
|
</span>
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<Badge variant={event.status === "active" ? "success" : "secondary"}>
|
|
{event.status}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
|
<div>
|
|
<p className="text-xl font-bold">{event.pledgeCount}</p>
|
|
<p className="text-xs text-muted-foreground">Pledges</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-bold">{formatPence(event.totalPledged)}</p>
|
|
<p className="text-xs text-muted-foreground">Pledged</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-bold text-success-green">{formatPence(event.totalCollected)}</p>
|
|
<p className="text-xs text-muted-foreground">Collected</p>
|
|
</div>
|
|
</div>
|
|
|
|
{event.goalAmount && (
|
|
<div>
|
|
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
|
<span>{progress}% of goal</span>
|
|
<span className="flex items-center gap-1">
|
|
<Target className="h-3 w-3" /> {formatPence(event.goalAmount)}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-trust-blue transition-all"
|
|
style={{ width: `${Math.min(progress, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Link href={`/dashboard/events/${event.id}`} className="flex-1">
|
|
<Button variant="outline" size="sm" className="w-full">
|
|
<QrCode className="h-4 w-4 mr-1" /> QR Codes ({event.qrSourceCount})
|
|
</Button>
|
|
</Link>
|
|
<Link href={`/dashboard/pledges?event=${event.id}`} className="flex-1">
|
|
<Button variant="outline" size="sm" className="w-full">
|
|
View Pledges
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Create dialog */}
|
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
|
<DialogHeader>
|
|
<DialogTitle>Create Event</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Event Name *</Label>
|
|
<Input
|
|
placeholder="e.g. Ramadan Gala 2025"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Description</Label>
|
|
<Textarea
|
|
placeholder="Brief description..."
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Date</Label>
|
|
<Input
|
|
type="datetime-local"
|
|
value={form.eventDate}
|
|
onChange={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Goal (£)</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="50000"
|
|
value={form.goalAmount}
|
|
onChange={(e) => setForm((f) => ({ ...f, goalAmount: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Location</Label>
|
|
<Input
|
|
placeholder="Venue name and address"
|
|
value={form.location}
|
|
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 pt-2">
|
|
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={!form.name || creating} className="flex-1">
|
|
{creating ? "Creating..." : "Create Event"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|