Complete dashboard UI overhaul: persona journeys + brand unification
Navigation: goal-oriented, not feature-oriented
- Overview → Home
- Campaigns → Collect ('I want people to pledge')
- Pledges → Money ('Where's the money?')
- Exports → Reports ('My treasurer needs numbers')
- Old routes still work via re-exports
Terminology: human language, not SaaS jargon
- new → Waiting
- initiated → Said they paid
- paid → Received ✓
- overdue → Needs a nudge
- Campaign → Appeal
- QR Source → Pledge link
- Reconcile → Match payments
- Rail → Payment method
- Pipeline by Status → How pledges are doing
- Conversion rate → % who pledged
- CRM Export Pack → Full data download
Visual identity: brand-consistent dashboard
- Sharp edges (no rounded-lg cards)
- Gap-px grids for stats (brand signature pattern)
- Left-border accents (brand signature pattern)
- Midnight/Paper/Promise Blue 60-30-10 color rule
- Typography as hero (big bold numbers, not card-heavy)
- No emoji in UI chrome
- Brand-consistent status badges (colored bg + text, not shadcn Badge)
- Consistent header typography (text-3xl font-black tracking-tight)
Pages rewritten: layout, home, events (collect), pledges (money),
exports (reports), reconcile, settings
Reconcile: auto-detects bank CSV format via presets + AI before upload
UX spec: docs/UX_OVERHAUL_SPEC.md
This commit is contained in:
5
pledge-now-pay-later/src/app/dashboard/collect/page.tsx
Normal file
5
pledge-now-pay-later/src/app/dashboard/collect/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* /dashboard/collect — "I want people to pledge"
|
||||
* This is the renamed "Campaigns/Events" page with human language.
|
||||
*/
|
||||
export { default } from "../events/page"
|
||||
@@ -1,65 +1,49 @@
|
||||
"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, ExternalLink } from "lucide-react"
|
||||
import { Plus, ExternalLink } 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
|
||||
paymentMode?: string
|
||||
externalPlatform?: string
|
||||
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
|
||||
paymentMode?: string; externalPlatform?: string
|
||||
}
|
||||
|
||||
const platformNames: Record<string, string> = {
|
||||
launchgood: "🌙 LaunchGood",
|
||||
enthuse: "💜 Enthuse",
|
||||
justgiving: "💛 JustGiving",
|
||||
gofundme: "💚 GoFundMe",
|
||||
other: "🔗 External",
|
||||
launchgood: "LaunchGood", enthuse: "Enthuse", justgiving: "JustGiving",
|
||||
gofundme: "GoFundMe", other: "External",
|
||||
}
|
||||
|
||||
export default function EventsPage() {
|
||||
export default function CollectPage() {
|
||||
const [events, setEvents] = useState<EventSummary[]>([])
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [orgType, setOrgType] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
name: "", description: "", location: "", eventDate: "", goalAmount: "",
|
||||
paymentMode: "self" as "self" | "external", externalUrl: "", externalPlatform: "", zakatEligible: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/events")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setEvents(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { if (Array.isArray(data)) setEvents(data) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [orgType, setOrgType] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: "self" as "self" | "external", externalUrl: "", externalPlatform: "", zakatEligible: false })
|
||||
|
||||
// Fetch org type to customize the form
|
||||
useEffect(() => {
|
||||
fetch("/api/onboarding").then(r => r.json()).then(d => {
|
||||
if (d.orgType) setOrgType(d.orgType)
|
||||
// Auto-set external mode for fundraisers
|
||||
if (d.orgType === "fundraiser") setForm(f => ({ ...f, paymentMode: "external" }))
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
@@ -71,274 +55,239 @@ export default function EventsPage() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
name: form.name, description: form.description || undefined,
|
||||
location: form.location || undefined,
|
||||
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
|
||||
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined,
|
||||
paymentMode: form.paymentMode,
|
||||
externalUrl: form.externalUrl || undefined,
|
||||
paymentMode: form.paymentMode, externalUrl: form.externalUrl || undefined,
|
||||
externalPlatform: form.paymentMode === "external" ? (form.externalPlatform || "other") : undefined,
|
||||
zakatEligible: form.zakatEligible,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const event = await res.json()
|
||||
setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
|
||||
setEvents(prev => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
|
||||
setShowCreate(false)
|
||||
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "", zakatEligible: false })
|
||||
}
|
||||
} catch {
|
||||
// handle error
|
||||
}
|
||||
} catch { /* */ }
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Campaigns</h1>
|
||||
<p className="text-muted-foreground mt-1">Create campaigns, share pledge links, and track donations</p>
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Create appeals, share pledge links, and track who's pledged</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> New Campaign
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> New Appeal
|
||||
</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")}
|
||||
{/* Appeal cards — brand style: gap-px grid on desktop, stacked on mobile */}
|
||||
{loading ? (
|
||||
<div className="text-center py-16 text-sm text-gray-400">Loading appeals...</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="bg-white border-2 border-dashed border-gray-200 p-10 text-center">
|
||||
<p className="text-4xl font-black text-gray-200 mb-3">01</p>
|
||||
<h3 className="text-base font-bold text-[#111827]">Create your first appeal</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 max-w-md mx-auto">
|
||||
An appeal is a collection — your gala dinner, Ramadan campaign, mosque fund, or any cause you're raising for.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Create Appeal →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{events.map(event => {
|
||||
const progress = event.goalAmount ? Math.min(100, Math.round((event.totalPledged / event.goalAmount) * 100)) : 0
|
||||
return (
|
||||
<div key={event.id} className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">{event.name}</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
|
||||
{event.eventDate && <span>{new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}</span>}
|
||||
{event.location && <span>{event.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{event.paymentMode === "external" && event.externalPlatform && (
|
||||
<span className="text-[10px] font-bold text-gray-500 border border-gray-200 px-1.5 py-0.5">
|
||||
{platformNames[event.externalPlatform] || "External"}
|
||||
</span>
|
||||
)}
|
||||
{event.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{event.location}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{event.paymentMode === "external" && event.externalPlatform && (
|
||||
<Badge variant="outline" className="text-[10px]">{platformNames[event.externalPlatform] || "External"}</Badge>
|
||||
)}
|
||||
<Badge variant={event.status === "active" ? "success" : "secondary"}>
|
||||
{event.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</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 className={`text-[10px] font-bold px-1.5 py-0.5 ${event.status === "active" ? "bg-[#16A34A]/10 text-[#16A34A]" : "bg-gray-100 text-gray-500"}`}>
|
||||
{event.status === "active" ? "Live" : event.status}
|
||||
</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>
|
||||
|
||||
{/* Stats — gap-px grid */}
|
||||
<div className="grid grid-cols-3 gap-px bg-gray-100">
|
||||
<div className="bg-white p-3 text-center">
|
||||
<p className="text-lg font-black text-[#111827]">{event.pledgeCount}</p>
|
||||
<p className="text-[10px] text-gray-500">Pledges</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 text-center">
|
||||
<p className="text-lg font-black text-[#111827]">{formatPence(event.totalPledged)}</p>
|
||||
<p className="text-[10px] text-gray-500">Promised</p>
|
||||
</div>
|
||||
<div className="bg-white p-3 text-center">
|
||||
<p className="text-lg font-black text-[#16A34A]">{formatPence(event.totalCollected)}</p>
|
||||
<p className="text-[10px] text-gray-500">Received</p>
|
||||
</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" /> Pledge Links ({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>
|
||||
{/* Goal bar */}
|
||||
{event.goalAmount && (
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] text-gray-500 mb-1">
|
||||
<span>{progress}% of target</span>
|
||||
<span>{formatPence(event.goalAmount)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 overflow-hidden">
|
||||
<div className="h-full bg-[#1E40AF] transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/dashboard/events/${event.id}`} className="flex-1">
|
||||
<button className="w-full border border-gray-200 px-3 py-2 text-xs font-semibold text-[#111827] hover:bg-gray-50 transition-colors">
|
||||
Pledge Links ({event.qrSourceCount})
|
||||
</button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/money?event=${event.id}`} className="flex-1">
|
||||
<button className="w-full border border-gray-200 px-3 py-2 text-xs font-semibold text-[#111827] hover:bg-gray-50 transition-colors">
|
||||
View Pledges
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Campaign</DialogTitle>
|
||||
<DialogTitle className="font-black text-[#111827]">New Appeal</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Campaign Name *</Label>
|
||||
<Label className="text-xs font-bold">What are you raising for? *</Label>
|
||||
<Input
|
||||
placeholder="e.g. Ramadan Appeal 2026, Mosque Building Fund"
|
||||
placeholder="e.g. Ramadan Appeal 2026, Mosque Building Fund, Annual Gala Dinner"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
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 }))}
|
||||
/>
|
||||
<Label className="text-xs font-bold">Description <span className="font-normal text-gray-400">(optional)</span></Label>
|
||||
<Textarea placeholder="Brief description for donors..." 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 }))}
|
||||
/>
|
||||
<Label className="text-xs font-bold">Date <span className="font-normal text-gray-400">(optional)</span></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 }))}
|
||||
/>
|
||||
<Label className="text-xs font-bold">Target amount (£) <span className="font-normal text-gray-400">(optional)</span></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 }))}
|
||||
/>
|
||||
<Label className="text-xs font-bold">Venue <span className="font-normal text-gray-400">(optional)</span></Label>
|
||||
<Input placeholder="Venue name and address" value={form.location} onChange={e => setForm(f => ({ ...f, location: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
{/* Payment mode toggle */}
|
||||
{/* How do donors pay? */}
|
||||
<div className="space-y-2">
|
||||
<Label>How do donors pay?</Label>
|
||||
<Label className="text-xs font-bold">How do donors pay?</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, paymentMode: "self" }))}
|
||||
className={`rounded-lg border p-3 text-left text-xs transition-all ${form.paymentMode === "self" ? "border-trust-blue bg-trust-blue/5" : "border-gray-200"}`}
|
||||
className={`border-2 p-3 text-left text-xs transition-all ${form.paymentMode === "self" ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"}`}
|
||||
>
|
||||
<span className="font-bold block">🏦 Bank transfer</span>
|
||||
<span className="text-muted-foreground">We show our bank details</span>
|
||||
<span className="font-bold block">Bank transfer</span>
|
||||
<span className="text-gray-500">We show our bank details</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, paymentMode: "external" }))}
|
||||
className={`rounded-lg border p-3 text-left text-xs transition-all ${form.paymentMode === "external" ? "border-warm-amber bg-warm-amber/5" : "border-gray-200"}`}
|
||||
className={`border-2 p-3 text-left text-xs transition-all ${form.paymentMode === "external" ? "border-[#F59E0B] bg-[#F59E0B]/5" : "border-gray-200"}`}
|
||||
>
|
||||
<span className="font-bold block">🔗 External page</span>
|
||||
<span className="text-muted-foreground">LaunchGood, Enthuse, etc.</span>
|
||||
<span className="font-bold block">External page</span>
|
||||
<span className="text-gray-500">LaunchGood, Enthuse, etc.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.paymentMode === "external" && (
|
||||
<div className="space-y-3 rounded-lg border border-warm-amber/20 bg-warm-amber/5 p-3">
|
||||
<div className="space-y-3 border-l-2 border-[#F59E0B] pl-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Fundraising page URL *</Label>
|
||||
<Label className="text-xs font-bold">Fundraising page URL *</Label>
|
||||
<div className="relative">
|
||||
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="https://launchgood.com/my-campaign"
|
||||
value={form.externalUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, externalUrl: e.target.value }))}
|
||||
className="pl-9"
|
||||
/>
|
||||
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input placeholder="https://launchgood.com/my-campaign" value={form.externalUrl} onChange={e => setForm(f => ({ ...f, externalUrl: e.target.value }))} className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Platform</Label>
|
||||
<select
|
||||
value={form.externalPlatform}
|
||||
onChange={(e) => setForm(f => ({ ...f, externalPlatform: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select platform...</option>
|
||||
<option value="launchgood">🌙 LaunchGood</option>
|
||||
<option value="enthuse">💜 Enthuse</option>
|
||||
<option value="justgiving">💛 JustGiving</option>
|
||||
<option value="gofundme">💚 GoFundMe</option>
|
||||
<option value="other">🔗 Other / Custom</option>
|
||||
<Label className="text-xs font-bold">Platform</Label>
|
||||
<select value={form.externalPlatform} onChange={e => setForm(f => ({ ...f, externalPlatform: e.target.value }))} className="w-full border border-gray-200 px-3 py-2 text-sm">
|
||||
<option value="">Select...</option>
|
||||
<option value="launchgood">LaunchGood</option>
|
||||
<option value="enthuse">Enthuse</option>
|
||||
<option value="justgiving">JustGiving</option>
|
||||
<option value="gofundme">GoFundMe</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zakat eligible toggle */}
|
||||
{/* Zakat toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, zakatEligible: !f.zakatEligible }))}
|
||||
className={`w-full flex items-center justify-between rounded-lg border-2 p-3 text-left transition-all ${
|
||||
form.zakatEligible ? "border-trust-blue bg-trust-blue/5" : "border-gray-200"
|
||||
className={`w-full flex items-center justify-between border-2 p-3 text-left transition-all ${
|
||||
form.zakatEligible ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-bold">🌙 Zakat eligible</p>
|
||||
<p className="text-xs text-muted-foreground">Let donors mark their pledge as Zakat</p>
|
||||
<p className="text-sm font-bold text-[#111827]">Zakat eligible</p>
|
||||
<p className="text-xs text-gray-500">Let donors mark their pledge as Zakat</p>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${form.zakatEligible ? "bg-trust-blue border-trust-blue" : "border-gray-300"}`}>
|
||||
<div className={`w-5 h-5 border-2 flex items-center justify-center ${form.zakatEligible ? "bg-[#1E40AF] border-[#1E40AF]" : "border-gray-300"}`}>
|
||||
{form.zakatEligible && <span className="text-white text-xs font-bold">✓</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* External URL for self-payment campaigns (for reference/allocation) */}
|
||||
{form.paymentMode === "self" && (
|
||||
<div className="space-y-2">
|
||||
<Label>External fundraising page <span className="text-muted-foreground font-normal">(optional)</span></Label>
|
||||
<div className="relative">
|
||||
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="https://launchgood.com/my-campaign"
|
||||
value={form.externalUrl}
|
||||
onChange={(e) => setForm(f => ({ ...f, externalUrl: e.target.value }))}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">Link an external page for reference. Payments still go via your bank.</p>
|
||||
</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 || (form.paymentMode === "external" && !form.externalUrl)} className="flex-1">
|
||||
{creating ? "Creating..." : "Create Campaign"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">Cancel</Button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!form.name || creating || (form.paymentMode === "external" && !form.externalUrl)}
|
||||
className="flex-1 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? "Creating..." : "Create Appeal"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Download, FileSpreadsheet, Webhook, Gift } from "lucide-react"
|
||||
import { Download } from "lucide-react"
|
||||
|
||||
export default function ExportsPage() {
|
||||
export default function ReportsPage() {
|
||||
const handleCrmExport = () => {
|
||||
const a = document.createElement("a")
|
||||
a.href = "/api/exports/crm-pack"
|
||||
@@ -20,94 +18,78 @@ export default function ExportsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Exports</h1>
|
||||
<p className="text-muted-foreground mt-1">Export data for your CRM, HMRC, and automation tools</p>
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Reports</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Download data for your treasurer, trustees, or HMRC</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" /> CRM Export Pack
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All pledges as CSV with full attribution data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>Includes:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 text-xs">
|
||||
<li>Donor name, email, phone</li>
|
||||
<li>Pledge amount and status</li>
|
||||
<li>Payment method and reference</li>
|
||||
<li>Event name and source attribution</li>
|
||||
<li>Gift Aid flag</li>
|
||||
<li>Days to collect</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button onClick={handleCrmExport} className="w-full">
|
||||
<Download className="h-4 w-4 mr-2" /> Download CRM Pack
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Full data download */}
|
||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Full data download</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Everything in one spreadsheet — donor details, amounts, statuses, attribution.</p>
|
||||
</div>
|
||||
<div className="border-l-2 border-[#111827] pl-3 space-y-1 text-xs text-gray-600">
|
||||
<p>Donor name, email, phone</p>
|
||||
<p>Amount and payment status</p>
|
||||
<p>Payment method and reference</p>
|
||||
<p>Appeal name and source</p>
|
||||
<p>Gift Aid flag</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCrmExport}
|
||||
className="w-full bg-[#111827] px-4 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card className="border-success-green/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Gift className="h-5 w-5 text-success-green" /> Gift Aid Report
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
HMRC-ready Gift Aid declarations for tax reclaim.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>Includes only Gift Aid-eligible pledges:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 text-xs">
|
||||
<li>Donor full name (required by HMRC)</li>
|
||||
<li>Donation amount and date</li>
|
||||
<li>Gift Aid declaration status</li>
|
||||
<li>Event and reference for audit trail</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg bg-success-green/5 border border-success-green/20 p-3">
|
||||
<p className="text-xs text-success-green font-medium">
|
||||
💷 Claim 25p for every £1 donated by a UK taxpayer
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleGiftAidExport} className="w-full bg-success-green hover:bg-success-green/90">
|
||||
<Download className="h-4 w-4 mr-2" /> Download Gift Aid Report
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Gift Aid report */}
|
||||
<div className="bg-white border border-[#16A34A]/30 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Gift Aid report</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">HMRC-ready declarations for tax reclaim. Only includes donors who ticked Gift Aid.</p>
|
||||
</div>
|
||||
<div className="border-l-2 border-[#16A34A] pl-3 space-y-1 text-xs text-gray-600">
|
||||
<p>Donor full name (required by HMRC)</p>
|
||||
<p>Donation amount and date</p>
|
||||
<p>Gift Aid declaration timestamp</p>
|
||||
<p>Home address and postcode</p>
|
||||
</div>
|
||||
<div className="bg-[#16A34A]/5 border border-[#16A34A]/20 p-3">
|
||||
<p className="text-xs text-[#16A34A] font-bold">
|
||||
Claim 25p for every £1 donated by a UK taxpayer
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGiftAidExport}
|
||||
className="w-full bg-[#16A34A] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#16A34A]/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download Gift Aid Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Webhook className="h-5 w-5" /> Webhook / API
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect to Zapier, Make, or n8n for automation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>Reminder endpoint:</p>
|
||||
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono break-all">
|
||||
GET /api/webhooks?since=2025-01-01
|
||||
</code>
|
||||
<p className="text-xs">Returns pending reminders with donor contact info for external email/SMS.</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-trust-blue/5 border border-trust-blue/20 p-3">
|
||||
<p className="text-xs text-trust-blue font-medium">
|
||||
💡 Connect to Zapier or n8n to send automatic reminder emails and SMS
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Connect to other tools */}
|
||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Connect to other tools</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Use our API to pull data into Zapier, Make, or your own systems.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-gray-600">Reminder endpoint:</p>
|
||||
<code className="block bg-gray-50 p-3 text-[11px] font-mono break-all border border-gray-100">
|
||||
GET /api/webhooks?since=2025-01-01
|
||||
</code>
|
||||
<p className="text-[10px] text-gray-500">Returns pending reminders with donor contact info for external email or SMS.</p>
|
||||
</div>
|
||||
<div className="bg-[#1E40AF]/5 border border-[#1E40AF]/20 p-3">
|
||||
<p className="text-xs text-[#1E40AF] font-bold">
|
||||
Connect to Zapier or Make to send automatic reminder emails
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,15 +4,22 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useSession, signOut } from "next-auth/react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { LayoutDashboard, Megaphone, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut, Shield, MessageCircle, AlertTriangle } from "lucide-react"
|
||||
import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Navigation: goal-oriented, not feature-oriented
|
||||
* "Home" — where am I at?
|
||||
* "Collect" — I want people to pledge
|
||||
* "Money" — where's the money?
|
||||
* "Reports" — my treasurer needs numbers
|
||||
* "Settings" — connect WhatsApp, bank details
|
||||
*/
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
|
||||
{ href: "/dashboard/events", label: "Campaigns", icon: Megaphone },
|
||||
{ href: "/dashboard/pledges", label: "Pledges", icon: FileBarChart },
|
||||
{ href: "/dashboard/reconcile", label: "Reconcile", icon: Upload },
|
||||
{ href: "/dashboard/exports", label: "Exports", icon: Download },
|
||||
{ href: "/dashboard", label: "Home", icon: Home },
|
||||
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
|
||||
{ href: "/dashboard/money", label: "Money", icon: Banknote },
|
||||
{ href: "/dashboard/reports", label: "Reports", icon: FileText },
|
||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||
]
|
||||
|
||||
@@ -24,54 +31,61 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const user = session?.user as any
|
||||
|
||||
// Map old routes to new ones for active state
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/dashboard") return pathname === "/dashboard"
|
||||
if (href === "/dashboard/collect") return pathname.startsWith("/dashboard/collect") || pathname.startsWith("/dashboard/events")
|
||||
if (href === "/dashboard/money") return pathname.startsWith("/dashboard/money") || pathname.startsWith("/dashboard/pledges") || pathname.startsWith("/dashboard/reconcile")
|
||||
if (href === "/dashboard/reports") return pathname.startsWith("/dashboard/reports") || pathname.startsWith("/dashboard/exports")
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-paper">
|
||||
{/* Top bar — sharp, no blur */}
|
||||
<div className="min-h-screen bg-[#F9FAFB]">
|
||||
{/* Top bar — brand-consistent: sharp, midnight, no blur */}
|
||||
<header className="sticky top-0 z-40 border-b border-gray-200 bg-white">
|
||||
<div className="flex h-14 items-center gap-4 px-4 md:px-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<div className="h-7 w-7 bg-midnight flex items-center justify-center">
|
||||
<div className="h-7 w-7 bg-[#111827] flex items-center justify-center">
|
||||
<span className="text-white text-xs font-black">P</span>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<span className="font-black text-sm text-midnight">{user?.orgName || "Pledge Now, Pay Later"}</span>
|
||||
<span className="font-black text-sm text-[#111827] tracking-tight">{user?.orgName || "Pledge Now, Pay Later"}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
<Link href="/dashboard/events" className="hidden md:block">
|
||||
<button className="inline-flex items-center gap-1.5 bg-midnight px-3 py-1.5 text-xs font-semibold text-white hover:bg-gray-800 transition-colors">
|
||||
<Plus className="h-3 w-3" /> New Campaign
|
||||
<Link href="/dashboard/collect">
|
||||
<button className="hidden md:inline-flex items-center gap-1.5 bg-[#111827] px-3.5 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
|
||||
<Plus className="h-3 w-3" /> New Appeal
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/" className="text-xs text-gray-400 hover:text-midnight transition-colors flex items-center gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
{session && (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs text-gray-400 hover:text-midnight transition-colors flex items-center gap-1"
|
||||
className="text-xs text-gray-400 hover:text-[#111827] transition-colors flex items-center gap-1"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* Desktop sidebar — clean, no decorative elements */}
|
||||
<aside className="hidden md:flex w-52 flex-col border-r border-gray-200 bg-white min-h-[calc(100vh-3.5rem)] py-3 px-2">
|
||||
{/* Desktop sidebar — brand style: sharp, left-border active state */}
|
||||
<aside className="hidden md:flex w-48 flex-col border-r border-gray-200 bg-white min-h-[calc(100vh-3.5rem)] py-4 px-2">
|
||||
<nav className="space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-midnight"
|
||||
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
@@ -81,14 +95,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
})}
|
||||
{user?.role === "super_admin" && (
|
||||
<>
|
||||
<div className="my-2 border-t border-gray-100" />
|
||||
<div className="my-3 border-t border-gray-100" />
|
||||
<Link
|
||||
href={adminNav.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 text-sm font-medium transition-colors",
|
||||
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
|
||||
pathname === adminNav.href
|
||||
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-midnight"
|
||||
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
|
||||
)}
|
||||
>
|
||||
<adminNav.icon className="h-4 w-4" />
|
||||
@@ -98,41 +112,42 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto px-2 pt-4">
|
||||
<div className="border border-gray-200 p-3 space-y-1.5">
|
||||
<p className="text-xs font-bold text-midnight">Need help?</p>
|
||||
<p className="text-[10px] text-gray-500 leading-relaxed">
|
||||
Get a fractional Head of Technology to optimise your charity's digital stack.
|
||||
{/* Sidebar CTA — brand style, no emoji */}
|
||||
<div className="mt-auto px-1.5 pt-4">
|
||||
<div className="border-l-2 border-[#111827] pl-3 py-2">
|
||||
<p className="text-xs font-bold text-[#111827]">Need expert help?</p>
|
||||
<p className="text-[10px] text-gray-500 leading-relaxed mt-0.5">
|
||||
Get a fractional CTO for your charity's digital stack.
|
||||
</p>
|
||||
<Link href="/dashboard/apply" className="inline-block text-[10px] font-semibold text-promise-blue hover:underline">
|
||||
<Link href="/dashboard/apply" className="inline-block text-[10px] font-semibold text-[#1E40AF] hover:underline mt-1">
|
||||
Learn more →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
{/* Mobile bottom nav — 5 items, icon + label */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white flex justify-around py-1.5 px-1">
|
||||
{navItems.slice(0, 5).map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 py-1 px-2 transition-colors",
|
||||
isActive ? "text-promise-blue" : "text-gray-400"
|
||||
"flex flex-col items-center gap-0.5 py-1.5 px-2 transition-colors",
|
||||
active ? "text-[#1E40AF]" : "text-gray-400"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className="text-[9px] font-medium">{item.label}</span>
|
||||
<span className="text-[9px] font-semibold">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-4 md:p-6 pb-20 md:pb-6 max-w-6xl">
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20 md:pb-8 max-w-6xl">
|
||||
<WhatsAppBanner />
|
||||
{children}
|
||||
</main>
|
||||
@@ -148,7 +163,6 @@ function WhatsAppBanner() {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
// Don't show on settings page (they're already there)
|
||||
if (pathname === "/dashboard/settings") { setStatus("skip"); return }
|
||||
fetch("/api/whatsapp/send")
|
||||
.then(r => r.json())
|
||||
@@ -159,19 +173,17 @@ function WhatsAppBanner() {
|
||||
if (status === "CONNECTED" || status === "skip" || status === null || dismissed) return null
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border-2 border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-amber-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="mb-6 border-l-2 border-[#F59E0B] bg-[#F59E0B]/5 p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-[#F59E0B] shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-gray-900">WhatsApp not connected — reminders won't send</p>
|
||||
<p className="text-sm font-bold text-[#111827]">WhatsApp not connected — reminders won't send</p>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Connect your WhatsApp to auto-send pledge receipts and payment reminders to donors. Takes 60 seconds.
|
||||
Connect your WhatsApp so donors automatically get payment reminders. Takes 60 seconds.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="flex items-center gap-3 mt-2.5">
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="inline-flex items-center gap-1.5 bg-[#25D366] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#25D366]/90 transition-colors rounded"
|
||||
className="inline-flex items-center gap-1.5 bg-[#25D366] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#25D366]/90 transition-colors"
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5" /> Connect WhatsApp
|
||||
</Link>
|
||||
|
||||
5
pledge-now-pay-later/src/app/dashboard/money/page.tsx
Normal file
5
pledge-now-pay-later/src/app/dashboard/money/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* /dashboard/money — "Where's the money?"
|
||||
* Merges Pledges + Reconcile into a single page with tabs.
|
||||
*/
|
||||
export { default } from "../pledges/page"
|
||||
@@ -1,134 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink, Circle, X, Building2, Heart } from "lucide-react"
|
||||
import { ArrowRight, Loader2, MessageCircle, CheckCircle2, Circle, X } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface DashboardData {
|
||||
summary: { totalPledges: number; totalPledgedPence: number; totalCollectedPence: number; collectionRate: number; overdueRate: number }
|
||||
byStatus: Record<string, number>
|
||||
byRail: Record<string, number>
|
||||
topSources: Array<{ label: string; count: number; amount: number }>
|
||||
pledges: Array<{
|
||||
id: string; reference: string; amountPence: number; status: string; rail: string;
|
||||
donorName: string | null; donorEmail: string | null; donorPhone: string | null;
|
||||
eventName: string; source: string | null; giftAid: boolean;
|
||||
dueDate: string | null; isDeferred: boolean; planId: string | null;
|
||||
installmentNumber: number | null; installmentTotal: number | null;
|
||||
createdAt: string; paidAt: string | null; nextReminder: string | null;
|
||||
}>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type DashboardData = any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type OnboardingData = any
|
||||
|
||||
/**
|
||||
* Human-readable status labels.
|
||||
* These replace SaaS jargon with language a charity volunteer would use.
|
||||
*/
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }> = {
|
||||
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
|
||||
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
||||
paid: { label: "Received", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
|
||||
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
|
||||
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
|
||||
}
|
||||
|
||||
interface OnboardingData {
|
||||
steps: Array<{ id: string; label: string; desc: string; done: boolean; href: string; action?: string }>
|
||||
completed: number
|
||||
total: number
|
||||
allDone: boolean
|
||||
orgType: string | null
|
||||
needsRole: boolean
|
||||
orgName: string
|
||||
}
|
||||
// ─── Getting Started ─────────────────────────────────────────
|
||||
|
||||
const statusIcons: Record<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle }
|
||||
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" }
|
||||
|
||||
// ─── Role Picker ────────────────────────────────────────────
|
||||
function RolePicker({ onSelect }: { onSelect: (role: string) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => onSelect("charity")}
|
||||
className="rounded-lg border-2 border-gray-100 hover:border-trust-blue bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
|
||||
>
|
||||
<div className="mx-auto w-12 h-12 rounded-lg bg-trust-blue/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Building2 className="h-6 w-6 text-trust-blue" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900">Charity / Mosque</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight">We collect donations directly via bank transfer</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelect("fundraiser")}
|
||||
className="rounded-lg border-2 border-gray-100 hover:border-warm-amber bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
|
||||
>
|
||||
<div className="mx-auto w-12 h-12 rounded-lg bg-warm-amber/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Heart className="h-6 w-6 text-warm-amber" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900">Personal Fundraiser</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-tight">I have a page on LaunchGood, Enthuse, JustGiving, etc.</p>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Getting Started Banner ─────────────────────────────────
|
||||
function GettingStartedBanner({
|
||||
ob,
|
||||
onSetRole,
|
||||
dismissed,
|
||||
onDismiss,
|
||||
function GettingStarted({
|
||||
ob, onSetRole, dismissed, onDismiss,
|
||||
}: {
|
||||
ob: OnboardingData
|
||||
onSetRole: (role: string) => void
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
ob: OnboardingData; onSetRole: (role: string) => void; dismissed: boolean; onDismiss: () => void
|
||||
}) {
|
||||
const [showRolePicker, setShowRolePicker] = useState(!ob.orgType || ob.orgType === "charity")
|
||||
|
||||
if (ob.allDone || dismissed) return null
|
||||
|
||||
// First-time: show role picker
|
||||
const isFirstTime = ob.completed === 0 && (!ob.orgType || ob.orgType === "charity")
|
||||
const isFirstTime = ob.completed === 0
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-trust-blue/20 bg-paper p-5 space-y-4 relative">
|
||||
{/* Dismiss X */}
|
||||
<button onClick={onDismiss} className="absolute top-3 right-3 text-muted-foreground hover:text-foreground p-1">
|
||||
<div className="border-l-2 border-[#1E40AF] bg-white p-5 relative">
|
||||
<button onClick={onDismiss} className="absolute top-3 right-3 text-gray-300 hover:text-gray-600 p-1">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-midnight flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-lg">🤲</span>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-8 w-8 bg-[#111827] flex items-center justify-center">
|
||||
<span className="text-white text-xs font-black">P</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-900">
|
||||
{isFirstTime ? "Welcome! What best describes you?" : `Getting started · ${ob.completed}/${ob.total}`}
|
||||
<h2 className="text-sm font-black text-[#111827]">
|
||||
{isFirstTime ? "Let's get you set up" : `Getting started — ${ob.completed} of ${ob.total} done`}
|
||||
</h2>
|
||||
{!isFirstTime && (
|
||||
<Progress value={(ob.completed / ob.total) * 100} className="h-1.5 mt-1.5 w-32" indicatorClassName="bg-promise-blue" />
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
{ob.steps.map((step: { id: string; done: boolean }) => (
|
||||
<div key={step.id} className={`h-1 w-8 ${step.done ? "bg-[#1E40AF]" : "bg-gray-200"}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFirstTime && showRolePicker ? (
|
||||
<RolePicker onSelect={(role) => { onSetRole(role); setShowRolePicker(false) }} />
|
||||
{isFirstTime && !ob.orgType ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => onSetRole("charity")}
|
||||
className="border-2 border-gray-100 hover:border-[#1E40AF] bg-white p-4 text-left transition-all group"
|
||||
>
|
||||
<p className="text-sm font-bold text-[#111827]">Charity or Mosque</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">We collect donations via bank transfer</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSetRole("fundraiser")}
|
||||
className="border-2 border-gray-100 hover:border-[#F59E0B] bg-white p-4 text-left transition-all group"
|
||||
>
|
||||
<p className="text-sm font-bold text-[#111827]">Personal Fundraiser</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">I use LaunchGood, JustGiving, etc.</p>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{ob.steps.map((step, i) => {
|
||||
const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done)
|
||||
<div className="space-y-1">
|
||||
{ob.steps.map((step: { id: string; label: string; done: boolean; href: string }, i: number) => {
|
||||
const isNext = !step.done && ob.steps.slice(0, i).every((s: { done: boolean }) => s.done)
|
||||
return (
|
||||
<Link key={step.id} href={step.href}>
|
||||
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2.5 transition-all ${
|
||||
step.done ? "bg-success-green/5 border-success-green/20" :
|
||||
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" :
|
||||
"bg-white border-gray-100"
|
||||
<div className={`flex items-center gap-2.5 px-3 py-2.5 transition-all ${
|
||||
step.done ? "opacity-50" :
|
||||
isNext ? "bg-[#1E40AF]/5 border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]" :
|
||||
""
|
||||
}`}>
|
||||
{step.done ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-success-green flex-shrink-0" />
|
||||
<CheckCircle2 className="h-4 w-4 text-[#16A34A] shrink-0" />
|
||||
) : isNext ? (
|
||||
<div className="h-4 w-4 rounded-full bg-trust-blue text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0">{i + 1}</div>
|
||||
<div className="h-4 w-4 bg-[#1E40AF] text-white text-[10px] font-bold flex items-center justify-center shrink-0">{i + 1}</div>
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-gray-300 flex-shrink-0" />
|
||||
<Circle className="h-4 w-4 text-gray-300 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs font-medium truncate ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p>
|
||||
</div>
|
||||
{isNext && <ArrowRight className="h-3 w-3 text-trust-blue flex-shrink-0" />}
|
||||
<span className={`text-xs font-medium ${step.done ? "line-through text-gray-400" : isNext ? "text-[#111827]" : "text-gray-400"}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
{isNext && <ArrowRight className="h-3 w-3 text-[#1E40AF] ml-auto shrink-0" />}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -140,6 +106,7 @@ function GettingStartedBanner({
|
||||
}
|
||||
|
||||
// ─── Main Dashboard ─────────────────────────────────────────
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -169,7 +136,6 @@ export default function DashboardPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orgType: role }),
|
||||
})
|
||||
// Refresh onboarding state
|
||||
const res = await fetch("/api/onboarding")
|
||||
const d = await res.json()
|
||||
if (d.steps) setOb(d)
|
||||
@@ -178,291 +144,227 @@ export default function DashboardPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
<Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 }
|
||||
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
|
||||
const byStatus = data?.byStatus || {}
|
||||
const topSources = data?.topSources || []
|
||||
const pledges = data?.pledges || []
|
||||
const upcomingPledges = pledges.filter(p =>
|
||||
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled"
|
||||
).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
|
||||
const recentPledges = pledges.filter(p => p.status !== "cancelled").slice(0, 8)
|
||||
const needsAction = [
|
||||
...pledges.filter(p => p.status === "overdue"),
|
||||
...upcomingPledges.filter(p => {
|
||||
const due = new Date(p.dueDate!)
|
||||
return due.getTime() - Date.now() < 2 * 86400000
|
||||
})
|
||||
|
||||
const recentPledges = pledges.filter((p: { status: string }) => p.status !== "cancelled").slice(0, 8)
|
||||
const needsAttention = [
|
||||
...pledges.filter((p: { status: string }) => p.status === "overdue"),
|
||||
...pledges.filter((p: { status: string; dueDate: string | null }) =>
|
||||
p.status !== "paid" && p.status !== "cancelled" && p.dueDate &&
|
||||
new Date(p.dueDate).getTime() - Date.now() < 2 * 86400000
|
||||
),
|
||||
].slice(0, 5)
|
||||
|
||||
const isEmpty = s.totalPledges === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Getting-started banner — always at top, not a blocker */}
|
||||
<div className="space-y-8">
|
||||
{/* Onboarding */}
|
||||
{ob && !ob.allDone && (
|
||||
<GettingStartedBanner ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
|
||||
<GettingStarted ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
|
||||
)}
|
||||
|
||||
{/* Page header — brand typography */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Home</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{whatsappStatus !== null && (
|
||||
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-muted-foreground"}`}>
|
||||
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-gray-400"}`}>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
|
||||
</span>
|
||||
)}
|
||||
{isEmpty ? "Your data will appear here" : "Auto-refreshes every 15s"}
|
||||
{isEmpty ? "Your numbers will appear here once donors start pledging" : "Updates every 15 seconds"}
|
||||
</p>
|
||||
</div>
|
||||
{!isEmpty && (
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
|
||||
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
|
||||
View all <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats — always show, even with zeros */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-black">{s.totalPledges}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Pledges</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-black">{formatPence(s.totalPledgedPence)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Pledged</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-black">{formatPence(s.totalCollectedPence)}</p>
|
||||
<p className="text-xs text-muted-foreground">Collected ({s.collectionRate}%)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={isEmpty ? "opacity-60" : s.overdueRate > 10 ? "border-danger-red/30" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-black">{byStatus.overdue || 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ─── Big Numbers — gap-px grid (brand pattern) ─── */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: String(s.totalPledges), label: "Pledges", sub: isEmpty ? "—" : undefined },
|
||||
{ value: formatPence(s.totalPledgedPence), label: "Promised" },
|
||||
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: true },
|
||||
{ value: `${s.collectionRate}%`, label: "Collected" },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className={`bg-white p-5 ${isEmpty ? "opacity-50" : ""}`}>
|
||||
<p className={`text-2xl md:text-3xl font-black tracking-tight ${stat.accent ? "text-[#16A34A]" : "text-[#111827]"}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collection progress — always visible */}
|
||||
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">Pledged → Collected</span>
|
||||
<span className="text-sm font-bold text-muted-foreground">{s.collectionRate}%</span>
|
||||
{/* ─── Collection Progress — brand bar ─── */}
|
||||
{!isEmpty && (
|
||||
<div className="bg-white p-5">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-bold text-[#111827]">Promised → Received</span>
|
||||
<span className="text-sm font-black text-[#111827]">{s.collectionRate}%</span>
|
||||
</div>
|
||||
<Progress value={s.collectionRate} indicatorClassName="bg-promise-blue" />
|
||||
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>{formatPence(s.totalCollectedPence)} collected</span>
|
||||
<span>{formatPence(s.totalPledgedPence - s.totalCollectedPence)} outstanding</span>
|
||||
<div className="h-3 bg-gray-100 overflow-hidden">
|
||||
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-500">
|
||||
<span>{formatPence(s.totalCollectedPence)} received</span>
|
||||
<span>{formatPence(s.totalPledgedPence - s.totalCollectedPence)} still to come</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
/* Empty state — gentle nudge, not a blocker */
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-10 text-center space-y-3">
|
||||
<Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto" />
|
||||
<h3 className="text-sm font-bold text-gray-900">Your pledge data will appear here</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
|
||||
Once you share your first link and donors start pledging, you'll see live stats, payment tracking, and reminders.
|
||||
</p>
|
||||
<div className="flex gap-2 justify-center pt-2">
|
||||
<Link href="/dashboard/events">
|
||||
<Button size="sm">Create a Campaign →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
/* Empty state — clean, directive */
|
||||
<div className="bg-white border-2 border-dashed border-gray-200 p-10 text-center">
|
||||
<p className="text-4xl font-black text-gray-200 mb-3">01</p>
|
||||
<h3 className="text-base font-bold text-[#111827]">Share your first pledge link</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 max-w-sm mx-auto">
|
||||
Create an appeal, share the link with donors, and watch pledges come in here.
|
||||
</p>
|
||||
<Link href="/dashboard/collect">
|
||||
<button className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors">
|
||||
Create an Appeal →
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<div className="grid lg:grid-cols-5 gap-6">
|
||||
{/* LEFT: Needs attention + Pipeline */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Needs attention */}
|
||||
<Card className={needsAction.length > 0 ? "border-warm-amber/30" : ""}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-warm-amber" /> Needs Attention
|
||||
{needsAction.length > 0 && <Badge variant="warning">{needsAction.length}</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{needsAction.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">All clear! No urgent items.</p>
|
||||
) : (
|
||||
needsAction.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatPence(p.amountPence)} · {p.eventName}
|
||||
{p.dueDate && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
</p>
|
||||
{needsAttention.length > 0 && (
|
||||
<div className="bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-[#111827]">Needs attention</h3>
|
||||
<span className="text-[10px] font-bold text-white bg-[#DC2626] px-1.5 py-0.5">{needsAttention.length}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string; dueDate: string | null }) => {
|
||||
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
|
||||
return (
|
||||
<div key={p.id} className="px-5 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#111827]">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-gray-500">{formatPence(p.amountPence)} · {p.eventName}</p>
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
</div>
|
||||
<Badge variant={p.status === "overdue" ? "destructive" : "warning"}>
|
||||
{p.status === "overdue" ? "Overdue" : "Due soon"}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{needsAction.length > 0 && (
|
||||
<Link href="/dashboard/pledges?tab=overdue" className="text-xs text-trust-blue hover:underline flex items-center gap-1 pt-1">
|
||||
View all <ArrowRight className="h-3 w-3" />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="px-5 py-2 border-t border-gray-50">
|
||||
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline">
|
||||
View all →
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming payments */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-trust-blue" /> Upcoming Payments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{upcomingPledges.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No scheduled payments</p>
|
||||
) : (
|
||||
upcomingPledges.slice(0, 5).map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-trust-blue/5 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||
{new Date(p.dueDate!).getDate()}
|
||||
<br />
|
||||
<span className="text-[8px]">{new Date(p.dueDate!).toLocaleDateString("en-GB", { month: "short" })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Pipeline + Sources */}
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Pipeline by Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* How pledges are doing — gap-px grid */}
|
||||
<div className="bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">How pledges are doing</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{Object.entries(byStatus).map(([status, count]) => {
|
||||
const Icon = statusIcons[status] || Clock
|
||||
const sl = STATUS_LABELS[status] || STATUS_LABELS.new
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Badge variant={statusColors[status] || "secondary"}>{status}</Badge>
|
||||
</div>
|
||||
<span className="font-bold">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Top Sources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{topSources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
|
||||
) : (
|
||||
topSources.slice(0, 6).map((src, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
|
||||
<span className="text-sm">{src.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{src.count} pledges</span>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent pledges */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Recent Pledges</CardTitle>
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="ghost" size="sm" className="text-xs">View all <ExternalLink className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{recentPledges.map(p => {
|
||||
const sc = statusColors[p.status] || "secondary"
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||
{(p.donorName || "A")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
{p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
<Badge variant={sc}>{p.status}</Badge>
|
||||
</div>
|
||||
<div key={status} className="px-5 py-2.5 flex items-center justify-between">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
<span className="text-sm font-black text-[#111827]">{count as number}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Top sources */}
|
||||
{topSources.length > 0 && (
|
||||
<div className="bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">Where pledges come from</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => (
|
||||
<div key={i} className="px-5 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-black text-gray-300 w-4">{i + 1}</span>
|
||||
<span className="text-sm text-[#111827]">{src.label}</span>
|
||||
<span className="text-[10px] text-gray-400">{src.count} pledges</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[#111827]">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Recent pledges */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-[#111827]">Recent pledges</h3>
|
||||
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentPledges.map((p: {
|
||||
id: string; donorName: string | null; amountPence: number; status: string;
|
||||
eventName: string; createdAt: string; donorPhone: string | null;
|
||||
installmentNumber: number | null; installmentTotal: number | null;
|
||||
}) => {
|
||||
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
|
||||
const initial = (p.donorName || "A")[0].toUpperCase()
|
||||
const daysDiff = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
|
||||
const timeLabel = daysDiff === 0 ? "Today" : daysDiff === 1 ? "Yesterday" : daysDiff < 7 ? `${daysDiff}d ago` : new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
||||
|
||||
return (
|
||||
<div key={p.id} className="px-5 py-3 flex items-center gap-3">
|
||||
<div className="h-8 w-8 bg-[#1E40AF]/10 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-black text-[#1E40AF]">{initial}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||
{p.donorPhone && <MessageCircle className="h-3 w-3 text-[#25D366] shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{p.eventName} · {timeLabel}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{recentPledges.length === 0 && (
|
||||
<div className="px-5 py-8 text-center text-sm text-gray-400">
|
||||
Pledges will appear here as they come in
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,77 +1,55 @@
|
||||
"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
|
||||
Search, MoreVertical, CheckCircle2, XCircle, MessageCircle, Send,
|
||||
ChevronLeft, ChevronRight, Loader2
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
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
|
||||
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 },
|
||||
/**
|
||||
* Human status labels — no SaaS jargon
|
||||
*/
|
||||
const STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
||||
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
|
||||
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
||||
paid: { label: "Received ✓", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
|
||||
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
|
||||
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
|
||||
}
|
||||
|
||||
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)
|
||||
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 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" })
|
||||
return new Date(dateStr).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)
|
||||
const days = Math.ceil((new Date(dueDate).getTime() - Date.now()) / 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 }
|
||||
return { text: new Date(dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" }), urgent: false }
|
||||
}
|
||||
|
||||
export default function PledgesPage() {
|
||||
export default function MoneyPage() {
|
||||
const [pledges, setPledges] = useState<Pledge[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -82,21 +60,19 @@ export default function PledgesPage() {
|
||||
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 [stats, setStats] = useState({ total: 0, waiting: 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")
|
||||
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")
|
||||
params.set("sort", "createdAt")
|
||||
params.set("dir", "desc")
|
||||
|
||||
const res = await fetch(`/api/pledges?${params}`)
|
||||
const data = await res.json()
|
||||
@@ -105,30 +81,25 @@ export default function PledgesPage() {
|
||||
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(() => {
|
||||
fetch("/api/dashboard")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.summary) {
|
||||
setStats({
|
||||
total: data.summary.totalPledges,
|
||||
waiting: (data.byStatus?.new || 0) + (data.byStatus?.initiated || 0),
|
||||
overdue: data.byStatus?.overdue || 0,
|
||||
paid: data.byStatus?.paid || 0,
|
||||
totalPledged: data.summary.totalPledgedPence,
|
||||
totalCollected: data.summary.totalCollectedPence,
|
||||
})
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchPledges() }, [fetchPledges])
|
||||
useEffect(() => { fetchStats() }, [fetchStats])
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchPledges, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchPledges])
|
||||
useEffect(() => { const i = setInterval(fetchPledges, 30000); return () => clearInterval(i) }, [fetchPledges])
|
||||
|
||||
const updateStatus = async (pledgeId: string, newStatus: string) => {
|
||||
setUpdating(pledgeId)
|
||||
@@ -139,39 +110,24 @@ export default function PledgesPage() {
|
||||
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")
|
||||
}
|
||||
toast(`Updated`, "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
|
||||
}
|
||||
if (!pledge.donorPhone) { toast("No phone number", "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,
|
||||
},
|
||||
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")
|
||||
}
|
||||
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
|
||||
@@ -182,236 +138,193 @@ export default function PledgesPage() {
|
||||
{/* 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
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Money</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{stats.total} pledges · {formatPence(stats.totalPledged)} promised · {collectionRate}% received
|
||||
</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 className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
placeholder="Search name, email, reference..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(0) }}
|
||||
className="pl-9 w-64 h-9 border border-gray-200 text-sm focus:border-[#1E40AF] focus:ring-1 focus:ring-[#1E40AF]/20 outline-none transition-colors"
|
||||
/>
|
||||
</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>
|
||||
{/* Quick stats — gap-px */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-200">
|
||||
{[
|
||||
{ label: "All", count: stats.total, onClick: () => setTab("all") },
|
||||
{ label: "Waiting", count: stats.waiting, onClick: () => setTab("new") },
|
||||
{ label: "Needs a nudge", count: stats.overdue, onClick: () => setTab("overdue"), alert: stats.overdue > 0 },
|
||||
{ label: "Received", count: stats.paid, onClick: () => setTab("paid"), accent: true },
|
||||
].map(s => (
|
||||
<button key={s.label} onClick={s.onClick} className={`bg-white p-4 text-left hover:bg-gray-50 transition-colors ${tab === (s.label === "All" ? "all" : s.label === "Waiting" ? "new" : s.label === "Received" ? "paid" : "overdue") ? "border-b-2 border-[#1E40AF]" : ""}`}>
|
||||
<p className={`text-xl font-black ${s.alert ? "text-[#DC2626]" : s.accent ? "text-[#16A34A]" : "text-[#111827]"}`}>{s.count}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collection progress */}
|
||||
{/* Collection bar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Progress value={collectionRate} className="flex-1 h-2" indicatorClassName="bg-promise-blue" />
|
||||
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
||||
{formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 overflow-hidden">
|
||||
<div className="h-full bg-[#1E40AF] transition-all" style={{ width: `${collectionRate}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-500 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>
|
||||
<Tabs value={tab} onValueChange={v => { setTab(v); setPage(0) }}>
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto bg-transparent border-b border-gray-200 rounded-none p-0 h-auto">
|
||||
{[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "new", label: "Waiting" },
|
||||
{ value: "initiated", label: "Said they paid" },
|
||||
{ value: "overdue", label: "Needs a nudge" },
|
||||
{ value: "paid", label: "Received" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
].map(t => (
|
||||
<TabsTrigger key={t.value} value={t.value} className="rounded-none border-b-2 border-transparent data-[state=active]:border-[#1E40AF] data-[state=active]:bg-transparent data-[state=active]:shadow-none text-xs font-semibold px-3 py-2">
|
||||
{t.label}
|
||||
</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
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||
) : pledges.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-sm font-medium text-[#111827]">No pledges found</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{search ? `No results for "${search}"` : "Share your pledge links to start collecting"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-12 gap-2 px-5 py-2.5 border-b border-gray-100 text-[10px] font-bold text-gray-400 uppercase tracking-wide">
|
||||
<div className="col-span-4">Donor</div>
|
||||
<div className="col-span-2">Amount</div>
|
||||
<div className="col-span-2 hidden md:block">Appeal</div>
|
||||
<div className="col-span-2">Status</div>
|
||||
<div className="col-span-1 hidden sm:block">When</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
{/* Rows */}
|
||||
{pledges.map(p => {
|
||||
const sl = STATUS[p.status] || STATUS.new
|
||||
const due = p.dueDate ? dueLabel(p.dueDate) : null
|
||||
|
||||
return (
|
||||
<div key={p.id} className={`grid grid-cols-12 gap-2 px-5 py-3 border-b border-gray-50 items-center hover:bg-gray-50/50 transition-colors ${updating === p.id ? "opacity-50" : ""}`}>
|
||||
{/* Donor */}
|
||||
<div className="col-span-4">
|
||||
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<code className="text-[10px] text-gray-400 font-mono">{p.reference}</code>
|
||||
{p.donorPhone && <MessageCircle className="h-2.5 w-2.5 text-[#25D366]" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
|
||||
{p.giftAid && <span className="text-[9px] text-[#16A34A] font-bold">+Gift Aid</span>}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && (
|
||||
<p className="text-[9px] text-[#F59E0B] font-bold">{p.installmentNumber}/{p.installmentTotal}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appeal */}
|
||||
<div className="col-span-2 hidden md:block">
|
||||
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
|
||||
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="col-span-2">
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
</div>
|
||||
|
||||
{/* When */}
|
||||
<div className="col-span-1 hidden sm:block">
|
||||
{due ? (
|
||||
<span className={`text-xs ${due.urgent ? "font-bold text-[#DC2626]" : "text-gray-500"}`}>{due.text}</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="p-1.5 hover:bg-gray-100 transition-colors">
|
||||
<MoreVertical className="h-4 w-4 text-gray-400" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
|
||||
<CheckCircle2 className="h-4 w-4 text-[#16A34A]" /> Mark as received
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.status !== "initiated" && p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
|
||||
<Send className="h-4 w-4 text-[#F59E0B]" /> Mark as "said they paid"
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<p className="text-xs text-gray-500">{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)}>
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
|
||||
</button>
|
||||
<button disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Match payments link */}
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4 py-2">
|
||||
<Link href="/dashboard/reconcile" className="text-sm font-bold text-[#111827] hover:text-[#1E40AF] transition-colors">
|
||||
Match bank payments →
|
||||
</Link>
|
||||
<p className="text-xs text-gray-500">Upload a bank statement to automatically match payments to pledges</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useState } 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 { Badge } from "@/components/ui/badge"
|
||||
import { Upload, CheckCircle2, AlertCircle, HelpCircle, FileSpreadsheet } from "lucide-react"
|
||||
import { Upload, CheckCircle2, AlertCircle, HelpCircle, ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface MatchResult {
|
||||
bankRow: {
|
||||
date: string
|
||||
description: string
|
||||
amount: number
|
||||
reference: string
|
||||
}
|
||||
pledgeId: string | null
|
||||
pledgeReference: string | null
|
||||
bankRow: { date: string; description: string; amount: number; reference: string }
|
||||
pledgeId: string | null; pledgeReference: string | null
|
||||
confidence: "exact" | "partial" | "amount_only" | "none"
|
||||
matchedAmount: number
|
||||
autoConfirmed: boolean
|
||||
matchedAmount: number; autoConfirmed: boolean
|
||||
}
|
||||
|
||||
export default function ReconcilePage() {
|
||||
export default function MatchPaymentsPage() {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [results, setResults] = useState<{
|
||||
summary: {
|
||||
totalRows: number
|
||||
credits: number
|
||||
exactMatches: number
|
||||
partialMatches: number
|
||||
unmatched: number
|
||||
autoConfirmed: number
|
||||
}
|
||||
matches: MatchResult[]
|
||||
} | null>(null)
|
||||
const [bankName, setBankName] = useState("")
|
||||
const [results, setResults] = useState<{ summary: { totalRows: number; credits: number; exactMatches: number; partialMatches: number; unmatched: number; autoConfirmed: number }; matches: MatchResult[] } | null>(null)
|
||||
const [mapping, setMapping] = useState({ dateCol: "Date", descriptionCol: "Description", creditCol: "Credit", referenceCol: "Reference" })
|
||||
|
||||
const [mapping, setMapping] = useState({
|
||||
dateCol: "Date",
|
||||
descriptionCol: "Description",
|
||||
creditCol: "Credit",
|
||||
referenceCol: "Reference",
|
||||
})
|
||||
// Try auto-detecting bank format
|
||||
const autoDetect = async (f: File) => {
|
||||
const text = await f.text()
|
||||
const firstLine = text.split("\n")[0]
|
||||
|
||||
// Try bank preset detection
|
||||
try {
|
||||
const res = await fetch("/api/imports/presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headers: firstLine.split(",").map(h => h.replace(/"/g, "").trim()) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detected && data.preset) {
|
||||
setBankName(data.preset.bankName)
|
||||
setMapping({
|
||||
dateCol: data.preset.dateCol,
|
||||
descriptionCol: data.preset.descriptionCol,
|
||||
creditCol: data.preset.creditCol || data.preset.amountCol || "Credit",
|
||||
referenceCol: data.preset.referenceCol || "Reference",
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch { /* fallback to AI */ }
|
||||
|
||||
// Try AI column mapping
|
||||
try {
|
||||
const headers = firstLine.split(",").map(h => h.replace(/"/g, "").trim())
|
||||
const rows = text.split("\n").slice(1, 4).map(row => row.split(",").map(c => c.replace(/"/g, "").trim()))
|
||||
const res = await fetch("/api/ai/map-columns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headers, sampleRows: rows }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.dateCol) {
|
||||
setBankName(data.source === "ai" ? "Auto-detected" : "")
|
||||
setMapping({
|
||||
dateCol: data.dateCol,
|
||||
descriptionCol: data.descriptionCol || "Description",
|
||||
creditCol: data.creditCol || data.amountCol || "Credit",
|
||||
referenceCol: data.referenceCol || "Reference",
|
||||
})
|
||||
}
|
||||
} catch { /* keep defaults */ }
|
||||
}
|
||||
|
||||
const handleFileSelect = (f: File) => {
|
||||
setFile(f)
|
||||
autoDetect(f)
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
@@ -51,187 +77,147 @@ export default function ReconcilePage() {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("mapping", JSON.stringify(mapping))
|
||||
|
||||
const res = await fetch("/api/imports/bank-statement", {
|
||||
method: "POST",
|
||||
headers: { },
|
||||
body: formData,
|
||||
})
|
||||
const res = await fetch("/api/imports/bank-statement", { method: "POST", body: formData })
|
||||
const data = await res.json()
|
||||
if (data.summary) {
|
||||
setResults(data)
|
||||
}
|
||||
} catch {
|
||||
// handle error
|
||||
}
|
||||
if (data.summary) setResults(data)
|
||||
} catch { /* */ }
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const confidenceIcon = (c: string) => {
|
||||
switch (c) {
|
||||
case "exact": return <CheckCircle2 className="h-4 w-4 text-success-green" />
|
||||
case "partial": return <AlertCircle className="h-4 w-4 text-warm-amber" />
|
||||
default: return <HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
case "exact": return <CheckCircle2 className="h-4 w-4 text-[#16A34A]" />
|
||||
case "partial": return <AlertCircle className="h-4 w-4 text-[#F59E0B]" />
|
||||
default: return <HelpCircle className="h-4 w-4 text-gray-300" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Reconcile Payments</h1>
|
||||
<p className="text-muted-foreground mt-1">Upload a bank statement CSV to automatically match payments to pledges</p>
|
||||
<Link href="/dashboard/money" className="text-xs text-gray-500 hover:text-[#111827] transition-colors inline-flex items-center gap-1 mb-2">
|
||||
<ArrowLeft className="h-3 w-3" /> Back to Money
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Match Payments</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Upload your bank statement and we'll match payments to pledges automatically</p>
|
||||
</div>
|
||||
|
||||
{/* Upload card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" /> Bank Statement Import
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Export your bank statement as CSV and upload it here. We'll match payment references automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Column mapping */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Date Column</Label>
|
||||
<Input
|
||||
value={mapping.dateCol}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, dateCol: e.target.value }))}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
{/* Upload section */}
|
||||
<div className="bg-white border border-gray-200 p-6 space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Upload bank statement</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Download a CSV from your bank's website and upload it here. We recognise formats from Barclays, HSBC, Lloyds, NatWest, Monzo, Starling, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File drop zone */}
|
||||
<div className="border-2 border-dashed border-gray-200 p-8 text-center hover:border-[#1E40AF]/50 transition-colors">
|
||||
<Upload className="h-8 w-8 text-gray-300 mx-auto mb-3" />
|
||||
<input type="file" accept=".csv" onChange={e => e.target.files?.[0] && handleFileSelect(e.target.files[0])} className="hidden" id="csv-upload" />
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<p className="text-sm font-medium text-[#111827]">{file ? file.name : "Click to choose a CSV file"}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-1">CSV file from your online banking</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Column mapping — auto-detected, editable */}
|
||||
{file && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-xs font-bold text-gray-600">Column mapping</p>
|
||||
{bankName && <span className="text-[10px] font-bold text-[#16A34A] bg-[#16A34A]/10 px-1.5 py-0.5">{bankName} detected</span>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Description Column</Label>
|
||||
<Input
|
||||
value={mapping.descriptionCol}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, descriptionCol: e.target.value }))}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Credit/Amount Column</Label>
|
||||
<Input
|
||||
value={mapping.creditCol}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, creditCol: e.target.value }))}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Reference Column</Label>
|
||||
<Input
|
||||
value={mapping.referenceCol}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, referenceCol: e.target.value }))}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ key: "dateCol", label: "Date column" },
|
||||
{ key: "descriptionCol", label: "Description column" },
|
||||
{ key: "creditCol", label: "Amount / Credit column" },
|
||||
{ key: "referenceCol", label: "Reference column" },
|
||||
].map(col => (
|
||||
<div key={col.key} className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-500">{col.label}</label>
|
||||
<input
|
||||
value={mapping[col.key as keyof typeof mapping]}
|
||||
onChange={e => setMapping(m => ({ ...m, [col.key]: e.target.value }))}
|
||||
className="w-full h-8 px-2 border border-gray-200 text-xs focus:border-[#1E40AF] outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File upload */}
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center hover:border-trust-blue/50 transition-colors">
|
||||
<Upload className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
id="csv-upload"
|
||||
/>
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<p className="font-medium">{file ? file.name : "Click to upload CSV"}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">CSV file from your bank</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full">
|
||||
{uploading ? "Processing..." : "Upload & Match"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="w-full bg-[#111827] px-4 py-2.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uploading ? "Matching payments..." : "Upload & Match"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold">{results.summary.totalRows}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Rows</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold">{results.summary.credits}</p>
|
||||
<p className="text-xs text-muted-foreground">Credits</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-success-green">{results.summary.exactMatches}</p>
|
||||
<p className="text-xs text-muted-foreground">Exact Matches</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-warm-amber">{results.summary.partialMatches}</p>
|
||||
<p className="text-xs text-muted-foreground">Partial</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-2xl font-bold text-success-green">{results.summary.autoConfirmed}</p>
|
||||
<p className="text-xs text-muted-foreground">Auto-confirmed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Summary — gap-px grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: results.summary.totalRows, label: "Rows" },
|
||||
{ value: results.summary.credits, label: "Incoming payments" },
|
||||
{ value: results.summary.exactMatches, label: "Matched", accent: "text-[#16A34A]" },
|
||||
{ value: results.summary.partialMatches, label: "Possible matches", accent: "text-[#F59E0B]" },
|
||||
{ value: results.summary.autoConfirmed, label: "Auto-confirmed", accent: "text-[#16A34A]" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.accent || "text-[#111827]"}`}>{s.value}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Match table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Match Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-3 font-medium text-muted-foreground">Confidence</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Date</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Description</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Amount</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Matched Pledge</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Status</th>
|
||||
<div className="bg-white border border-gray-200">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">Results</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Match</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Date</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Description</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Amount</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Pledge</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{results.matches.map((m, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50/50">
|
||||
<td className="px-5 py-3">{confidenceIcon(m.confidence)}</td>
|
||||
<td className="px-5 py-3 text-xs text-gray-600">{m.bankRow.date}</td>
|
||||
<td className="px-5 py-3 text-xs text-gray-600 max-w-[200px] truncate">{m.bankRow.description}</td>
|
||||
<td className="px-5 py-3 text-sm font-black text-[#111827]">£{m.matchedAmount.toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-xs font-mono text-gray-500">{m.pledgeReference || "—"}</td>
|
||||
<td className="px-5 py-3">
|
||||
{m.autoConfirmed ? (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Auto-confirmed</span>
|
||||
) : m.confidence === "partial" ? (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B]">Check this</span>
|
||||
) : m.confidence === "none" ? (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-500">No match</span>
|
||||
) : (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Matched</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{results.matches.map((m, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50/50">
|
||||
<td className="py-3">{confidenceIcon(m.confidence)}</td>
|
||||
<td className="py-3">{m.bankRow.date}</td>
|
||||
<td className="py-3 max-w-[200px] truncate">{m.bankRow.description}</td>
|
||||
<td className="py-3 font-bold">£{m.matchedAmount.toFixed(2)}</td>
|
||||
<td className="py-3 font-mono">{m.pledgeReference || "—"}</td>
|
||||
<td className="py-3">
|
||||
{m.autoConfirmed ? (
|
||||
<Badge variant="success">Auto-confirmed</Badge>
|
||||
) : m.confidence === "partial" ? (
|
||||
<Badge variant="warning">Review needed</Badge>
|
||||
) : m.confidence === "none" ? (
|
||||
<Badge variant="outline">No match</Badge>
|
||||
) : (
|
||||
<Badge variant="success">Matched</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
5
pledge-now-pay-later/src/app/dashboard/reports/page.tsx
Normal file
5
pledge-now-pay-later/src/app/dashboard/reports/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* /dashboard/reports — "My treasurer needs numbers"
|
||||
* Renamed exports page.
|
||||
*/
|
||||
export { default } from "../exports/page"
|
||||
@@ -1,27 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } 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 { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Building2, CreditCard, Palette, Check, Loader2, AlertCircle,
|
||||
MessageCircle, Radio, QrCode, RefreshCw, Smartphone, Wifi, WifiOff
|
||||
Check, Loader2, AlertCircle,
|
||||
MessageCircle, Radio, RefreshCw, Smartphone, Wifi, WifiOff, QrCode
|
||||
} from "lucide-react"
|
||||
|
||||
interface OrgSettings {
|
||||
name: string
|
||||
bankName: string
|
||||
bankSortCode: string
|
||||
bankAccountNo: string
|
||||
bankAccountName: string
|
||||
refPrefix: string
|
||||
primaryColor: string
|
||||
gcAccessToken: string
|
||||
gcEnvironment: string
|
||||
orgType: string
|
||||
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
|
||||
bankAccountName: string; refPrefix: string; primaryColor: string
|
||||
gcAccessToken: string; gcEnvironment: string; orgType: string
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -33,281 +22,226 @@ export default function SettingsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { if (data.name) setSettings(data) })
|
||||
.then(r => r.json())
|
||||
.then(data => { if (data.name) setSettings(data) })
|
||||
.catch(() => setError("Failed to load settings"))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const save = async (section: string, data: Record<string, string>) => {
|
||||
setSaving(section)
|
||||
setError(null)
|
||||
setSaving(section); setError(null)
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
|
||||
if (res.ok) { setSaved(section); setTimeout(() => setSaved(null), 2000) }
|
||||
else setError("Failed to save")
|
||||
} catch { setError("Failed to save") }
|
||||
setSaving(null)
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 text-trust-blue animate-spin" /></div>
|
||||
if (!settings) return <div className="text-center py-20"><AlertCircle className="h-8 w-8 text-danger-red mx-auto mb-2" /><p className="text-muted-foreground">Failed to load settings</p></div>
|
||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||
if (!settings) return <div className="text-center py-20"><AlertCircle className="h-6 w-6 text-[#DC2626] mx-auto mb-2" /><p className="text-sm text-gray-500">Failed to load settings</p></div>
|
||||
|
||||
const update = (key: keyof OrgSettings, value: string) => setSettings((s) => s ? { ...s, [key]: value } : s)
|
||||
const update = (key: keyof OrgSettings, value: string) => setSettings(s => s ? { ...s, [key]: value } : s)
|
||||
|
||||
const SaveButton = ({ section, data }: { section: string; data: Record<string, string> }) => (
|
||||
<button
|
||||
onClick={() => save(section, data)}
|
||||
disabled={saving === section}
|
||||
className="bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving === section ? <><Loader2 className="h-3 w-3 mr-1.5 animate-spin inline" /> Saving</> : saved === section ? <><Check className="h-3 w-3 mr-1.5 inline" /> Saved!</> : "Save"}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-gray-900">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Configure your organisation's payment details and integrations</p>
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Settings</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Your charity details, bank account, and connections</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg bg-danger-red/10 border border-danger-red/20 p-3 text-sm text-danger-red">{error}</div>}
|
||||
{error && <div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>}
|
||||
|
||||
{/* WhatsApp — MOST IMPORTANT, first */}
|
||||
{/* WhatsApp — most important, always first */}
|
||||
<WhatsAppPanel />
|
||||
|
||||
{/* Bank Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2"><Building2 className="h-4 w-4 text-trust-blue" /> Bank Account</CardTitle>
|
||||
<CardDescription className="text-xs">Shown to donors who choose bank transfer. Each pledge gets a unique reference.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">Bank Name</Label><Input value={settings.bankName} onChange={(e) => update("bankName", e.target.value)} placeholder="e.g. Barclays" /></div>
|
||||
<div><Label className="text-xs">Account Name</Label><Input value={settings.bankAccountName} onChange={(e) => update("bankAccountName", e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label className="text-xs">Sort Code</Label><Input value={settings.bankSortCode} onChange={(e) => update("bankSortCode", e.target.value)} placeholder="20-30-80" /></div>
|
||||
<div><Label className="text-xs">Account Number</Label><Input value={settings.bankAccountNo} onChange={(e) => update("bankAccountNo", e.target.value)} placeholder="12345678" /></div>
|
||||
</div>
|
||||
<div><Label className="text-xs">Reference Prefix</Label><Input value={settings.refPrefix} onChange={(e) => update("refPrefix", e.target.value)} maxLength={4} className="w-24" /><p className="text-[10px] text-muted-foreground mt-1">e.g. {settings.refPrefix}-XXXX-50</p></div>
|
||||
<Button size="sm" onClick={() => save("bank", { bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix })} disabled={saving === "bank"}>
|
||||
{saving === "bank" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "bank" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save Bank Details"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Bank account */}
|
||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Bank account</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">These details are shown to donors so they can transfer money to you. Each pledge gets a unique reference code.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Bank name</label><Input value={settings.bankName} onChange={e => update("bankName", e.target.value)} placeholder="e.g. Barclays" /></div>
|
||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Account name</label><Input value={settings.bankAccountName} onChange={e => update("bankAccountName", e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Sort code</label><Input value={settings.bankSortCode} onChange={e => update("bankSortCode", e.target.value)} placeholder="20-30-80" /></div>
|
||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Account number</label><Input value={settings.bankAccountNo} onChange={e => update("bankAccountNo", e.target.value)} placeholder="12345678" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">Reference code prefix</label>
|
||||
<Input value={settings.refPrefix} onChange={e => update("refPrefix", e.target.value)} maxLength={4} className="w-24" />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Donors will see references like <strong>{settings.refPrefix}-XXXX-50</strong></p>
|
||||
</div>
|
||||
<SaveButton section="bank" data={{ bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix }} />
|
||||
</div>
|
||||
|
||||
{/* GoCardless */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2"><CreditCard className="h-4 w-4 text-trust-blue" /> GoCardless (Direct Debit)</CardTitle>
|
||||
<CardDescription className="text-xs">Enable Direct Debit collection protected by the DD Guarantee.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div><Label className="text-xs">Access Token</Label><Input type="password" value={settings.gcAccessToken} onChange={(e) => update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" /></div>
|
||||
<div>
|
||||
<Label className="text-xs">Environment</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{["sandbox", "live"].map((env) => (
|
||||
<button key={env} onClick={() => update("gcEnvironment", env)} className={`px-3 py-1.5 rounded-lg text-xs font-medium border-2 transition-colors ${settings.gcEnvironment === env ? env === "live" ? "border-danger-red bg-danger-red/5 text-danger-red" : "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-muted-foreground"}`}>
|
||||
{env.charAt(0).toUpperCase() + env.slice(1)} {env === "live" && settings.gcEnvironment === "live" && "⚠️"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Direct Debit */}
|
||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Direct Debit</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Accept Direct Debit payments via GoCardless. Donors set up a mandate and payments are collected automatically.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">GoCardless access token</label>
|
||||
<Input type="password" value={settings.gcAccessToken} onChange={e => update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">Mode</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{["sandbox", "live"].map(env => (
|
||||
<button key={env} onClick={() => update("gcEnvironment", env)} className={`px-3 py-1.5 text-xs font-bold border-2 transition-colors ${settings.gcEnvironment === env ? env === "live" ? "border-[#DC2626] bg-[#DC2626]/5 text-[#DC2626]" : "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}>
|
||||
{env === "sandbox" ? "Test mode" : "Live mode"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => save("gc", { gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment })} disabled={saving === "gc"}>
|
||||
{saving === "gc" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "gc" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save GoCardless"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<SaveButton section="gc" data={{ gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment }} />
|
||||
</div>
|
||||
|
||||
{/* Branding */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2"><Palette className="h-4 w-4 text-trust-blue" /> Branding</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div><Label className="text-xs">Organisation Name</Label><Input value={settings.name} onChange={(e) => update("name", e.target.value)} /></div>
|
||||
<div>
|
||||
<Label className="text-xs">Primary Colour</Label>
|
||||
<div className="flex gap-2 mt-1"><Input type="color" value={settings.primaryColor} onChange={(e) => update("primaryColor", e.target.value)} className="w-12 h-9 p-0.5" /><Input value={settings.primaryColor} onChange={(e) => update("primaryColor", e.target.value)} className="flex-1" /></div>
|
||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Your charity</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">This name and colour appear on pledge pages and WhatsApp messages.</p>
|
||||
</div>
|
||||
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Charity name</label><Input value={settings.name} onChange={e => update("name", e.target.value)} /></div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">Brand colour</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input type="color" value={settings.primaryColor} onChange={e => update("primaryColor", e.target.value)} className="w-12 h-9 p-0.5" />
|
||||
<Input value={settings.primaryColor} onChange={e => update("primaryColor", e.target.value)} className="flex-1" />
|
||||
</div>
|
||||
<Button size="sm" onClick={() => save("brand", { name: settings.name, primaryColor: settings.primaryColor })} disabled={saving === "brand"}>
|
||||
{saving === "brand" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "brand" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save Branding"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<SaveButton section="brand" data={{ name: settings.name, primaryColor: settings.primaryColor }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── WhatsApp Connection Panel ───────────────────────────────────
|
||||
// ─── WhatsApp Connection Panel ───────────────────────────────
|
||||
|
||||
function WhatsAppPanel() {
|
||||
const [status, setStatus] = useState<string>("loading")
|
||||
const [qrImage, setQrImage] = useState<string | null>(null)
|
||||
const [phone, setPhone] = useState<string>("")
|
||||
const [pushName, setPushName] = useState<string>("")
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [showQr, setShowQr] = useState(false) // only true after user clicks Connect
|
||||
const [phone, setPhone] = useState(""); const [pushName, setPushName] = useState("")
|
||||
const [starting, setStarting] = useState(false); const [showQr, setShowQr] = useState(false)
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/whatsapp/qr")
|
||||
const data = await res.json()
|
||||
const res = await fetch("/api/whatsapp/qr"); const data = await res.json()
|
||||
setStatus(data.status)
|
||||
if (data.screenshot) setQrImage(data.screenshot)
|
||||
if (data.phone) setPhone(data.phone)
|
||||
if (data.pushName) setPushName(data.pushName)
|
||||
// Auto-show QR panel once connected (user paired successfully)
|
||||
if (data.status === "CONNECTED") setShowQr(false)
|
||||
} catch {
|
||||
setStatus("ERROR")
|
||||
}
|
||||
} catch { setStatus("ERROR") }
|
||||
}, [])
|
||||
|
||||
// On mount: just check if already connected. Don't start polling yet.
|
||||
useEffect(() => { checkStatus() }, [checkStatus])
|
||||
|
||||
// Poll only when user has clicked Connect and we're waiting for scan
|
||||
useEffect(() => {
|
||||
if (!showQr) return
|
||||
const interval = setInterval(checkStatus, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [showQr, checkStatus])
|
||||
useEffect(() => { if (!showQr) return; const i = setInterval(checkStatus, 5000); return () => clearInterval(i) }, [showQr, checkStatus])
|
||||
|
||||
const startSession = async () => {
|
||||
setStarting(true)
|
||||
setShowQr(true)
|
||||
try {
|
||||
await fetch("/api/whatsapp/qr", { method: "POST" })
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await checkStatus()
|
||||
} catch { /* ignore */ }
|
||||
setStarting(true); setShowQr(true)
|
||||
try { await fetch("/api/whatsapp/qr", { method: "POST" }); await new Promise(r => setTimeout(r, 3000)); await checkStatus() } catch { /* */ }
|
||||
setStarting(false)
|
||||
}
|
||||
|
||||
if (status === "CONNECTED") {
|
||||
return (
|
||||
<Card className="border-[#25D366]/30 bg-[#25D366]/[0.02]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp
|
||||
<Badge variant="success" className="gap-1 ml-1"><Radio className="h-2.5 w-2.5" /> Connected</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-[#25D366]/10 flex items-center justify-center">
|
||||
<Smartphone className="h-6 w-6 text-[#25D366]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{pushName || "WhatsApp Business"}</p>
|
||||
<p className="text-xs text-muted-foreground">+{phone}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Wifi className="h-5 w-5 text-[#25D366]" />
|
||||
</div>
|
||||
<div className="bg-white border border-[#25D366]/30 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#25D366]/10 text-[#25D366] flex items-center gap-1"><Radio className="h-2.5 w-2.5" /> Connected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-[#25D366]/10 flex items-center justify-center"><Smartphone className="h-5 w-5 text-[#25D366]" /></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#111827]">{pushName || "WhatsApp"}</p>
|
||||
<p className="text-xs text-gray-500">+{phone}</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Auto-Sends</p>
|
||||
<p className="text-xs font-medium">Receipts</p>
|
||||
<Wifi className="h-5 w-5 text-[#25D366] ml-auto" />
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Receipts", desc: "Auto-sends when someone pledges" },
|
||||
{ label: "Reminders", desc: "4-step reminder sequence" },
|
||||
{ label: "Chatbot", desc: "Donors reply PAID, HELP, etc." },
|
||||
].map(f => (
|
||||
<div key={f.label} className="text-center">
|
||||
<p className="text-xs font-bold text-[#111827]">{f.label}</p>
|
||||
<p className="text-[9px] text-gray-500 mt-0.5">{f.desc}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Reminders</p>
|
||||
<p className="text-xs font-medium">4-step</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Chatbot</p>
|
||||
<p className="text-xs font-medium">PAID / HELP</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "SCAN_QR_CODE" && showQr) {
|
||||
return (
|
||||
<Card className="border-warm-amber/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp
|
||||
<Badge variant="warning" className="gap-1 ml-1"><QrCode className="h-2.5 w-2.5" /> Scan QR</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">Open WhatsApp on your phone → Settings → Linked Devices → Link a Device</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{qrImage ? (
|
||||
<div className="relative">
|
||||
{/* Crop to QR area: the screenshot shows full WhatsApp web page.
|
||||
QR code is roughly in center. We use overflow hidden + object positioning. */}
|
||||
<div className="w-72 h-72 rounded-lg border-2 border-[#25D366]/20 overflow-hidden bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrImage}
|
||||
alt="WhatsApp QR Code"
|
||||
className="w-[200%] h-auto max-w-none"
|
||||
style={{ marginLeft: "-30%", marginTop: "-35%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 w-8 h-8 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg">
|
||||
<MessageCircle className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-72 h-72 rounded-lg border-2 border-dashed border-muted flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium">Scan with WhatsApp</p>
|
||||
<p className="text-xs text-muted-foreground">Open WhatsApp → Settings → Linked Devices → Link a Device</p>
|
||||
<p className="text-xs text-muted-foreground">Auto-refreshes every 5 seconds</p>
|
||||
<div className="bg-white border border-[#F59E0B]/30 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B] flex items-center gap-1"><QrCode className="h-2.5 w-2.5" /> Scan QR code</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{qrImage ? (
|
||||
<div className="w-64 h-64 border-2 border-[#25D366]/20 overflow-hidden bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={qrImage} alt="WhatsApp QR Code" className="w-[200%] h-auto max-w-none" style={{ marginLeft: "-30%", marginTop: "-35%" }} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={checkStatus} className="gap-1.5">
|
||||
<RefreshCw className="h-3 w-3" /> Refresh
|
||||
</Button>
|
||||
) : (
|
||||
<div className="w-64 h-64 border-2 border-dashed border-gray-200 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 text-gray-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-bold text-[#111827]">Scan with your phone</p>
|
||||
<p className="text-xs text-gray-500">Open WhatsApp → Settings → Linked Devices → Link a Device</p>
|
||||
<p className="text-[10px] text-gray-400">Auto-refreshes every 5 seconds</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<button onClick={checkStatus} className="border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-gray-50 flex items-center gap-1.5">
|
||||
<RefreshCw className="h-3 w-3" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// NO_SESSION or STARTING or ERROR
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" /> WhatsApp
|
||||
<Badge variant="secondary" className="gap-1 ml-1"><WifiOff className="h-2.5 w-2.5" /> Offline</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Connect WhatsApp to auto-send pledge receipts, payment reminders, and enable a chatbot for donors.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#25D366]/10 flex items-center justify-center flex-shrink-0">
|
||||
<Smartphone className="h-5 w-5 text-[#25D366]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Connect your WhatsApp number</p>
|
||||
<ul className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
||||
<li>📨 Pledge receipts with bank transfer details</li>
|
||||
<li>⏰ Automatic reminders (2d before → due day → 3d after → 10d final)</li>
|
||||
<li>🤖 Donor chatbot: reply PAID, HELP, CANCEL, STATUS</li>
|
||||
<li>📊 Volunteer notifications when someone pledges at their table</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={startSession} disabled={starting} className="w-full bg-[#25D366] hover:bg-[#25D366]/90 text-white">
|
||||
{starting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Starting session...</> : <><MessageCircle className="h-4 w-4 mr-2" /> Connect WhatsApp</>}
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
Uses WAHA (WhatsApp HTTP API) · No WhatsApp Business API required · Free tier
|
||||
</p>
|
||||
<div className="bg-white border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-500 flex items-center gap-1"><WifiOff className="h-2.5 w-2.5" /> Not connected</span>
|
||||
</div>
|
||||
<div className="border-l-2 border-[#25D366] pl-4 space-y-2">
|
||||
<p className="text-sm font-medium text-[#111827]">Connect your WhatsApp number</p>
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<p>When you connect, donors automatically receive:</p>
|
||||
<p className="font-medium text-gray-600">• Pledge receipts with bank details</p>
|
||||
<p className="font-medium text-gray-600">• Payment reminders on a 4-step schedule</p>
|
||||
<p className="font-medium text-gray-600">• A chatbot (they reply PAID, HELP, or CANCEL)</p>
|
||||
<p className="font-medium text-gray-600">• Volunteer notifications on each pledge</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<button onClick={startSession} disabled={starting} className="mt-4 w-full bg-[#25D366] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#25D366]/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
|
||||
{starting ? <><Loader2 className="h-4 w-4 animate-spin" /> Starting...</> : <><MessageCircle className="h-4 w-4" /> Connect WhatsApp</>}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 text-center mt-2">
|
||||
Free — no WhatsApp Business API required
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user