production: reminder cron, dashboard overhaul, shadcn components, setup wizard
- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback - /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate) - /api/pledges: GET handler with filtering, search, pagination, sort by dueDate - Dashboard overview: stats, collection progress bar, needs attention, upcoming payments - Dashboard pledges: proper table with status tabs, search, actions, pagination - New shadcn components: Table, Tabs, DropdownMenu, Progress - Setup wizard: 4-step onboarding (org → bank → event → QR code) - Settings API: PUT handler for org create/update - Org resolver: single-tenant fallback to first org - Cron jobs installed: reminders every 15min, overdue check at 6am - Auto-generates installment dates when not provided - HOSTNAME=0.0.0.0 in compose for multi-network binding
This commit is contained in:
54
pledge-now-pay-later/src/app/api/cron/overdue/route.ts
Normal file
54
pledge-now-pay-later/src/app/api/cron/overdue/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
|
||||
/**
|
||||
* Mark overdue pledges.
|
||||
* Call via cron daily: GET /api/cron/overdue?key=SECRET
|
||||
*
|
||||
* A pledge is overdue if:
|
||||
* - status is "new" or "initiated"
|
||||
* - AND either:
|
||||
* - dueDate is set and is more than 7 days ago
|
||||
* - dueDate is null and createdAt is more than 14 days ago
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const key = request.nextUrl.searchParams.get("key") || request.headers.get("x-cron-key")
|
||||
const expectedKey = process.env.CRON_SECRET || "pnpl-cron-2026"
|
||||
if (key !== expectedKey) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ error: "No DB" }, { status: 503 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 86400000)
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 86400000)
|
||||
|
||||
// Deferred pledges: 7 days past due date
|
||||
const overdueDeferred = await prisma.pledge.updateMany({
|
||||
where: {
|
||||
status: { in: ["new", "initiated"] },
|
||||
dueDate: { not: null, lt: sevenDaysAgo },
|
||||
},
|
||||
data: { status: "overdue" },
|
||||
})
|
||||
|
||||
// Immediate pledges: 14 days since creation
|
||||
const overdueImmediate = await prisma.pledge.updateMany({
|
||||
where: {
|
||||
status: { in: ["new", "initiated"] },
|
||||
dueDate: null,
|
||||
createdAt: { lt: fourteenDaysAgo },
|
||||
},
|
||||
data: { status: "overdue" },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
markedOverdue: overdueDeferred.count + overdueImmediate.count,
|
||||
deferred: overdueDeferred.count,
|
||||
immediate: overdueImmediate.count,
|
||||
timestamp: now.toISOString(),
|
||||
})
|
||||
}
|
||||
155
pledge-now-pay-later/src/app/api/cron/reminders/route.ts
Normal file
155
pledge-now-pay-later/src/app/api/cron/reminders/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { sendPledgeReminder, isWhatsAppReady } from "@/lib/whatsapp"
|
||||
import { generateReminderContent } from "@/lib/reminders"
|
||||
|
||||
/**
|
||||
* Process and send pending reminders.
|
||||
* Call this via cron every 15 minutes: GET /api/cron/reminders?key=SECRET
|
||||
*
|
||||
* Sends reminders that are:
|
||||
* 1. status = "pending"
|
||||
* 2. scheduledAt <= now
|
||||
* 3. pledge is not paid/cancelled
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Simple auth via query param or header
|
||||
const key = request.nextUrl.searchParams.get("key") || request.headers.get("x-cron-key")
|
||||
const expectedKey = process.env.CRON_SECRET || "pnpl-cron-2026"
|
||||
if (key !== expectedKey) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ error: "No DB" }, { status: 503 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const whatsappReady = await isWhatsAppReady()
|
||||
|
||||
// Find pending reminders that are due
|
||||
const dueReminders = await prisma.reminder.findMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
scheduledAt: { lte: now },
|
||||
pledge: {
|
||||
status: { notIn: ["paid", "cancelled"] },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
pledge: {
|
||||
include: {
|
||||
event: { select: { name: true } },
|
||||
organization: { select: { name: true, bankSortCode: true, bankAccountNo: true, bankAccountName: true } },
|
||||
paymentInstruction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 50, // Process in batches
|
||||
orderBy: { scheduledAt: "asc" },
|
||||
})
|
||||
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
const results: Array<{ id: string; status: string; channel: string; error?: string }> = []
|
||||
|
||||
for (const reminder of dueReminders) {
|
||||
const pledge = reminder.pledge
|
||||
const phone = pledge.donorPhone
|
||||
const email = pledge.donorEmail
|
||||
const channel = reminder.channel
|
||||
const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)
|
||||
|
||||
try {
|
||||
// WhatsApp channel
|
||||
if (channel === "whatsapp" && phone && whatsappReady) {
|
||||
const result = await sendPledgeReminder(phone, {
|
||||
donorName: pledge.donorName || undefined,
|
||||
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
||||
eventName: pledge.event.name,
|
||||
reference: pledge.reference,
|
||||
daysSincePledge: daysSince,
|
||||
step: reminder.step,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now },
|
||||
})
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp" })
|
||||
} else {
|
||||
// Try email fallback
|
||||
if (email) {
|
||||
// For now, mark as sent (email integration is external via webhook API)
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now, payload: { ...(reminder.payload as object || {}), fallback: "email", waError: result.error } },
|
||||
})
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "email-fallback" })
|
||||
} else {
|
||||
failed++
|
||||
results.push({ id: reminder.id, status: "failed", channel: "whatsapp", error: result.error })
|
||||
}
|
||||
}
|
||||
}
|
||||
// Email channel (exposed via webhook API for external tools like n8n/Zapier)
|
||||
else if (channel === "email" && email) {
|
||||
// Generate content and store for external pickup
|
||||
const payload = reminder.payload as Record<string, string> || {}
|
||||
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
|
||||
|
||||
const content = generateReminderContent(payload.templateKey || "gentle_nudge", {
|
||||
donorName: pledge.donorName || undefined,
|
||||
amount: (pledge.amountPence / 100).toFixed(0),
|
||||
reference: pledge.reference,
|
||||
eventName: pledge.event.name,
|
||||
bankName: bankDetails?.bankName,
|
||||
sortCode: bankDetails?.sortCode,
|
||||
accountNo: bankDetails?.accountNo,
|
||||
accountName: bankDetails?.accountName,
|
||||
pledgeUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/${pledge.reference}`,
|
||||
cancelUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/${pledge.reference}?cancel=1`,
|
||||
})
|
||||
|
||||
// Mark as sent — the /api/webhooks endpoint exposes these for external email sending
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: {
|
||||
status: "sent",
|
||||
sentAt: now,
|
||||
payload: { ...payload, generatedSubject: content.subject, generatedBody: content.body, recipientEmail: email },
|
||||
},
|
||||
})
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "email" })
|
||||
}
|
||||
// No channel available
|
||||
else {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "skipped" },
|
||||
})
|
||||
skipped++
|
||||
results.push({ id: reminder.id, status: "skipped", channel, error: "No contact method" })
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
results.push({ id: reminder.id, status: "failed", channel, error: String(err) })
|
||||
console.error(`[CRON] Reminder ${reminder.id} failed:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
processed: dueReminders.length,
|
||||
sent,
|
||||
skipped,
|
||||
failed,
|
||||
whatsappReady,
|
||||
results,
|
||||
nextCheck: "Call again in 15 minutes",
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,90 @@ import { generateReference } from "@/lib/reference"
|
||||
import { calculateReminderSchedule } from "@/lib/reminders"
|
||||
import { sendPledgeReceipt } from "@/lib/whatsapp"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) return NextResponse.json({ pledges: [] })
|
||||
|
||||
const sp = request.nextUrl.searchParams
|
||||
const eventId = sp.get("eventId")
|
||||
const status = sp.get("status")
|
||||
const limit = parseInt(sp.get("limit") || "50")
|
||||
const offset = parseInt(sp.get("offset") || "0")
|
||||
const sort = sp.get("sort") || "createdAt"
|
||||
const dir = sp.get("dir") === "asc" ? "asc" as const : "desc" as const
|
||||
const dueSoon = sp.get("dueSoon") === "true" // pledges due in next 7 days
|
||||
const overdue = sp.get("overdue") === "true"
|
||||
const search = sp.get("search")
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {}
|
||||
if (eventId) where.eventId = eventId
|
||||
if (status && status !== "all") where.status = status
|
||||
if (overdue) where.status = "overdue"
|
||||
if (dueSoon) {
|
||||
const now = new Date()
|
||||
const weekFromNow = new Date(now.getTime() + 7 * 86400000)
|
||||
where.dueDate = { gte: now, lte: weekFromNow }
|
||||
where.status = { in: ["new", "initiated"] }
|
||||
}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ donorName: { contains: search, mode: "insensitive" } },
|
||||
{ donorEmail: { contains: search, mode: "insensitive" } },
|
||||
{ reference: { contains: search, mode: "insensitive" } },
|
||||
{ donorPhone: { contains: search } },
|
||||
]
|
||||
}
|
||||
|
||||
const orderBy = sort === "dueDate" ? { dueDate: dir } :
|
||||
sort === "amountPence" ? { amountPence: dir } :
|
||||
{ createdAt: dir }
|
||||
|
||||
const [pledges, total] = await Promise.all([
|
||||
prisma.pledge.findMany({
|
||||
where,
|
||||
include: {
|
||||
event: { select: { name: true } },
|
||||
qrSource: { select: { label: true, volunteerName: true } },
|
||||
},
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.pledge.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
pledges: pledges.map(p => ({
|
||||
id: p.id,
|
||||
reference: p.reference,
|
||||
amountPence: p.amountPence,
|
||||
status: p.status,
|
||||
rail: p.rail,
|
||||
donorName: p.donorName,
|
||||
donorEmail: p.donorEmail,
|
||||
donorPhone: p.donorPhone,
|
||||
giftAid: p.giftAid,
|
||||
dueDate: p.dueDate,
|
||||
planId: p.planId,
|
||||
installmentNumber: p.installmentNumber,
|
||||
installmentTotal: p.installmentTotal,
|
||||
eventName: p.event.name,
|
||||
qrSourceLabel: p.qrSource?.label || null,
|
||||
volunteerName: p.qrSource?.volunteerName || null,
|
||||
createdAt: p.createdAt,
|
||||
paidAt: p.paidAt,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Pledges GET error:", error)
|
||||
return NextResponse.json({ pledges: [], total: 0, error: "Failed to load pledges" })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -36,7 +120,17 @@ export async function POST(request: NextRequest) {
|
||||
const org = event.organization
|
||||
|
||||
// --- INSTALLMENT MODE: create N linked pledges ---
|
||||
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && installmentDates?.length) {
|
||||
// Auto-generate dates if not provided (1st of each month starting next month)
|
||||
let resolvedDates = installmentDates
|
||||
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && !resolvedDates?.length) {
|
||||
resolvedDates = []
|
||||
const now = new Date()
|
||||
for (let i = 0; i < installmentCount; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + 1 + i, 1)
|
||||
resolvedDates.push(d.toISOString().split("T")[0])
|
||||
}
|
||||
}
|
||||
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && resolvedDates?.length) {
|
||||
const perInstallment = Math.ceil(amountPence / installmentCount)
|
||||
const planId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
let firstRef = ""
|
||||
@@ -54,7 +148,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
if (i === 0) firstRef = ref
|
||||
|
||||
const installmentDue = new Date(installmentDates[i])
|
||||
const installmentDue = new Date(resolvedDates[i])
|
||||
|
||||
const p = await tx.pledge.create({
|
||||
data: {
|
||||
@@ -99,7 +193,7 @@ export async function POST(request: NextRequest) {
|
||||
const name = donorName?.split(" ")[0] || "there"
|
||||
const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
|
||||
sendWhatsAppMessage(donorPhone,
|
||||
`🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\n💷 *£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\n📆 *${installmentCount} monthly payments* of *£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(installmentDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.`
|
||||
`🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\n💷 *£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\n📆 *${installmentCount} monthly payments* of *£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(resolvedDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.`
|
||||
).catch(err => console.error("[WAHA] Installment receipt failed:", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,47 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Try to find existing org first
|
||||
const orgId = await resolveOrgId(request.headers.get("x-org-id") || "default")
|
||||
|
||||
if (orgId) {
|
||||
// Update existing
|
||||
const allowed = ["name", "charityNumber", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo"]
|
||||
const data: Record<string, string> = {}
|
||||
for (const key of allowed) {
|
||||
if (key in body && body[key] !== undefined) data[key] = body[key]
|
||||
}
|
||||
const org = await prisma.organization.update({ where: { id: orgId }, data })
|
||||
return NextResponse.json({ id: org.id, name: org.name, created: false })
|
||||
} else {
|
||||
// Create new org
|
||||
const slug = (body.name || "org").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "")
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: body.name || "My Charity",
|
||||
slug: slug || "my-charity",
|
||||
country: "GB",
|
||||
bankName: body.bankName || "",
|
||||
bankSortCode: body.bankSortCode || "",
|
||||
bankAccountNo: body.bankAccountNo || "",
|
||||
bankAccountName: body.bankAccountName || body.name || "",
|
||||
refPrefix: slug.substring(0, 4).toUpperCase() || "PNPL",
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ id: org.id, name: org.name, created: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Settings PUT error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings, Plus, ExternalLink } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
|
||||
@@ -11,54 +15,65 @@ const navItems = [
|
||||
]
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50/50">
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-40 border-b bg-white/80 backdrop-blur-xl">
|
||||
<div className="flex h-16 items-center gap-4 px-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-trust-blue flex items-center justify-center">
|
||||
<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-8 w-8 rounded-xl bg-gradient-to-br from-trust-blue to-blue-600 flex items-center justify-center shadow-lg shadow-trust-blue/20">
|
||||
<span className="text-white font-bold text-sm">P</span>
|
||||
</div>
|
||||
<span className="font-bold text-lg hidden sm:block">Pledge Now, Pay Later</span>
|
||||
<div className="hidden sm:block">
|
||||
<span className="font-black text-sm text-gray-900">PNPL</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-1">Dashboard</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View Public Site
|
||||
<Link href="/dashboard/events" className="hidden md:block">
|
||||
<button className="inline-flex items-center gap-1.5 rounded-lg bg-trust-blue px-3 py-1.5 text-xs font-semibold text-white hover:bg-trust-blue/90 transition-colors">
|
||||
<Plus className="h-3 w-3" /> New Event
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<ExternalLink className="h-3 w-3" /> <span className="hidden sm:inline">Public Site</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden md:flex w-64 flex-col border-r bg-white min-h-[calc(100vh-4rem)] p-4">
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-gray-100 hover:text-foreground transition-colors"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex w-56 flex-col border-r bg-white min-h-[calc(100vh-3.5rem)] py-3 px-2">
|
||||
<nav className="space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-trust-blue/5 text-trust-blue"
|
||||
: "text-muted-foreground hover:bg-gray-100 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Upsell CTA */}
|
||||
<div className="mt-auto pt-4">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-trust-blue/5 to-warm-amber/5 border p-4 space-y-2">
|
||||
<p className="text-sm font-semibold">Need tech leadership?</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className="mt-auto px-2 pt-4">
|
||||
<div className="rounded-xl bg-gradient-to-br from-trust-blue/5 to-warm-amber/5 border p-3 space-y-1.5">
|
||||
<p className="text-xs font-semibold">Need help?</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
Get a fractional Head of Technology to optimise your charity's digital stack.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/apply"
|
||||
className="inline-block text-xs font-semibold text-trust-blue hover:underline"
|
||||
>
|
||||
<Link href="/dashboard/apply" className="inline-block text-[10px] font-semibold text-trust-blue hover:underline">
|
||||
Learn more →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -66,21 +81,27 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</aside>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t bg-white/80 backdrop-blur-xl flex justify-around py-2">
|
||||
{navItems.slice(0, 5).map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex flex-col items-center gap-1 p-2 text-muted-foreground hover:text-trust-blue transition-colors"
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t bg-white/95 backdrop-blur-xl 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))
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 py-1 px-2 rounded-lg transition-colors",
|
||||
isActive ? "text-trust-blue" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className="text-[9px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-4 md:p-8 pb-20 md:pb-8">
|
||||
<main className="flex-1 p-4 md:p-6 pb-20 md:pb-6 max-w-6xl">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,321 +1,324 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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 } from "lucide-react"
|
||||
import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface DashboardData {
|
||||
summary: {
|
||||
totalPledges: number
|
||||
totalPledgedPence: number
|
||||
totalCollectedPence: number
|
||||
collectionRate: number
|
||||
overdueRate: number
|
||||
}
|
||||
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
|
||||
eventName: string
|
||||
source: string | null
|
||||
createdAt: string
|
||||
paidAt: string | null
|
||||
nextReminder: string | null
|
||||
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;
|
||||
}>
|
||||
}
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "success" | "warning" | "destructive" | "outline"> = {
|
||||
new: "secondary",
|
||||
initiated: "warning",
|
||||
paid: "success",
|
||||
overdue: "destructive",
|
||||
cancelled: "outline",
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "New",
|
||||
initiated: "Payment Initiated",
|
||||
paid: "Paid",
|
||||
overdue: "Overdue",
|
||||
cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
const EMPTY_DATA: DashboardData = {
|
||||
summary: { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 },
|
||||
byStatus: {},
|
||||
byRail: {},
|
||||
topSources: [],
|
||||
pledges: [],
|
||||
}
|
||||
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" }
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardData>(EMPTY_DATA)
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<string>("all")
|
||||
const [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(null)
|
||||
|
||||
const fetchData = () => {
|
||||
fetch("/api/dashboard", {
|
||||
headers: { "x-org-id": "demo" },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.summary) setData(d)
|
||||
})
|
||||
const fetchData = useCallback(() => {
|
||||
fetch("/api/dashboard", { headers: { "x-org-id": "demo" } })
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.summary) setData(d) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
// Auto-refresh every 10 seconds
|
||||
const interval = setInterval(fetchData, 10000)
|
||||
fetch("/api/whatsapp/send").then(r => r.json()).then(d => setWhatsappStatus(d.connected)).catch(() => {})
|
||||
const interval = setInterval(fetchData, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const filteredPledges = filter === "all" ? data.pledges : data.pledges.filter((p) => p.status === filter)
|
||||
|
||||
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
|
||||
try {
|
||||
await fetch(`/api/pledges/${pledgeId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
pledges: prev.pledges.map((p) =>
|
||||
p.id === pledgeId ? { ...p, status: newStatus } : p
|
||||
),
|
||||
}))
|
||||
} catch {
|
||||
// handle error
|
||||
}
|
||||
}
|
||||
}, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Loading...</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}><CardContent className="pt-6"><div className="h-16 animate-pulse bg-gray-100 rounded-lg" /></CardContent></Card>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-center py-20 space-y-4">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
<h2 className="text-xl font-bold">Welcome to Pledge Now, Pay Later</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Start by configuring your organisation's bank details, then create your first event.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link href="/dashboard/settings">
|
||||
<Button variant="outline">Configure Bank Details</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard/events">
|
||||
<Button>Create First Event →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const s = data.summary
|
||||
const upcomingPledges = data.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 = data.pledges.filter(p => p.status !== "cancelled").slice(0, 8)
|
||||
const overduePledges = data.pledges.filter(p => p.status === "overdue")
|
||||
const needsAction = [...overduePledges, ...upcomingPledges.filter(p => {
|
||||
const due = new Date(p.dueDate!)
|
||||
return due.getTime() - Date.now() < 2 * 86400000 // due in 2 days
|
||||
})].slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Overview of all pledge activity</p>
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{whatsappStatus !== null && (
|
||||
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-muted-foreground"}`}>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
|
||||
</span>
|
||||
)}
|
||||
Auto-refreshes every 15s
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-trust-blue/10 p-2.5">
|
||||
<Users className="h-5 w-5 text-trust-blue" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{data.summary.totalPledges}</p>
|
||||
<p className="text-2xl font-black">{s.totalPledges}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Pledges</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-warm-amber/10 p-2.5">
|
||||
<Banknote className="h-5 w-5 text-warm-amber" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{formatPence(data.summary.totalPledgedPence)}</p>
|
||||
<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>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-success-green/10 p-2.5">
|
||||
<TrendingUp className="h-5 w-5 text-success-green" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{data.summary.collectionRate}%</p>
|
||||
<p className="text-xs text-muted-foreground">Collection Rate</p>
|
||||
<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>
|
||||
<CardContent className="pt-6">
|
||||
<Card className={s.overdueRate > 10 ? "border-danger-red/30" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-danger-red/10 p-2.5">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-red" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{data.summary.overdueRate}%</p>
|
||||
<p className="text-xs text-muted-foreground">Overdue Rate</p>
|
||||
<p className="text-2xl font-black">{data.byStatus.overdue || 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Collection progress bar */}
|
||||
{/* Collection progress */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">Pledged vs Collected</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatPence(data.summary.totalCollectedPence)} of {formatPence(data.summary.totalPledgedPence)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">Pledged → Collected</span>
|
||||
<span className="text-sm font-bold text-muted-foreground">{s.collectionRate}%</span>
|
||||
</div>
|
||||
<div className="h-4 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-trust-blue to-success-green transition-all duration-1000"
|
||||
style={{ width: `${data.summary.collectionRate}%` }}
|
||||
/>
|
||||
<Progress value={s.collectionRate} indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Two-column: Sources + Status */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Top QR sources */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Top Sources</CardTitle>
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
{/* 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-3">
|
||||
{data.topSources.map((src, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-muted-foreground w-5">{i + 1}</span>
|
||||
<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">{src.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{src.count} pledges</p>
|
||||
<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>
|
||||
</div>
|
||||
<Badge variant={p.status === "overdue" ? "destructive" : "warning"}>
|
||||
{p.status === "overdue" ? "Overdue" : "Due soon"}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm font-bold">{formatPence(src.amount)}</span>
|
||||
</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" />
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status breakdown */}
|
||||
{/* Upcoming payments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">By Status</CardTitle>
|
||||
<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-3">
|
||||
{Object.entries(data.byStatus).map(([status, count]) => (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<Badge variant={statusColors[status]}>
|
||||
{statusLabels[status] || status}
|
||||
</Badge>
|
||||
<span className="text-sm font-bold">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
|
||||
{/* Pledge pipeline */}
|
||||
{/* 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">
|
||||
{Object.entries(data.byStatus).map(([status, count]) => {
|
||||
const Icon = statusIcons[status] || Clock
|
||||
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">
|
||||
{data.topSources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
|
||||
) : (
|
||||
data.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="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Pledge Pipeline</CardTitle>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{["all", "new", "initiated", "paid", "overdue", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full font-medium transition-colors ${
|
||||
filter === s
|
||||
? "bg-trust-blue text-white"
|
||||
: "bg-gray-100 text-muted-foreground hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "All" : statusLabels[s] || s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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="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">Reference</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Donor</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Amount</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Rail</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Source</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Status</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filteredPledges.map((pledge) => (
|
||||
<tr key={pledge.id} className="hover:bg-gray-50/50">
|
||||
<td className="py-3 font-mono font-bold text-trust-blue">{pledge.reference}</td>
|
||||
<td className="py-3">
|
||||
<div>
|
||||
<p className="font-medium">{pledge.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">{pledge.donorEmail || ""}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 font-bold">{formatPence(pledge.amountPence)}</td>
|
||||
<td className="py-3 capitalize">{pledge.rail}</td>
|
||||
<td className="py-3 text-xs">{pledge.source || "—"}</td>
|
||||
<td className="py-3">
|
||||
<Badge variant={statusColors[pledge.status]}>
|
||||
{statusLabels[pledge.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-1">
|
||||
{pledge.status !== "paid" && pledge.status !== "cancelled" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStatusChange(pledge.id, "paid")}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-success-green/10 text-success-green hover:bg-success-green/20 font-medium"
|
||||
>
|
||||
Mark Paid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusChange(pledge.id, "cancelled")}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-danger-red/10 text-danger-red hover:bg-danger-red/20 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,39 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import { Search, Loader2, Download, RefreshCw, ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useToast } from "@/components/ui/toast"
|
||||
import {
|
||||
Search, MoreVertical, Calendar, Clock, AlertTriangle,
|
||||
CheckCircle2, XCircle, MessageCircle, Send, Filter,
|
||||
ChevronLeft, ChevronRight, Users, Loader2
|
||||
} from "lucide-react"
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "success" | "warning" | "destructive" | "outline"> = {
|
||||
new: "secondary",
|
||||
initiated: "warning",
|
||||
paid: "success",
|
||||
overdue: "destructive",
|
||||
cancelled: "outline",
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "New",
|
||||
initiated: "Initiated",
|
||||
paid: "Paid",
|
||||
overdue: "Overdue",
|
||||
cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
const railLabels: Record<string, string> = {
|
||||
bank: "Bank Transfer",
|
||||
gocardless: "Direct Debit",
|
||||
card: "Card",
|
||||
fpx: "FPX",
|
||||
}
|
||||
|
||||
interface PledgeRow {
|
||||
interface Pledge {
|
||||
id: string
|
||||
reference: string
|
||||
amountPence: number
|
||||
@@ -43,252 +26,392 @@ interface PledgeRow {
|
||||
donorEmail: string | null
|
||||
donorPhone: string | null
|
||||
giftAid: boolean
|
||||
dueDate: string | null
|
||||
planId: string | null
|
||||
installmentNumber: number | null
|
||||
installmentTotal: number | null
|
||||
eventName: string
|
||||
source: string | null
|
||||
qrSourceLabel: string | null
|
||||
volunteerName: string | null
|
||||
createdAt: string
|
||||
paidAt: string | null
|
||||
}
|
||||
|
||||
function PledgesContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const eventId = searchParams.get("event")
|
||||
const [pledges, setPledges] = useState<PledgeRow[]>([])
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "success" | "warning" | "destructive"; icon: typeof Clock }> = {
|
||||
new: { label: "Pending", variant: "secondary", icon: Clock },
|
||||
initiated: { label: "Initiated", variant: "warning", icon: Send },
|
||||
paid: { label: "Paid", variant: "success", icon: CheckCircle2 },
|
||||
overdue: { label: "Overdue", variant: "destructive", icon: AlertTriangle },
|
||||
cancelled: { label: "Cancelled", variant: "secondary", icon: XCircle },
|
||||
}
|
||||
|
||||
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
|
||||
|
||||
function timeAgo(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const days = Math.floor(diff / 86400000)
|
||||
if (days === 0) return "Today"
|
||||
if (days === 1) return "Yesterday"
|
||||
if (days < 7) return `${days}d ago`
|
||||
if (days < 30) return `${Math.floor(days / 7)}w ago`
|
||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
||||
}
|
||||
|
||||
function dueLabel(dueDate: string) {
|
||||
const d = new Date(dueDate)
|
||||
const now = new Date()
|
||||
const diff = d.getTime() - now.getTime()
|
||||
const days = Math.ceil(diff / 86400000)
|
||||
if (days < 0) return { text: `${Math.abs(days)}d overdue`, urgent: true }
|
||||
if (days === 0) return { text: "Due today", urgent: true }
|
||||
if (days === 1) return { text: "Due tomorrow", urgent: false }
|
||||
if (days <= 7) return { text: `Due in ${days}d`, urgent: false }
|
||||
return { text: d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }), urgent: false }
|
||||
}
|
||||
|
||||
export default function PledgesPage() {
|
||||
const [pledges, setPledges] = useState<Pledge[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [page, setPage] = useState(0)
|
||||
const [updating, setUpdating] = useState<string | null>(null)
|
||||
const [eventName, setEventName] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
const pageSize = 25
|
||||
|
||||
const fetchPledges = () => {
|
||||
const url = eventId
|
||||
? `/api/dashboard?eventId=${eventId}`
|
||||
: "/api/dashboard"
|
||||
fetch(url, { headers: { "x-org-id": "demo" } })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.pledges) {
|
||||
setPledges(data.pledges)
|
||||
if (eventId && data.pledges.length > 0) {
|
||||
setEventName(data.pledges[0].eventName)
|
||||
}
|
||||
}
|
||||
// Stats
|
||||
const [stats, setStats] = useState({ total: 0, pending: 0, dueSoon: 0, overdue: 0, paid: 0, totalPledged: 0, totalCollected: 0 })
|
||||
|
||||
const fetchPledges = useCallback(async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set("limit", String(pageSize))
|
||||
params.set("offset", String(page * pageSize))
|
||||
if (tab !== "all") {
|
||||
if (tab === "due-soon") params.set("dueSoon", "true")
|
||||
else if (tab === "overdue") params.set("overdue", "true")
|
||||
else params.set("status", tab)
|
||||
}
|
||||
if (search) params.set("search", search)
|
||||
params.set("sort", tab === "due-soon" ? "dueDate" : "createdAt")
|
||||
params.set("dir", tab === "due-soon" ? "asc" : "desc")
|
||||
|
||||
const res = await fetch(`/api/pledges?${params}`)
|
||||
const data = await res.json()
|
||||
setPledges(data.pledges || [])
|
||||
setTotal(data.total || 0)
|
||||
setLoading(false)
|
||||
}, [tab, search, page])
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
const res = await fetch("/api/dashboard", { headers: { "x-org-id": "demo" } })
|
||||
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,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchPledges() }, [fetchPledges])
|
||||
useEffect(() => { fetchStats() }, [fetchStats])
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
fetchPledges()
|
||||
const interval = setInterval(fetchPledges, 15000)
|
||||
const interval = setInterval(fetchPledges, 30000)
|
||||
return () => clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventId])
|
||||
}, [fetchPledges])
|
||||
|
||||
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
|
||||
const updateStatus = async (pledgeId: string, newStatus: string) => {
|
||||
setUpdating(pledgeId)
|
||||
try {
|
||||
const res = await fetch(`/api/pledges/${pledgeId}`, {
|
||||
await fetch(`/api/pledges/${pledgeId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setPledges((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === pledgeId
|
||||
? { ...p, status: newStatus, paidAt: newStatus === "paid" ? new Date().toISOString() : p.paidAt }
|
||||
: p
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
setPledges(prev => prev.map(p => p.id === pledgeId ? { ...p, status: newStatus } : p))
|
||||
toast(`Pledge marked as ${newStatus}`, "success")
|
||||
} catch {
|
||||
toast("Failed to update", "error")
|
||||
}
|
||||
setUpdating(null)
|
||||
}
|
||||
|
||||
const filtered = pledges.filter((p) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
p.reference.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.donorName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.donorEmail?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.donorPhone?.includes(search)
|
||||
const matchStatus = statusFilter === "all" || p.status === statusFilter
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
|
||||
const statusCounts = pledges.reduce((acc, p) => {
|
||||
acc[p.status] = (acc[p.status] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
)
|
||||
const sendReminder = async (pledge: Pledge) => {
|
||||
if (!pledge.donorPhone) {
|
||||
toast("No phone number — can't send WhatsApp", "error")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch("/api/whatsapp/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "reminder",
|
||||
phone: pledge.donorPhone,
|
||||
data: {
|
||||
donorName: pledge.donorName,
|
||||
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
||||
eventName: pledge.eventName,
|
||||
reference: pledge.reference,
|
||||
daysSincePledge: Math.floor((Date.now() - new Date(pledge.createdAt).getTime()) / 86400000),
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
})
|
||||
toast("Reminder sent via WhatsApp ✓", "success")
|
||||
} catch {
|
||||
toast("Failed to send", "error")
|
||||
}
|
||||
}
|
||||
|
||||
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
{eventId && (
|
||||
<Link
|
||||
href="/dashboard/events"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" /> Back to Events
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">
|
||||
{eventName ? `${eventName} — Pledges` : "All Pledges"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{pledges.length} pledge{pledges.length !== 1 ? "s" : ""} totalling{" "}
|
||||
{formatPence(pledges.reduce((s, p) => s + p.amountPence, 0))}
|
||||
<h1 className="text-2xl font-black text-gray-900">Pledges</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stats.total} total · {formatPence(stats.totalPledged)} pledged · {collectionRate}% collected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchPledges}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
|
||||
</Button>
|
||||
<a href={`/api/exports/crm-pack${eventId ? `?eventId=${eventId}` : ""}`} download>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</a>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search name, email, ref..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by reference, name, email, or phone..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{["all", "new", "initiated", "paid", "overdue", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`text-xs px-3 py-2 rounded-xl font-medium transition-colors whitespace-nowrap ${
|
||||
statusFilter === s ? "bg-trust-blue text-white" : "bg-gray-100 text-muted-foreground hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? `All (${pledges.length})` : `${statusLabels[s]} (${statusCounts[s] || 0})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pledges list */}
|
||||
{filtered.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{search ? "No pledges match your search." : "No pledges yet. Share a QR code to get started!"}
|
||||
</p>
|
||||
{/* 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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((p) => (
|
||||
<Card key={p.id} className="hover:shadow-sm transition-shadow">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono font-bold text-trust-blue">{p.reference}</span>
|
||||
<Badge variant={statusColors[p.status]}>{statusLabels[p.status]}</Badge>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-muted-foreground">
|
||||
{railLabels[p.rail] || p.rail}
|
||||
</span>
|
||||
{p.giftAid && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-50 text-green-700 font-medium">
|
||||
Gift Aid ✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-semibold">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[p.donorEmail, p.donorPhone].filter(Boolean).join(" · ") || "No contact info"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName}{p.source ? ` · ${p.source}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-xl font-bold">{formatPence(p.amountPence)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(p.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
{p.paidAt && (
|
||||
<p className="text-xs text-success-green font-medium">
|
||||
Paid {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("new")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-warm-amber" />
|
||||
<span className="text-xs text-muted-foreground">Pending</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1">{stats.pending}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow border-warm-amber/30" onClick={() => setTab("due-soon")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-warm-amber" />
|
||||
<span className="text-xs text-muted-foreground">Due Soon</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1 text-warm-amber">{stats.dueSoon || "—"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow border-danger-red/30" onClick={() => setTab("overdue")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-danger-red" />
|
||||
<span className="text-xs text-muted-foreground">Overdue</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1 text-danger-red">{stats.overdue}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("paid")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-success-green" />
|
||||
<span className="text-xs text-muted-foreground">Paid</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1 text-success-green">{stats.paid}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Collection progress */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Progress value={collectionRate} className="flex-1 h-2" indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
||||
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
||||
{formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs + Table */}
|
||||
<Tabs value={tab} onValueChange={(v) => { setTab(v); setPage(0) }}>
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="new">Pending</TabsTrigger>
|
||||
<TabsTrigger value="due-soon">Due Soon</TabsTrigger>
|
||||
<TabsTrigger value="overdue">Overdue</TabsTrigger>
|
||||
<TabsTrigger value="initiated">Initiated</TabsTrigger>
|
||||
<TabsTrigger value="paid">Paid</TabsTrigger>
|
||||
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={tab}>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
{p.status !== "paid" && p.status !== "cancelled" && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="success"
|
||||
className="text-xs"
|
||||
disabled={updating === p.id}
|
||||
onClick={() => handleStatusChange(p.id, "paid")}
|
||||
>
|
||||
{updating === p.id ? "..." : "Mark Paid"}
|
||||
</Button>
|
||||
{p.status === "new" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={updating === p.id}
|
||||
onClick={() => handleStatusChange(p.id, "initiated")}
|
||||
>
|
||||
Mark Initiated
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs text-danger-red ml-auto"
|
||||
disabled={updating === p.id}
|
||||
onClick={() => handleStatusChange(p.id, "cancelled")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : pledges.length === 0 ? (
|
||||
<div className="text-center py-16 space-y-3">
|
||||
<Filter className="h-8 w-8 text-muted-foreground mx-auto" />
|
||||
<p className="font-medium text-gray-900">No pledges found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{search ? `No results for "${search}"` : "Create an event and share QR codes to start collecting pledges"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Donor</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Event</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Due / Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Method</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pledges.map((p) => {
|
||||
const sc = statusConfig[p.status] || statusConfig.new
|
||||
const due = p.dueDate ? dueLabel(p.dueDate) : null
|
||||
const isInstallment = p.installmentTotal && p.installmentTotal > 1
|
||||
|
||||
return (
|
||||
<TableRow key={p.id} className={updating === p.id ? "opacity-50" : ""}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{p.reference}</p>
|
||||
{p.donorPhone && (
|
||||
<p className="text-[10px] text-[#25D366] flex items-center gap-0.5 mt-0.5">
|
||||
<MessageCircle className="h-2.5 w-2.5" /> WhatsApp
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold">{formatPence(p.amountPence)}</p>
|
||||
{p.giftAid && <span className="text-[10px] text-success-green">🎁 +Gift Aid</span>}
|
||||
{isInstallment && (
|
||||
<p className="text-[10px] text-warm-amber font-medium">
|
||||
{p.installmentNumber}/{p.installmentTotal}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<p className="text-sm truncate max-w-[140px]">{p.eventName}</p>
|
||||
{p.qrSourceLabel && (
|
||||
<p className="text-[10px] text-muted-foreground">{p.qrSourceLabel}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={sc.variant} className="gap-1 text-[11px]">
|
||||
<sc.icon className="h-3 w-3" /> {sc.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{due ? (
|
||||
<span className={`text-xs font-medium ${due.urgent ? "text-danger-red" : "text-muted-foreground"}`}>
|
||||
{due.urgent && "⚠ "}{due.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{timeAgo(p.createdAt)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="text-xs capitalize text-muted-foreground">
|
||||
{p.rail === "gocardless" ? "Direct Debit" : p.rail}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="p-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<MoreVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
|
||||
<CheckCircle2 className="h-4 w-4 text-success-green" /> Mark Paid
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.status !== "initiated" && p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
|
||||
<Send className="h-4 w-4 text-warm-amber" /> Mark Initiated
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.donorPhone && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => sendReminder(p)}>
|
||||
<MessageCircle className="h-4 w-4 text-[#25D366]" /> Send WhatsApp Reminder
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{p.status !== "cancelled" && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive onClick={() => updateStatus(p.id, "cancelled")}>
|
||||
<XCircle className="h-4 w-4" /> Cancel Pledge
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PledgesPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<PledgesContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
290
pledge-now-pay-later/src/app/dashboard/setup/page.tsx
Normal file
290
pledge-now-pay-later/src/app/dashboard/setup/page.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
// Badge available but not used yet
|
||||
import { useToast } from "@/components/ui/toast"
|
||||
import {
|
||||
Building2, Banknote, Calendar, QrCode, ArrowRight, CheckCircle2, Loader2, Sparkles
|
||||
} from "lucide-react"
|
||||
|
||||
export default function SetupPage() {
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
// Org
|
||||
const [orgName, setOrgName] = useState("")
|
||||
const [charityNumber, setCharityNumber] = useState("")
|
||||
|
||||
// Bank
|
||||
const [bankName, setBankName] = useState("")
|
||||
const [sortCode, setSortCode] = useState("")
|
||||
const [accountNo, setAccountNo] = useState("")
|
||||
const [accountName, setAccountName] = useState("")
|
||||
|
||||
// Event
|
||||
const [eventName, setEventName] = useState("")
|
||||
const [eventDate, setEventDate] = useState("")
|
||||
const [targetPence, setTargetPence] = useState("")
|
||||
|
||||
// Result
|
||||
const [setupResult, setSetupResult] = useState<{ orgId?: string; eventId?: string; qrToken?: string } | null>(null)
|
||||
|
||||
const saveOrg = async () => {
|
||||
if (!orgName) { toast("Charity name is required", "error"); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: orgName,
|
||||
charityNumber,
|
||||
bankName,
|
||||
bankSortCode: sortCode,
|
||||
bankAccountNo: accountNo,
|
||||
bankAccountName: accountName || orgName,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.id) setSetupResult(prev => ({ ...prev, orgId: data.id }))
|
||||
setStep(2)
|
||||
} catch {
|
||||
toast("Failed to save", "error")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const saveBankAndContinue = async () => {
|
||||
if (!sortCode || !accountNo) { toast("Bank details are required for donors to pay you", "error"); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bankName,
|
||||
bankSortCode: sortCode.replace(/\s/g, "").replace(/(\d{2})(\d{2})(\d{2})/, "$1-$2-$3"),
|
||||
bankAccountNo: accountNo.replace(/\s/g, ""),
|
||||
bankAccountName: accountName || orgName,
|
||||
}),
|
||||
})
|
||||
setStep(3)
|
||||
} catch {
|
||||
toast("Failed to save bank details", "error")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const createFirstEvent = async () => {
|
||||
if (!eventName) { toast("Event name is required", "error"); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: eventName,
|
||||
date: eventDate || undefined,
|
||||
targetPence: targetPence ? parseInt(targetPence) * 100 : undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.id) {
|
||||
setSetupResult(prev => ({ ...prev, eventId: data.id }))
|
||||
// Auto-generate a QR code
|
||||
const qrRes = await fetch(`/api/events/${data.id}/qr`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ label: "Main QR Code", volunteerName: "" }),
|
||||
})
|
||||
const qrData = await qrRes.json()
|
||||
if (qrData.token) setSetupResult(prev => ({ ...prev, qrToken: qrData.token }))
|
||||
setStep(4)
|
||||
}
|
||||
} catch {
|
||||
toast("Failed to create event", "error")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ num: 1, label: "Charity", icon: Building2 },
|
||||
{ num: 2, label: "Bank", icon: Banknote },
|
||||
{ num: 3, label: "Event", icon: Calendar },
|
||||
{ num: 4, label: "Ready!", icon: Sparkles },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((s, i) => (
|
||||
<div key={s.num} className="flex items-center">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold transition-all ${
|
||||
step >= s.num ? "bg-trust-blue text-white" : "bg-gray-100 text-gray-400"
|
||||
}`}>
|
||||
{step > s.num ? <CheckCircle2 className="h-5 w-5" /> : <s.icon className="h-5 w-5" />}
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`w-12 sm:w-20 h-0.5 mx-1 transition-all ${step > s.num ? "bg-trust-blue" : "bg-gray-200"}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Org */}
|
||||
{step === 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-trust-blue" /> Your Charity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Charity Name *</Label>
|
||||
<Input value={orgName} onChange={e => setOrgName(e.target.value)} placeholder="e.g. Islamic Relief UK" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Charity Number <span className="text-muted-foreground">(optional)</span></Label>
|
||||
<Input value={charityNumber} onChange={e => setCharityNumber(e.target.value)} placeholder="e.g. 328158" />
|
||||
</div>
|
||||
<Button onClick={saveOrg} disabled={loading} className="w-full">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Next: Bank Details <ArrowRight className="h-4 w-4 ml-1" /></>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Bank */}
|
||||
{step === 2 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Banknote className="h-5 w-5 text-trust-blue" /> Bank Details
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">These will be shown to donors so they can make bank transfers to your charity.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Bank Name</Label>
|
||||
<Input value={bankName} onChange={e => setBankName(e.target.value)} placeholder="e.g. Barclays" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Sort Code *</Label>
|
||||
<Input value={sortCode} onChange={e => setSortCode(e.target.value)} placeholder="20-30-80" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Account Number *</Label>
|
||||
<Input value={accountNo} onChange={e => setAccountNo(e.target.value)} placeholder="12345678" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Account Name</Label>
|
||||
<Input value={accountName} onChange={e => setAccountName(e.target.value)} placeholder={orgName || "Account holder name"} />
|
||||
</div>
|
||||
<Button onClick={saveBankAndContinue} disabled={loading} className="w-full">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Next: Create Event <ArrowRight className="h-4 w-4 ml-1" /></>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Event */}
|
||||
{step === 3 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-trust-blue" /> First Event
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Create an event to start collecting pledges. You can add more later.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Event Name *</Label>
|
||||
<Input value={eventName} onChange={e => setEventName(e.target.value)} placeholder="e.g. Annual Charity Gala 2026" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Event Date <span className="text-muted-foreground">(optional)</span></Label>
|
||||
<Input type="date" value={eventDate} onChange={e => setEventDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Target (£) <span className="text-muted-foreground">(optional)</span></Label>
|
||||
<Input type="number" value={targetPence} onChange={e => setTargetPence(e.target.value)} placeholder="10000" />
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={createFirstEvent} disabled={loading} className="w-full">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Create Event & Generate QR <QrCode className="h-4 w-4 ml-1" /></>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Done */}
|
||||
{step === 4 && (
|
||||
<Card className="border-success-green/30">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-success-green/10 flex items-center justify-center mb-3">
|
||||
<Sparkles className="h-8 w-8 text-success-green" />
|
||||
</div>
|
||||
<CardTitle>You're All Set! 🎉</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Your charity is ready to collect pledges.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Charity</span>
|
||||
<span className="text-sm font-medium">{orgName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Bank</span>
|
||||
<span className="text-sm font-medium">{sortCode} / {accountNo}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Event</span>
|
||||
<span className="text-sm font-medium">{eventName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupResult?.qrToken && (
|
||||
<div className="bg-trust-blue/5 rounded-xl p-4 text-center space-y-2">
|
||||
<QrCode className="h-8 w-8 text-trust-blue mx-auto" />
|
||||
<p className="text-sm font-medium">Your pledge link is ready</p>
|
||||
<code className="text-xs bg-white px-3 py-1.5 rounded-lg border block overflow-x-auto">
|
||||
{typeof window !== "undefined" ? window.location.origin : ""}/p/{setupResult.qrToken}
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">Share this link or print the QR code from your Events dashboard</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<a href="/dashboard">
|
||||
<Button variant="outline" className="w-full">Go to Dashboard</Button>
|
||||
</a>
|
||||
<a href={`/dashboard/events${setupResult?.eventId ? `/${setupResult.eventId}/qr` : ""}`}>
|
||||
<Button className="w-full">Print QR Codes</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{step < 4 && (
|
||||
<div className="bg-warm-amber/5 rounded-xl border border-warm-amber/20 p-4">
|
||||
<p className="text-xs font-semibold text-warm-amber mb-1">💡 Tip</p>
|
||||
{step === 1 && <p className="text-xs text-muted-foreground">Your charity name appears on the donor pledge page and WhatsApp receipts.</p>}
|
||||
{step === 2 && <p className="text-xs text-muted-foreground">Bank details are shown to donors who choose "Bank Transfer". Each pledge gets a unique reference number for easy reconciliation.</p>}
|
||||
{step === 3 && <p className="text-xs text-muted-foreground">Give each volunteer their own QR code to track who brings in the most pledges. You can create more QR codes after setup.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
pledge-now-pay-later/src/components/ui/dropdown-menu.tsx
Normal file
73
pledge-now-pay-later/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick)
|
||||
return () => document.removeEventListener("mousedown", handleClick)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative inline-block">
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement<{ onClick?: () => void }>(child) && (child.type as { displayName?: string }).displayName === "DropdownMenuTrigger") {
|
||||
return React.cloneElement(child, { onClick: () => setOpen(!open) })
|
||||
}
|
||||
if (React.isValidElement<{ className?: string }>(child) && (child.type as { displayName?: string }).displayName === "DropdownMenuContent") {
|
||||
return open ? React.cloneElement(child, { className: cn(child.props.className) }) : null
|
||||
}
|
||||
return child
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
||||
({ children, ...props }, ref) => (
|
||||
<button ref={ref} type="button" {...props}>{children}</button>
|
||||
)
|
||||
)
|
||||
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
|
||||
|
||||
function DropdownMenuContent({ children, className, align = "end" }: { children: React.ReactNode; className?: string; align?: "start" | "end" }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute z-50 min-w-[180px] overflow-hidden rounded-xl border bg-white p-1.5 shadow-lg animate-scale-in",
|
||||
align === "end" ? "right-0" : "left-0",
|
||||
"top-full mt-1",
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
DropdownMenuContent.displayName = "DropdownMenuContent"
|
||||
|
||||
function DropdownMenuItem({ children, className, onClick, destructive }: { children: React.ReactNode; className?: string; onClick?: () => void; destructive?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
destructive ? "text-danger-red hover:bg-danger-red/10" : "text-foreground hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator() {
|
||||
return <div className="my-1 h-px bg-border" />
|
||||
}
|
||||
|
||||
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator }
|
||||
25
pledge-now-pay-later/src/components/ui/progress.tsx
Normal file
25
pledge-now-pay-later/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
indicatorClassName?: string
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, indicatorClassName, ...props }, ref) => {
|
||||
const pct = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
return (
|
||||
<div ref={ref} className={cn("relative h-3 w-full overflow-hidden rounded-full bg-muted", className)} {...props}>
|
||||
<div
|
||||
className={cn("h-full rounded-full bg-trust-blue transition-all duration-500 ease-out", indicatorClassName)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
44
pledge-now-pay-later/src/components/ui/table.tsx
Normal file
44
pledge-now-pay-later/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
)
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
)
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr ref={ref} className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn("h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("px-3 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
48
pledge-now-pay-later/src/components/ui/tabs.tsx
Normal file
48
pledge-now-pay-later/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TabsContextValue { value: string; onValueChange: (v: string) => void }
|
||||
const TabsContext = React.createContext<TabsContextValue>({ value: "", onValueChange: () => {} })
|
||||
|
||||
function Tabs({ value, onValueChange, children, className }: { value: string; onValueChange: (v: string) => void; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||
<div className={cn("", className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("inline-flex h-10 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ value, children, className }: { value: string; children: React.ReactNode; className?: string }) {
|
||||
const ctx = React.useContext(TabsContext)
|
||||
const isActive = ctx.value === value
|
||||
return (
|
||||
<button
|
||||
onClick={() => ctx.onValueChange(value)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
isActive ? "bg-background text-foreground shadow-sm" : "hover:bg-background/50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ value, children, className }: { value: string; children: React.ReactNode; className?: string }) {
|
||||
const ctx = React.useContext(TabsContext)
|
||||
if (ctx.value !== value) return null
|
||||
return <div className={cn("mt-3 ring-offset-background focus-visible:outline-none", className)}>{children}</div>
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -3,27 +3,37 @@ import prisma from "@/lib/prisma"
|
||||
/**
|
||||
* Resolve organization ID from x-org-id header.
|
||||
* The header may contain a slug or a direct ID — we try slug first, then ID.
|
||||
* Falls back to first org if none specified (single-tenant mode).
|
||||
*/
|
||||
export async function resolveOrgId(headerValue: string | null): Promise<string | null> {
|
||||
if (!prisma) return null
|
||||
const val = headerValue?.trim()
|
||||
if (!val) return null
|
||||
|
||||
// Try by slug first (most common from frontend)
|
||||
const bySlug = await prisma.organization.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ slug: val },
|
||||
{ slug: { startsWith: val } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (bySlug) return bySlug.id
|
||||
if (val && val !== "demo") {
|
||||
// Try by slug first (most common from frontend)
|
||||
const bySlug = await prisma.organization.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ slug: val },
|
||||
{ slug: { startsWith: val } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (bySlug) return bySlug.id
|
||||
|
||||
// Try direct ID
|
||||
const byId = await prisma.organization.findUnique({
|
||||
where: { id: val },
|
||||
// Try direct ID
|
||||
const byId = await prisma.organization.findUnique({
|
||||
where: { id: val },
|
||||
select: { id: true },
|
||||
})
|
||||
if (byId) return byId.id
|
||||
}
|
||||
|
||||
// Single-tenant fallback: use first org
|
||||
const first = await prisma.organization.findFirst({
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
})
|
||||
return byId?.id ?? null
|
||||
return first?.id ?? null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user