Embed mode (?embed=1 or iframe detection): - Shows sleek mini card (Make a Pledge) instead of full step 1 - 160px at rest, expands to 700px when user starts the flow - postMessage resize signal for parent iframe auto-height - Powered-by footer Back button: - Moved from hidden bottom bar to fixed top navigation bar - ChevronLeft + "Back" text, always visible during backable steps - Org name centered in header, step label on right - Progress bar integrated into top bar Embed code updated: - iframe starts at height=160 (mini widget height) - Includes resize listener script for auto-expansion
906 lines
48 KiB
TypeScript
906 lines
48 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { formatPence } from "@/lib/utils"
|
|
import {
|
|
Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail,
|
|
Download, Trophy, Link2,
|
|
ArrowRight, QrCode as QrCodeIcon, Code2,
|
|
Building2, CreditCard, Globe
|
|
} from "lucide-react"
|
|
import Image from "next/image"
|
|
import Link from "next/link"
|
|
import { QRCodeCanvas } from "@/components/qr-code"
|
|
|
|
/**
|
|
* /dashboard/collect — "I want to share my link"
|
|
*
|
|
* FUNDAMENTAL REDESIGN:
|
|
*
|
|
* The primary object is the LINK. Not the appeal.
|
|
*
|
|
* Creating a link = one flow that handles:
|
|
* 1. What you're raising for (appeal — created behind the scenes)
|
|
* 2. How donors pay (bank / external platform / card)
|
|
* 3. Link name
|
|
*
|
|
* Every link card shows:
|
|
* - Payment method badge (so the user always knows how THIS link works)
|
|
* - 3 sharing tabs: Link, QR Code, Website Widget
|
|
* - Stats
|
|
*
|
|
* The widget is NOT a separate concept — it's just another way to share
|
|
* the same link. Copy the embed code, paste on your website.
|
|
*/
|
|
|
|
interface EventSummary {
|
|
id: string; name: string; slug: string; eventDate: string | null
|
|
location: string | null; goalAmount: number | null; status: string
|
|
pledgeCount: number; qrSourceCount: number; totalPledged: number; totalCollected: number
|
|
paymentMode?: string; externalPlatform?: string; externalUrl?: string
|
|
}
|
|
|
|
interface SourceInfo {
|
|
id: string; label: string; code: string
|
|
volunteerName: string | null; tableName: string | null
|
|
scanCount: number; pledgeCount: number; totalPledged: number; totalCollected?: number
|
|
}
|
|
|
|
const PLATFORM_LABELS: Record<string, string> = {
|
|
justgiving: "JustGiving", launchgood: "LaunchGood", gofundme: "GoFundMe",
|
|
enthuse: "Enthuse", other: "External link"
|
|
}
|
|
|
|
export default function CollectPage() {
|
|
const [events, setEvents] = useState<EventSummary[]>([])
|
|
const [sources, setSources] = useState<SourceInfo[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
|
const [copiedEmbed, setCopiedEmbed] = useState<string | null>(null)
|
|
const [expandedLink, setExpandedLink] = useState<string | null>(null)
|
|
const [shareTab, setShareTab] = useState<Record<string, "link" | "qr" | "embed">>({})
|
|
|
|
// Unified creation flow
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [createStep, setCreateStep] = useState<"name" | "payment" | "link">("name")
|
|
const [newAppealName, setNewAppealName] = useState("")
|
|
const [newPaymentMode, setNewPaymentMode] = useState<"bank" | "external" | "card">("bank")
|
|
const [newPlatform, setNewPlatform] = useState("")
|
|
const [newExternalUrl, setNewExternalUrl] = useState("")
|
|
const [newLinkName, setNewLinkName] = useState("Main link")
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
// Quick add link (to existing appeal)
|
|
const [showQuickAdd, setShowQuickAdd] = useState(false)
|
|
const [quickLinkName, setQuickLinkName] = useState("")
|
|
const [quickCreating, setQuickCreating] = useState(false)
|
|
|
|
const [hasStripe, setHasStripe] = useState(false)
|
|
|
|
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
|
|
|
|
// Load everything
|
|
useEffect(() => {
|
|
Promise.all([
|
|
fetch("/api/events").then(r => r.json()),
|
|
fetch("/api/settings").then(r => r.json()).catch(() => ({})),
|
|
]).then(([evData, settingsData]) => {
|
|
if (Array.isArray(evData)) {
|
|
setEvents(evData)
|
|
// Load sources for all events
|
|
const allSourcePromises = evData.map((ev: EventSummary) =>
|
|
fetch(`/api/events/${ev.id}/qr`).then(r => r.json()).catch(() => [])
|
|
)
|
|
Promise.all(allSourcePromises).then(results => {
|
|
const allSources: (SourceInfo & { _eventId: string; _eventName: string; _paymentMode: string; _externalPlatform: string | null })[] = []
|
|
results.forEach((srcs, i) => {
|
|
if (Array.isArray(srcs)) {
|
|
srcs.forEach((s: SourceInfo) => {
|
|
allSources.push({
|
|
...s,
|
|
_eventId: evData[i].id,
|
|
_eventName: evData[i].name,
|
|
_paymentMode: evData[i].paymentMode || "self",
|
|
_externalPlatform: evData[i].externalPlatform || null,
|
|
})
|
|
})
|
|
}
|
|
})
|
|
setSources(allSources)
|
|
})
|
|
}
|
|
if (settingsData.stripeSecretKey) setHasStripe(true)
|
|
}).catch(() => {}).finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
// Quick-add: which appeal does the new link belong to?
|
|
// Single appeal → auto-select. Multiple → user picks.
|
|
const [quickAddEventId, setQuickAddEventId] = useState<string | null>(null)
|
|
const activeEvent = events.length === 1
|
|
? events[0]
|
|
: events.find(e => e.id === quickAddEventId) || events[0]
|
|
|
|
// ── Actions ──
|
|
const copyLink = async (code: string) => {
|
|
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
|
|
setCopiedCode(code); setTimeout(() => setCopiedCode(null), 2000)
|
|
}
|
|
const copyEmbed = async (code: string) => {
|
|
const snippet = `<iframe src="${baseUrl}/p/${code}?embed=1" width="100%" height="160" style="border:none;max-width:480px;" title="Make a Pledge"></iframe>\n<script>window.addEventListener("message",e=>{if(e.data?.type==="pnpl-resize")document.querySelector('iframe[src*="${code}"]').style.height=e.data.height+"px"});<\/script>`
|
|
await navigator.clipboard.writeText(snippet)
|
|
setCopiedEmbed(code); setTimeout(() => setCopiedEmbed(null), 2000)
|
|
}
|
|
const shareWhatsApp = (code: string, label: string) => {
|
|
window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge here 🤲\n\n${label}\n${baseUrl}/p/${code}`)}`, "_blank")
|
|
}
|
|
const shareEmail = (code: string, label: string) => {
|
|
window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${label}`)}&body=${encodeURIComponent(`Please pledge here:\n\n${baseUrl}/p/${code}`)}`)
|
|
}
|
|
const shareNative = (code: string, label: string) => {
|
|
if (navigator.share) navigator.share({ title: label, text: `Pledge here: ${baseUrl}/p/${code}`, url: `${baseUrl}/p/${code}` })
|
|
else copyLink(code)
|
|
}
|
|
|
|
// Unified create: makes appeal + first link in one go
|
|
const handleCreate = async () => {
|
|
if (!newAppealName.trim()) return
|
|
setCreating(true)
|
|
try {
|
|
// Create the appeal
|
|
const appealRes = await fetch("/api/events", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: newAppealName.trim(),
|
|
paymentMode: newPaymentMode === "card" ? "self" : newPaymentMode === "external" ? "external" : "self",
|
|
externalPlatform: newPaymentMode === "external" ? (newPlatform || "other") : undefined,
|
|
externalUrl: newPaymentMode === "external" ? newExternalUrl : undefined,
|
|
}),
|
|
})
|
|
if (!appealRes.ok) throw new Error("Failed")
|
|
const appeal = await appealRes.json()
|
|
|
|
// Create the first link
|
|
const linkRes = await fetch(`/api/events/${appeal.id}/qr`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ label: newLinkName.trim() || "Main link" }),
|
|
})
|
|
if (linkRes.ok) {
|
|
const src = await linkRes.json()
|
|
const newEvent = { ...appeal, pledgeCount: 0, qrSourceCount: 1, totalPledged: 0, totalCollected: 0, paymentMode: appeal.paymentMode, externalPlatform: appeal.externalPlatform, externalUrl: appeal.externalUrl }
|
|
setEvents(prev => [newEvent, ...prev])
|
|
const newSource = {
|
|
...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0,
|
|
_eventId: appeal.id, _eventName: appeal.name,
|
|
_paymentMode: appeal.paymentMode || "self",
|
|
_externalPlatform: appeal.externalPlatform || null,
|
|
}
|
|
setSources(prev => [newSource, ...prev])
|
|
setExpandedLink(src.code)
|
|
}
|
|
|
|
// Reset
|
|
setShowCreate(false); setCreateStep("name")
|
|
setNewAppealName(""); setNewPaymentMode("bank"); setNewPlatform(""); setNewExternalUrl(""); setNewLinkName("Main link")
|
|
} catch { /* */ }
|
|
setCreating(false)
|
|
}
|
|
|
|
// Quick add link to existing appeal
|
|
const handleQuickAdd = async () => {
|
|
if (!quickLinkName.trim() || !activeEvent) return
|
|
setQuickCreating(true)
|
|
try {
|
|
const res = await fetch(`/api/events/${activeEvent.id}/qr`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ label: quickLinkName.trim() }),
|
|
})
|
|
if (res.ok) {
|
|
const src = await res.json()
|
|
const newSource = {
|
|
...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0,
|
|
_eventId: activeEvent.id, _eventName: activeEvent.name,
|
|
_paymentMode: activeEvent.paymentMode || "self",
|
|
_externalPlatform: activeEvent.externalPlatform || null,
|
|
}
|
|
setSources(prev => [newSource, ...prev])
|
|
setExpandedLink(src.code)
|
|
setShowQuickAdd(false); setQuickLinkName("")
|
|
}
|
|
} catch { /* */ }
|
|
setQuickCreating(false)
|
|
}
|
|
|
|
const getShareTab = (code: string) => shareTab[code] || "link"
|
|
const setShareTabFor = (code: string, tab: "link" | "qr" | "embed") => setShareTab(prev => ({ ...prev, [code]: tab }))
|
|
|
|
// Stats
|
|
const totalPledges = events.reduce((s, e) => s + e.pledgeCount, 0)
|
|
const totalPledged = events.reduce((s, e) => s + e.totalPledged, 0)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const totalConditional = events.reduce((s, e) => s + ((e as any).totalConditional || 0), 0)
|
|
// Sort sources by pledges
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const sortedSources = [...sources].sort((a: any, b: any) => b.totalPledged - a.totalPledged)
|
|
|
|
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// EMPTY STATE — Guided first-time setup
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
if (events.length === 0 || showCreate) {
|
|
return (
|
|
<div className="p-4 md:p-6 lg:p-8">
|
|
<div className="grid lg:grid-cols-12 gap-8">
|
|
<div className="lg:col-span-7">
|
|
|
|
{/* Step indicator */}
|
|
<div className="flex items-center gap-2 mb-6">
|
|
{["What you're raising for", "How donors pay", "Your link"].map((label, i) => {
|
|
const steps = ["name", "payment", "link"] as const
|
|
const isActive = steps[i] === createStep
|
|
const isDone = steps.indexOf(createStep) > i
|
|
return (
|
|
<div key={i} className="flex items-center gap-2">
|
|
{i > 0 && <div className={`w-8 h-px ${isDone ? "bg-[#1E40AF]" : "bg-gray-200"}`} />}
|
|
<div className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold ${
|
|
isActive ? "bg-[#1E40AF] text-white" : isDone ? "bg-[#1E40AF]/10 text-[#1E40AF]" : "bg-gray-100 text-gray-400"
|
|
}`}>
|
|
{isDone ? <Check className="h-3 w-3" /> : <span>{i + 1}</span>}
|
|
<span className="hidden sm:inline">{label}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
{events.length > 0 && (
|
|
<button onClick={() => { setShowCreate(false); setCreateStep("name") }} className="ml-auto text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Step 1: What are you raising for? */}
|
|
{createStep === "name" && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-black text-[#111827] tracking-tight">What are you raising for?</h1>
|
|
<p className="text-sm text-gray-500 mt-1">This could be a gala dinner, Ramadan appeal, building fund, or any cause.</p>
|
|
</div>
|
|
<div>
|
|
<input
|
|
value={newAppealName}
|
|
onChange={e => setNewAppealName(e.target.value)}
|
|
placeholder="e.g. Ramadan Gala Dinner 2026"
|
|
autoFocus
|
|
onKeyDown={e => e.key === "Enter" && newAppealName.trim() && setCreateStep("payment")}
|
|
className="w-full h-14 px-4 border-2 border-gray-200 text-lg placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => setCreateStep("payment")}
|
|
disabled={!newAppealName.trim()}
|
|
className="bg-[#111827] px-6 py-3 text-sm font-bold text-white hover:bg-gray-800 transition-colors disabled:opacity-30 flex items-center gap-2"
|
|
>
|
|
Next: How donors pay <ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 2: How will donors pay? */}
|
|
{createStep === "payment" && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-black text-[#111827] tracking-tight">How will donors pay?</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
We capture the pledge (name, phone, amount, Gift Aid) for all three options.
|
|
This is how you tell donors to actually send the money.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{/* Bank transfer */}
|
|
<button
|
|
onClick={() => setNewPaymentMode("bank")}
|
|
className={`w-full text-left p-5 border-2 transition-all ${
|
|
newPaymentMode === "bank" ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className={`w-10 h-10 flex items-center justify-center shrink-0 ${newPaymentMode === "bank" ? "bg-[#1E40AF] text-white" : "bg-gray-100 text-gray-400"}`}>
|
|
<Building2 className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-[#111827]">Bank transfer</p>
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
After pledging, donors see your sort code, account number, and a unique reference.
|
|
They transfer money directly to your bank account.
|
|
</p>
|
|
<p className="text-[10px] text-[#1E40AF] font-bold mt-1.5">Most popular · Free · No fees</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* External platform */}
|
|
<button
|
|
onClick={() => setNewPaymentMode("external")}
|
|
className={`w-full text-left p-5 border-2 transition-all ${
|
|
newPaymentMode === "external" ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className={`w-10 h-10 flex items-center justify-center shrink-0 ${newPaymentMode === "external" ? "bg-[#1E40AF] text-white" : "bg-gray-100 text-gray-400"}`}>
|
|
<Globe className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-[#111827]">JustGiving, LaunchGood, or any link</p>
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
After pledging, donors are sent to your fundraising page to pay.
|
|
We track the pledge and send reminders with your link.
|
|
</p>
|
|
<p className="text-[10px] text-gray-500 mt-1.5">Works with any platform that has a donation page</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Card payment */}
|
|
<button
|
|
onClick={() => setNewPaymentMode("card")}
|
|
className={`w-full text-left p-5 border-2 transition-all ${
|
|
newPaymentMode === "card" ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className={`w-10 h-10 flex items-center justify-center shrink-0 ${newPaymentMode === "card" ? "bg-[#1E40AF] text-white" : "bg-gray-100 text-gray-400"}`}>
|
|
<CreditCard className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-[#111827]">Card payment (Stripe)</p>
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
Donors pay instantly by Visa, Mastercard, or Apple Pay.
|
|
Money goes straight to your Stripe account.
|
|
</p>
|
|
{!hasStripe && (
|
|
<p className="text-[10px] text-[#F59E0B] font-bold mt-1.5">
|
|
Requires Stripe — <Link href="/dashboard/settings" className="underline">connect in Settings</Link>
|
|
</p>
|
|
)}
|
|
{hasStripe && <p className="text-[10px] text-[#16A34A] font-bold mt-1.5">Stripe connected ✓</p>}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* External platform details */}
|
|
{newPaymentMode === "external" && (
|
|
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-3">
|
|
<div>
|
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Platform</label>
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
{[
|
|
{ value: "justgiving", label: "JustGiving" },
|
|
{ value: "launchgood", label: "LaunchGood" },
|
|
{ value: "gofundme", label: "GoFundMe" },
|
|
{ value: "enthuse", label: "Enthuse" },
|
|
{ value: "other", label: "Other" },
|
|
].map(p => (
|
|
<button key={p.value} onClick={() => setNewPlatform(p.value)}
|
|
className={`px-3 py-1.5 text-xs font-bold border-2 transition-colors ${
|
|
newPlatform === p.value ? "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"
|
|
}`}>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Your fundraising page URL</label>
|
|
<input
|
|
value={newExternalUrl}
|
|
onChange={e => setNewExternalUrl(e.target.value)}
|
|
placeholder="https://www.justgiving.com/your-page"
|
|
className="w-full h-10 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
|
|
/>
|
|
<p className="text-[10px] text-gray-400 mt-1">Donors are sent to this link after pledging.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setCreateStep("name")} className="border border-gray-200 px-4 py-3 text-sm font-bold text-gray-500 hover:bg-gray-50">Back</button>
|
|
<button
|
|
onClick={() => setCreateStep("link")}
|
|
disabled={newPaymentMode === "external" && !newExternalUrl.trim()}
|
|
className="bg-[#111827] px-6 py-3 text-sm font-bold text-white hover:bg-gray-800 transition-colors disabled:opacity-30 flex items-center gap-2"
|
|
>
|
|
Next: Name your link <ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3: Name your link */}
|
|
{createStep === "link" && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-black text-[#111827] tracking-tight">Name your link</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
You can create multiple links — one per table, per volunteer, or per WhatsApp group.
|
|
Start with a main link and add more later.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Summary of what they've set up */}
|
|
<div className="bg-[#F9FAFB] border border-gray-100 p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-gray-500">Raising for</span>
|
|
<span className="text-xs font-bold text-[#111827]">{newAppealName}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-gray-500">Donors pay via</span>
|
|
<span className="text-xs font-bold text-[#111827]">
|
|
{newPaymentMode === "bank" ? "Bank transfer" :
|
|
newPaymentMode === "external" ? PLATFORM_LABELS[newPlatform] || "External link" :
|
|
"Card (Stripe)"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<input
|
|
value={newLinkName}
|
|
onChange={e => setNewLinkName(e.target.value)}
|
|
placeholder="e.g. Main link, Table 5, Imam Yusuf"
|
|
autoFocus
|
|
onKeyDown={e => e.key === "Enter" && handleCreate()}
|
|
className="w-full h-14 px-4 border-2 border-gray-200 text-lg placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setCreateStep("payment")} className="border border-gray-200 px-4 py-3 text-sm font-bold text-gray-500 hover:bg-gray-50">Back</button>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={creating}
|
|
className="bg-[#1E40AF] px-6 py-3 text-sm font-bold text-white hover:bg-[#1E40AF]/90 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{creating ? <><Loader2 className="h-4 w-4 animate-spin" /> Creating…</> : <>Create pledge link <ArrowRight className="h-4 w-4" /></>}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* RIGHT COLUMN — Education */}
|
|
<div className="lg:col-span-5 space-y-6">
|
|
<div className="border border-gray-200 bg-white">
|
|
<div className="border-b border-gray-100 px-5 py-3">
|
|
<h3 className="text-sm font-bold text-[#111827]">How it works</h3>
|
|
</div>
|
|
<div className="divide-y divide-gray-50">
|
|
{[
|
|
{ n: "01", title: "You create a pledge link", desc: "Give it a name and choose how donors pay. Print the QR, share the link, or embed on your website." },
|
|
{ n: "02", title: "Donors pledge in 60 seconds", desc: "They enter name, phone, amount, and Gift Aid. No app, no account, no friction." },
|
|
{ n: "03", title: "We tell them how to pay", desc: "Bank details, external link, or card checkout — based on what you chose. Plus a unique reference to track it." },
|
|
{ n: "04", title: "Reminders go out automatically", desc: "WhatsApp messages on day 2, 7, and 14. With a link to pay. Stops when they do." },
|
|
{ n: "05", title: "You match payments", desc: "Upload your bank statement. We auto-match. Or payments confirm instantly via Stripe/external." },
|
|
].map(s => (
|
|
<div key={s.n} className="px-5 py-3 flex gap-3">
|
|
<span className="text-lg font-black text-gray-200 shrink-0 w-6">{s.n}</span>
|
|
<div>
|
|
<p className="text-xs font-bold text-[#111827]">{s.title}</p>
|
|
<p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{s.desc}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment method comparison */}
|
|
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-3">
|
|
<p className="text-xs font-bold text-[#111827]">Which payment method should I choose?</p>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<p className="text-[11px] font-bold text-[#111827]">Bank transfer</p>
|
|
<p className="text-[10px] text-gray-500">Best for most charities. No fees. Donors transfer to your account directly. Use the bank statement upload to match payments.</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-bold text-[#111827]">JustGiving / LaunchGood</p>
|
|
<p className="text-[10px] text-gray-500">Already have a fundraising page? We capture the pledge and send donors to your page to pay. You get pledge tracking + reminders for free.</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-bold text-[#111827]">Card payment (Stripe)</p>
|
|
<p className="text-[10px] text-gray-500">Instant payment. Best conversion rate. Stripe charges 1.4% + 20p per transaction. Connect in Settings first.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Can I mix? */}
|
|
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-1.5">
|
|
<p className="text-xs font-bold text-[#111827]">Can I mix payment methods?</p>
|
|
<p className="text-[10px] text-gray-500">
|
|
Yes. Create a separate appeal for each payment method. For example: "Ramadan — Bank Transfer" and "Ramadan — JustGiving".
|
|
Each gets its own pledge links.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// HAS DATA — Links are the primary display
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
return (
|
|
<div>
|
|
{/* Hero */}
|
|
<div className="grid md:grid-cols-5 gap-0">
|
|
<div className="md:col-span-2 relative min-h-[140px] md:min-h-[180px] overflow-hidden">
|
|
<Image
|
|
src="/images/brand/event-05-qr-scanning.jpg"
|
|
alt="Guest scanning a QR code at a fundraising event"
|
|
fill className="object-cover"
|
|
sizes="(max-width: 768px) 100vw, 40vw"
|
|
/>
|
|
</div>
|
|
<div className="md:col-span-3 bg-[#111827] p-6 md:p-8 flex flex-col justify-center">
|
|
<div className="border-l-2 border-[#F59E0B] pl-3 mb-3">
|
|
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Collect</p>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<p className="text-2xl md:text-3xl font-black text-white tracking-tight">{sources.length}</p>
|
|
<p className="text-[10px] text-gray-500 mt-0.5">{sources.length === 1 ? "pledge link" : "pledge links"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl md:text-3xl font-black text-white tracking-tight">{totalPledges}</p>
|
|
<p className="text-[10px] text-gray-500 mt-0.5">pledges</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl md:text-3xl font-black text-[#4ADE80] tracking-tight">{formatPence(totalPledged)}</p>
|
|
<p className="text-[10px] text-gray-500 mt-0.5">confirmed</p>
|
|
</div>
|
|
{totalConditional > 0 && (
|
|
<div>
|
|
<p className="text-2xl md:text-3xl font-black text-[#FBBF24] tracking-tight">{formatPence(totalConditional)}</p>
|
|
<p className="text-[10px] text-gray-500 mt-0.5">conditional</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4 md:p-6 lg:p-8 space-y-6">
|
|
|
|
{/* Action bar */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="text-sm font-bold text-[#111827]">Your pledge links</h2>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setShowQuickAdd(true)}
|
|
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
|
|
<Plus className="h-3.5 w-3.5" /> New link
|
|
</button>
|
|
{events.length <= 3 && (
|
|
<button onClick={() => setShowCreate(true)}
|
|
className="inline-flex items-center gap-1.5 border border-gray-200 px-4 py-2 text-xs font-bold text-gray-600 hover:bg-gray-50 transition-colors">
|
|
<Plus className="h-3.5 w-3.5" /> New appeal
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick add link inline */}
|
|
{showQuickAdd && (
|
|
<div className="border-2 border-[#1E40AF] bg-[#1E40AF]/[0.02] p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-bold text-[#111827]">Create a new pledge link</p>
|
|
<button onClick={() => { setShowQuickAdd(false); setQuickLinkName(""); setQuickAddEventId(null) }}
|
|
className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
|
</div>
|
|
|
|
{/* Appeal picker — only shows when there are multiple appeals */}
|
|
{events.length > 1 && (
|
|
<div>
|
|
<label className="text-[10px] font-bold text-gray-500 block mb-1.5">Which appeal is this for?</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{events.map(ev => (
|
|
<button key={ev.id}
|
|
onClick={() => setQuickAddEventId(ev.id)}
|
|
className={`px-3 py-1.5 text-xs font-bold border-2 transition-colors ${
|
|
activeEvent?.id === ev.id
|
|
? "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]"
|
|
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
|
}`}>
|
|
{ev.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 items-end">
|
|
<div className="flex-1">
|
|
<label className="text-[10px] font-bold text-gray-500 block mb-1">
|
|
Link name
|
|
{events.length === 1 && activeEvent && (
|
|
<span className="text-gray-400 font-normal"> · {activeEvent.name}</span>
|
|
)}
|
|
</label>
|
|
<input
|
|
value={quickLinkName} onChange={e => setQuickLinkName(e.target.value)}
|
|
placeholder="e.g. Table 5, Imam Yusuf, WhatsApp group"
|
|
autoFocus onKeyDown={e => e.key === "Enter" && handleQuickAdd()}
|
|
className="w-full h-10 px-3 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none"
|
|
/>
|
|
</div>
|
|
<button onClick={handleQuickAdd} disabled={quickCreating || !quickLinkName.trim()}
|
|
className="h-10 bg-[#1E40AF] px-4 text-xs font-bold text-white disabled:opacity-40">
|
|
{quickCreating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Create"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* TWO-COLUMN: Links left, Education right */}
|
|
<div className="grid lg:grid-cols-12 gap-6">
|
|
|
|
{/* LEFT: Link cards */}
|
|
<div className="lg:col-span-7 space-y-3">
|
|
{sortedSources.map((src) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const s = src as any
|
|
const url = `${baseUrl}/p/${src.code}`
|
|
const isCopied = copiedCode === src.code
|
|
const isEmbedCopied = copiedEmbed === src.code
|
|
const isExpanded = expandedLink === src.code
|
|
const conversion = src.scanCount > 0 ? Math.round((src.pledgeCount / src.scanCount) * 100) : 0
|
|
const tab = getShareTab(src.code)
|
|
const payMode = s._paymentMode || "self"
|
|
const platform = s._externalPlatform
|
|
const eventName = s._eventName
|
|
|
|
return (
|
|
<div key={src.id} className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
|
|
{/* Header row */}
|
|
<button onClick={() => setExpandedLink(isExpanded ? null : src.code)}
|
|
className="w-full p-4 md:p-5 text-left">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-bold text-[#111827] truncate">{src.label}</p>
|
|
{/* Payment method badge */}
|
|
{payMode === "external" && platform ? (
|
|
<span className="text-[8px] font-bold px-1.5 py-0.5 bg-[#1E40AF]/10 text-[#1E40AF] shrink-0 flex items-center gap-0.5">
|
|
<Globe className="h-2 w-2" /> {PLATFORM_LABELS[platform] || "External"}
|
|
</span>
|
|
) : payMode === "self" ? (
|
|
<span className="text-[8px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-500 shrink-0 flex items-center gap-0.5">
|
|
<Building2 className="h-2 w-2" /> Bank
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{events.length > 1 && <p className="text-[10px] text-gray-400 truncate mt-0.5">{eventName}</p>}
|
|
{src.volunteerName && src.volunteerName !== src.label && (
|
|
<p className="text-[10px] text-gray-400 truncate">by {src.volunteerName}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-px bg-gray-200 shrink-0">
|
|
<div className="bg-white px-2.5 py-1.5 text-center">
|
|
<p className="text-sm font-black text-[#111827]">{src.scanCount}</p>
|
|
<p className="text-[8px] text-gray-500">clicks</p>
|
|
</div>
|
|
<div className="bg-white px-2.5 py-1.5 text-center">
|
|
<p className="text-sm font-black text-[#111827]">{src.pledgeCount}</p>
|
|
<p className="text-[8px] text-gray-500">pledges</p>
|
|
</div>
|
|
<div className="bg-white px-2.5 py-1.5 text-center">
|
|
<p className="text-sm font-black text-[#16A34A]">{formatPence(src.totalPledged)}</p>
|
|
<p className="text-[8px] text-gray-500">raised</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Conversion bar */}
|
|
{src.scanCount > 0 && (
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<div className="flex-1 h-1 bg-gray-100 overflow-hidden">
|
|
<div className="h-full bg-[#1E40AF]" style={{ width: `${conversion}%` }} />
|
|
</div>
|
|
<span className="text-[9px] text-gray-400 font-bold">{conversion}% convert</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
|
|
{/* Expanded: Share section */}
|
|
{isExpanded && (
|
|
<div className="border-t border-gray-100 p-4 md:p-5 space-y-4">
|
|
{/* Share tabs */}
|
|
<div className="flex gap-px bg-gray-200">
|
|
{[
|
|
{ key: "link" as const, label: "Link", icon: Link2 },
|
|
{ key: "qr" as const, label: "QR Code", icon: QrCodeIcon },
|
|
{ key: "embed" as const, label: "Website Widget", icon: Code2 },
|
|
].map(t => (
|
|
<button key={t.key} onClick={() => setShareTabFor(src.code, t.key)}
|
|
className={`flex-1 py-2 text-xs font-bold flex items-center justify-center gap-1.5 transition-colors ${
|
|
tab === t.key ? "bg-[#111827] text-white" : "bg-white text-gray-500 hover:bg-gray-50"
|
|
}`}>
|
|
<t.icon className="h-3 w-3" /> {t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
{tab === "link" && (
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-mono text-gray-500 break-all bg-[#F9FAFB] p-3 border border-gray-100">{url}</p>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
<button onClick={() => copyLink(src.code)} className={`py-2.5 text-[10px] font-bold flex items-center justify-center gap-1 ${
|
|
isCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"
|
|
}`}>
|
|
{isCopied ? <><Check className="h-3 w-3" /> Copied</> : <><Copy className="h-3 w-3" /> Copy</>}
|
|
</button>
|
|
<button onClick={() => shareWhatsApp(src.code, src.label)} className="bg-[#25D366] py-2.5 text-[10px] font-bold text-white flex items-center justify-center gap-1">
|
|
<MessageCircle className="h-3 w-3" /> WhatsApp
|
|
</button>
|
|
<button onClick={() => shareEmail(src.code, src.label)} className="border border-gray-200 py-2.5 text-[10px] font-bold text-gray-600 flex items-center justify-center gap-1 hover:bg-gray-50">
|
|
<Mail className="h-3 w-3" /> Email
|
|
</button>
|
|
<button onClick={() => shareNative(src.code, src.label)} className="border border-gray-200 py-2.5 text-[10px] font-bold text-gray-600 flex items-center justify-center gap-1 hover:bg-gray-50">
|
|
<Share2 className="h-3 w-3" /> Share
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "qr" && (
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="bg-white p-4 border border-gray-200 inline-block">
|
|
<QRCodeCanvas url={url} size={180} />
|
|
</div>
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<a href={`/api/events/${(src as any)._eventId}/qr/${src.id}/download?code=${src.code}`}
|
|
download={`${src.label.replace(/\s+/g, "-")}-qr.png`}
|
|
className="bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 flex items-center gap-1.5">
|
|
<Download className="h-3.5 w-3.5" /> Download QR
|
|
</a>
|
|
<p className="text-[10px] text-gray-400">Print on table cards, flyers, or banners</p>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "embed" && (
|
|
<div className="space-y-3">
|
|
<p className="text-xs text-gray-600">Add the pledge form to your website. Copy this code and paste it into your HTML:</p>
|
|
<div className="bg-[#111827] p-4 overflow-x-auto">
|
|
<code className="text-[11px] text-[#4ADE80] font-mono whitespace-pre">{`<iframe\n src="${url}?embed=1"\n width="100%"\n height="160"\n style="border:none;max-width:480px;"\n title="Pledge: ${src.label}"\n></iframe>`}</code>
|
|
</div>
|
|
<button onClick={() => copyEmbed(src.code)} className={`w-full py-2.5 text-xs font-bold flex items-center justify-center gap-1.5 ${
|
|
isEmbedCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"
|
|
}`}>
|
|
{isEmbedCopied ? <><Check className="h-3.5 w-3.5" /> Copied</> : <><Code2 className="h-3.5 w-3.5" /> Copy embed code</>}
|
|
</button>
|
|
<div className="border-l-2 border-[#1E40AF] pl-3 text-[10px] text-gray-500 space-y-1">
|
|
<p>Works on any website — WordPress, Squarespace, Wix, custom HTML.</p>
|
|
<p>The form adapts to mobile. Donors never leave your site.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Payment method note for external */}
|
|
{payMode === "external" && platform && (
|
|
<div className="border-l-2 border-[#1E40AF] bg-[#1E40AF]/5 p-3 flex items-center gap-2">
|
|
<Globe className="h-3.5 w-3.5 text-[#1E40AF] shrink-0" />
|
|
<p className="text-[10px] text-gray-600">
|
|
After pledging, donors are sent to <strong className="text-[#111827]">{PLATFORM_LABELS[platform] || "your page"}</strong> to pay
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* RIGHT: Education */}
|
|
<div className="lg:col-span-5 space-y-6">
|
|
{/* Leaderboard */}
|
|
{sortedSources.filter(s => s.pledgeCount > 0).length >= 3 && (
|
|
<div className="bg-white border border-gray-200">
|
|
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
|
|
<h3 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
|
|
<Trophy className="h-4 w-4 text-[#F59E0B]" /> Who's collecting the most
|
|
</h3>
|
|
</div>
|
|
<div className="divide-y divide-gray-50">
|
|
{sortedSources.filter(s => s.pledgeCount > 0).slice(0, 5).map((src, i) => {
|
|
const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"]
|
|
return (
|
|
<div key={src.id} className="px-5 py-3 flex items-center gap-3">
|
|
<div className={`w-6 h-6 flex items-center justify-center text-[10px] font-black text-white ${medals[i] || "bg-gray-200 text-gray-500"}`}>{i + 1}</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-[#111827] truncate">{src.volunteerName || src.label}</p>
|
|
<p className="text-[10px] text-gray-500">{src.pledgeCount} pledges</p>
|
|
</div>
|
|
<p className="text-sm font-black text-[#111827]">{formatPence(src.totalPledged)}</p>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Where to share */}
|
|
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
|
|
<p className="text-xs font-bold text-[#111827]">Where to share your link</p>
|
|
<div className="space-y-1.5">
|
|
{[
|
|
{ n: "01", text: "Print the QR code on each table at your event" },
|
|
{ n: "02", text: "Send the link to WhatsApp groups — one tap to pledge" },
|
|
{ n: "03", text: "Post it on Instagram or Facebook stories" },
|
|
{ n: "04", text: "Embed the widget on your mosque or charity website" },
|
|
{ n: "05", text: "Give each volunteer their own link — competition works" },
|
|
].map(t => (
|
|
<div key={t.n} className="flex items-start gap-2">
|
|
<span className="text-[#1E40AF] font-bold text-xs shrink-0">{t.n}</span>
|
|
<p className="text-xs text-gray-600">{t.text}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment methods */}
|
|
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-2">
|
|
<p className="text-xs font-bold text-[#111827]">How payment works</p>
|
|
<p className="text-[11px] text-gray-500 leading-relaxed">
|
|
Every pledge link captures the donor's details first. What happens next depends on how you set up the appeal:
|
|
</p>
|
|
<div className="space-y-1.5">
|
|
{[
|
|
{ icon: Building2, label: "Bank transfer", desc: "Donor sees your sort code and account number with a unique reference" },
|
|
{ icon: Globe, label: "External platform", desc: "Donor is sent to JustGiving, LaunchGood, or your fundraising page to pay" },
|
|
{ icon: CreditCard, label: "Card payment", desc: "Donor pays instantly by Visa/Mastercard. Money goes to your Stripe account" },
|
|
].map(p => (
|
|
<div key={p.label} className="flex items-start gap-2">
|
|
<p.icon className="h-3 w-3 text-[#F59E0B] shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-[11px] font-bold text-[#111827]">{p.label}</p>
|
|
<p className="text-[10px] text-gray-500">{p.desc}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* What's an appeal? */}
|
|
{events.length > 0 && (
|
|
<div className="border-l-2 border-gray-300 pl-4 space-y-1.5">
|
|
<p className="text-xs font-bold text-[#111827]">What's an appeal?</p>
|
|
<p className="text-[10px] text-gray-500">
|
|
An appeal is your fundraiser — one dinner, one campaign, one cause.
|
|
All links within an appeal share the same payment method.
|
|
Most organisations only need one appeal with several links.
|
|
</p>
|
|
{events.length > 1 && (
|
|
<div className="mt-2 space-y-1">
|
|
<p className="text-[9px] font-bold text-gray-400 uppercase tracking-wide">Your appeals</p>
|
|
{events.map(ev => (
|
|
<div key={ev.id} className="flex items-center justify-between text-[10px]">
|
|
<span className="text-[#111827] font-medium">{ev.name}</span>
|
|
<span className="text-gray-400">{ev.paymentMode === "external"
|
|
? PLATFORM_LABELS[ev.externalPlatform || ""] || "External"
|
|
: "Bank transfer"} · {ev.pledgeCount} pledges</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|