Files
calvana/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
Omair Saleh 4f23f28873 production auth: signup, login, protected dashboard, landing page, WAHA QR fix
AUTH:
- NextAuth with credentials provider (bcrypt password hashing)
- /api/auth/signup: creates org + user in transaction
- /login, /signup pages with clean minimal UI
- Middleware protects all /dashboard/* routes → redirects to /login
- Session-based org resolution (no more hardcoded 'demo' headers)
- SessionProvider wraps entire app
- Dashboard header shows org name + sign out button

LANDING PAGE:
- Full marketing page at / with hero, problem, how-it-works, features, CTA
- 'Get Started Free' → /signup → auto-login → /dashboard/setup
- Clean responsive design, no auth required for public pages

WAHA QR FIX:
- WAHA CORE doesn't expose QR value via API or webhook
- Now uses /api/screenshot (full browser capture) with CSS crop to QR area
- Settings panel shows cropped screenshot with overflow:hidden
- Auto-polls every 5s, refresh button

MULTI-TENANT:
- getOrgId() tries session first, then header, then first-org fallback
- All dashboard APIs use session-based org
- Signup creates isolated org per charity
2026-03-03 05:37:04 +08:00

418 lines
19 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 { useState, useEffect, useCallback } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/components/ui/toast"
import {
Search, MoreVertical, Calendar, Clock, AlertTriangle,
CheckCircle2, XCircle, MessageCircle, Send, Filter,
ChevronLeft, ChevronRight, Users, Loader2
} from "lucide-react"
interface Pledge {
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
giftAid: boolean
dueDate: string | null
planId: string | null
installmentNumber: number | null
installmentTotal: number | null
eventName: string
qrSourceLabel: string | null
volunteerName: string | null
createdAt: string
paidAt: string | null
}
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "success" | "warning" | "destructive"; icon: typeof Clock }> = {
new: { label: "Pending", variant: "secondary", icon: Clock },
initiated: { label: "Initiated", variant: "warning", icon: Send },
paid: { label: "Paid", variant: "success", icon: CheckCircle2 },
overdue: { label: "Overdue", variant: "destructive", icon: AlertTriangle },
cancelled: { label: "Cancelled", variant: "secondary", icon: XCircle },
}
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
function timeAgo(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const days = Math.floor(diff / 86400000)
if (days === 0) return "Today"
if (days === 1) return "Yesterday"
if (days < 7) return `${days}d ago`
if (days < 30) return `${Math.floor(days / 7)}w ago`
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
}
function dueLabel(dueDate: string) {
const d = new Date(dueDate)
const now = new Date()
const diff = d.getTime() - now.getTime()
const days = Math.ceil(diff / 86400000)
if (days < 0) return { text: `${Math.abs(days)}d overdue`, urgent: true }
if (days === 0) return { text: "Due today", urgent: true }
if (days === 1) return { text: "Due tomorrow", urgent: false }
if (days <= 7) return { text: `Due in ${days}d`, urgent: false }
return { text: d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }), urgent: false }
}
export default function PledgesPage() {
const [pledges, setPledges] = useState<Pledge[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState("all")
const [search, setSearch] = useState("")
const [page, setPage] = useState(0)
const [updating, setUpdating] = useState<string | null>(null)
const { toast } = useToast()
const pageSize = 25
// Stats
const [stats, setStats] = useState({ total: 0, pending: 0, dueSoon: 0, overdue: 0, paid: 0, totalPledged: 0, totalCollected: 0 })
const fetchPledges = useCallback(async () => {
const params = new URLSearchParams()
params.set("limit", String(pageSize))
params.set("offset", String(page * pageSize))
if (tab !== "all") {
if (tab === "due-soon") params.set("dueSoon", "true")
else if (tab === "overdue") params.set("overdue", "true")
else params.set("status", tab)
}
if (search) params.set("search", search)
params.set("sort", tab === "due-soon" ? "dueDate" : "createdAt")
params.set("dir", tab === "due-soon" ? "asc" : "desc")
const res = await fetch(`/api/pledges?${params}`)
const data = await res.json()
setPledges(data.pledges || [])
setTotal(data.total || 0)
setLoading(false)
}, [tab, search, page])
const fetchStats = useCallback(async () => {
const res = await fetch("/api/dashboard")
const data = await res.json()
if (data.summary) {
setStats({
total: data.summary.totalPledges,
pending: data.byStatus?.new || 0,
dueSoon: 0, // calculated client-side
overdue: data.byStatus?.overdue || 0,
paid: data.byStatus?.paid || 0,
totalPledged: data.summary.totalPledgedPence,
totalCollected: data.summary.totalCollectedPence,
})
}
}, [])
useEffect(() => { fetchPledges() }, [fetchPledges])
useEffect(() => { fetchStats() }, [fetchStats])
// Auto-refresh
useEffect(() => {
const interval = setInterval(fetchPledges, 30000)
return () => clearInterval(interval)
}, [fetchPledges])
const updateStatus = async (pledgeId: string, newStatus: string) => {
setUpdating(pledgeId)
try {
await fetch(`/api/pledges/${pledgeId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
})
setPledges(prev => prev.map(p => p.id === pledgeId ? { ...p, status: newStatus } : p))
toast(`Pledge marked as ${newStatus}`, "success")
} catch {
toast("Failed to update", "error")
}
setUpdating(null)
}
const sendReminder = async (pledge: Pledge) => {
if (!pledge.donorPhone) {
toast("No phone number — can't send WhatsApp", "error")
return
}
try {
await fetch("/api/whatsapp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "reminder",
phone: pledge.donorPhone,
data: {
donorName: pledge.donorName,
amountPounds: (pledge.amountPence / 100).toFixed(0),
eventName: pledge.eventName,
reference: pledge.reference,
daysSincePledge: Math.floor((Date.now() - new Date(pledge.createdAt).getTime()) / 86400000),
step: 1,
},
}),
})
toast("Reminder sent via WhatsApp ✓", "success")
} catch {
toast("Failed to send", "error")
}
}
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-gray-900">Pledges</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{stats.total} total · {formatPence(stats.totalPledged)} pledged · {collectionRate}% collected
</p>
</div>
<div className="flex gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search name, email, ref..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
className="pl-9 w-64"
/>
</div>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("all")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-trust-blue" />
<span className="text-xs text-muted-foreground">All</span>
</div>
<p className="text-xl font-bold mt-1">{stats.total}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("new")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-warm-amber" />
<span className="text-xs text-muted-foreground">Pending</span>
</div>
<p className="text-xl font-bold mt-1">{stats.pending}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow border-warm-amber/30" onClick={() => setTab("due-soon")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-warm-amber" />
<span className="text-xs text-muted-foreground">Due Soon</span>
</div>
<p className="text-xl font-bold mt-1 text-warm-amber">{stats.dueSoon || "—"}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow border-danger-red/30" onClick={() => setTab("overdue")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-danger-red" />
<span className="text-xs text-muted-foreground">Overdue</span>
</div>
<p className="text-xl font-bold mt-1 text-danger-red">{stats.overdue}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("paid")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-success-green" />
<span className="text-xs text-muted-foreground">Paid</span>
</div>
<p className="text-xl font-bold mt-1 text-success-green">{stats.paid}</p>
</CardContent>
</Card>
</div>
{/* Collection progress */}
<div className="flex items-center gap-4">
<Progress value={collectionRate} className="flex-1 h-2" indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)}
</span>
</div>
{/* Tabs + Table */}
<Tabs value={tab} onValueChange={(v) => { setTab(v); setPage(0) }}>
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="new">Pending</TabsTrigger>
<TabsTrigger value="due-soon">Due Soon</TabsTrigger>
<TabsTrigger value="overdue">Overdue</TabsTrigger>
<TabsTrigger value="initiated">Initiated</TabsTrigger>
<TabsTrigger value="paid">Paid</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
<TabsContent value={tab}>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 text-trust-blue animate-spin" />
</div>
) : pledges.length === 0 ? (
<div className="text-center py-16 space-y-3">
<Filter className="h-8 w-8 text-muted-foreground mx-auto" />
<p className="font-medium text-gray-900">No pledges found</p>
<p className="text-sm text-muted-foreground">
{search ? `No results for "${search}"` : "Create an event and share QR codes to start collecting pledges"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Donor</TableHead>
<TableHead>Amount</TableHead>
<TableHead className="hidden md:table-cell">Event</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">Due / Created</TableHead>
<TableHead className="hidden lg:table-cell">Method</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pledges.map((p) => {
const sc = statusConfig[p.status] || statusConfig.new
const due = p.dueDate ? dueLabel(p.dueDate) : null
const isInstallment = p.installmentTotal && p.installmentTotal > 1
return (
<TableRow key={p.id} className={updating === p.id ? "opacity-50" : ""}>
<TableCell>
<div>
<p className="font-medium text-sm">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground font-mono">{p.reference}</p>
{p.donorPhone && (
<p className="text-[10px] text-[#25D366] flex items-center gap-0.5 mt-0.5">
<MessageCircle className="h-2.5 w-2.5" /> WhatsApp
</p>
)}
</div>
</TableCell>
<TableCell>
<p className="font-bold">{formatPence(p.amountPence)}</p>
{p.giftAid && <span className="text-[10px] text-success-green">🎁 +Gift Aid</span>}
{isInstallment && (
<p className="text-[10px] text-warm-amber font-medium">
{p.installmentNumber}/{p.installmentTotal}
</p>
)}
</TableCell>
<TableCell className="hidden md:table-cell">
<p className="text-sm truncate max-w-[140px]">{p.eventName}</p>
{p.qrSourceLabel && (
<p className="text-[10px] text-muted-foreground">{p.qrSourceLabel}</p>
)}
</TableCell>
<TableCell>
<Badge variant={sc.variant} className="gap-1 text-[11px]">
<sc.icon className="h-3 w-3" /> {sc.label}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{due ? (
<span className={`text-xs font-medium ${due.urgent ? "text-danger-red" : "text-muted-foreground"}`}>
{due.urgent && "⚠ "}{due.text}
</span>
) : (
<span className="text-xs text-muted-foreground">{timeAgo(p.createdAt)}</span>
)}
</TableCell>
<TableCell className="hidden lg:table-cell">
<span className="text-xs capitalize text-muted-foreground">
{p.rail === "gocardless" ? "Direct Debit" : p.rail}
</span>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger className="p-1.5 rounded-lg hover:bg-muted transition-colors">
<MoreVertical className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{p.status !== "paid" && (
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
<CheckCircle2 className="h-4 w-4 text-success-green" /> Mark Paid
</DropdownMenuItem>
)}
{p.status !== "initiated" && p.status !== "paid" && (
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
<Send className="h-4 w-4 text-warm-amber" /> Mark Initiated
</DropdownMenuItem>
)}
{p.donorPhone && p.status !== "paid" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => sendReminder(p)}>
<MessageCircle className="h-4 w-4 text-[#25D366]" /> Send WhatsApp Reminder
</DropdownMenuItem>
</>
)}
{p.status !== "cancelled" && p.status !== "paid" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onClick={() => updateStatus(p.id, "cancelled")}>
<XCircle className="h-4 w-4" /> Cancel Pledge
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-3">
<p className="text-sm text-muted-foreground">
Showing {page * pageSize + 1}{Math.min((page + 1) * pageSize, total)} of {total}
</p>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
)
}