Telepathic Collect: link-first, flattened hierarchy, embedded leaderboard

Core insight: The primary object is the LINK, not the appeal.
Aaisha doesn't think 'manage appeals' — she thinks 'share my link'.

## Collect page (/dashboard/collect) — complete rewrite
- Flattened hierarchy: single-appeal orgs see links directly (no card to click)
- Multi-appeal orgs: quiet appeal switcher at top, links below
- Inline link creation: just type a name + press Enter (no dialog)
- Quick preset buttons: 'Table 1', 'WhatsApp Group', 'Instagram', etc.
- Share buttons are THE primary CTA on every link card (Copy, WhatsApp, Email, Share)
- Each link shows: clicks, pledges, amount raised, conversion rate
- Embedded mini-leaderboard when 3+ links have pledges
- Contextual tips when pledges < 5 ('give each volunteer their own link')
- New appeal creation is inline, auto-creates 'Main link'

## Appeal detail page (/dashboard/events/[id]) — brand redesign
- Sharp edges, gap-px grids, typography-as-hero
- Same link card component with share-first design
- Embedded leaderboard section
- Inline link creation (same as Collect)
- Clone appeal button
- Appeal details in collapsed <details> (context, not hero)
- Download all QR codes link
- Public progress page link

## Leaderboard page — brand redesign
- Total raised as hero number (dark section)
- Progress bars relative to leader
- Medal badges for top 3
- Conversion rate badges
- Auto-refresh every 10 seconds (live event mode)

## Route cleanup
- /dashboard/events re-exports /dashboard/collect (backward compat)
- Old events/page.tsx removed (was duplicate)

5 files changed, 3 pages redesigned
This commit is contained in:
2026-03-04 21:13:32 +08:00
parent 6fb97e1461
commit a9b3b70dfc
14 changed files with 1680 additions and 556 deletions

View File

@@ -1,5 +1,610 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { formatPence } from "@/lib/utils"
import {
Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail,
Download, ExternalLink, Users, Trophy, ChevronDown, Link2,
ArrowRight, QrCode as QrCodeIcon
} from "lucide-react"
import Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code"
/**
* /dashboard/collect — "I want people to pledge"
* This is the renamed "Campaigns/Events" page with human language.
* /dashboard/collect — "I want to share my link"
*
* This page is redesigned around ONE insight:
* The primary object is the LINK, not the appeal.
*
* For single-appeal orgs (90%):
* Links are shown directly. No appeal card to click through.
* The appeal is just a quiet context header.
*
* For multi-appeal orgs:
* An appeal selector at the top. Links below it.
*
* Aaisha's mental model:
* "Where's my link? Let me share it."
* "I need a link for Ahmed for tonight."
* "Who's collecting the most?"
*/
export { default } from "../events/page"
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
}
export default function CollectPage() {
const [events, setEvents] = useState<EventSummary[]>([])
const [activeEventId, setActiveEventId] = useState<string | null>(null)
const [sources, setSources] = useState<SourceInfo[]>([])
const [loading, setLoading] = useState(true)
const [loadingSources, setLoadingSources] = useState(false)
const [copiedCode, setCopiedCode] = useState<string | null>(null)
const [showQr, setShowQr] = useState<string | null>(null)
// Inline create
const [newLinkName, setNewLinkName] = useState("")
const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
// New appeal
const [showNewAppeal, setShowNewAppeal] = useState(false)
const [appealName, setAppealName] = useState("")
const [appealDate, setAppealDate] = useState("")
const [appealTarget, setAppealTarget] = useState("")
const [creatingAppeal, setCreatingAppeal] = useState(false)
const [eventSwitcherOpen, setEventSwitcherOpen] = useState(false)
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
// Load events
useEffect(() => {
fetch("/api/events")
.then(r => r.json())
.then(data => {
if (Array.isArray(data)) {
setEvents(data)
if (data.length > 0) setActiveEventId(data[0].id)
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Load sources when active event changes
const loadSources = useCallback(async (eventId: string) => {
setLoadingSources(true)
try {
const res = await fetch(`/api/events/${eventId}/qr`)
const data = await res.json()
if (Array.isArray(data)) setSources(data)
} catch { /* */ }
setLoadingSources(false)
}, [])
useEffect(() => {
if (activeEventId) loadSources(activeEventId)
}, [activeEventId, loadSources])
const activeEvent = events.find(e => e.id === activeEventId)
// ── Actions ──
const copyLink = async (code: string) => {
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
setCopiedCode(code)
if (navigator.vibrate) navigator.vibrate(10)
setTimeout(() => setCopiedCode(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) => {
const url = `${baseUrl}/p/${code}`
if (navigator.share) navigator.share({ title: label, text: `Pledge here: ${url}`, url })
else copyLink(code)
}
// Inline link create — just type a name and go
const createLink = async () => {
if (!newLinkName.trim() || !activeEventId) return
setCreating(true)
try {
const res = await fetch(`/api/events/${activeEventId}/qr`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: newLinkName.trim() }),
})
if (res.ok) {
const src = await res.json()
setSources(prev => [{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setNewLinkName("")
setShowCreate(false)
}
} catch { /* */ }
setCreating(false)
}
// Create new appeal
const createAppeal = async () => {
if (!appealName.trim()) return
setCreatingAppeal(true)
try {
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: appealName.trim(),
eventDate: appealDate ? new Date(appealDate).toISOString() : undefined,
goalAmount: appealTarget ? Math.round(parseFloat(appealTarget) * 100) : undefined,
}),
})
if (res.ok) {
const event = await res.json()
const newEvent = { ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }
setEvents(prev => [newEvent, ...prev])
setActiveEventId(event.id)
setShowNewAppeal(false)
setAppealName(""); setAppealDate(""); setAppealTarget("")
// Auto-create a "Main link"
const qrRes = await fetch(`/api/events/${event.id}/qr`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: "Main link" }),
})
if (qrRes.ok) {
const src = await qrRes.json()
setSources([{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }])
}
}
} catch { /* */ }
setCreatingAppeal(false)
}
// Sort sources: most pledges first
const sortedSources = [...sources].sort((a, b) => b.totalPledged - a.totalPledged)
// ── Loading ──
if (loading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
}
// ── No events yet ──
if (events.length === 0) {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
<p className="text-sm text-gray-500 mt-0.5">Create an appeal and share pledge links</p>
</div>
<div className="border-2 border-dashed border-gray-200 p-10 text-center">
<Link2 className="h-10 w-10 text-gray-300 mx-auto mb-4" />
<h3 className="text-base font-bold text-[#111827]">Create your first appeal</h3>
<p className="text-sm text-gray-500 mt-1 max-w-md mx-auto">
An appeal is your fundraiser a gala dinner, Ramadan campaign, building fund, or any cause.
We&apos;ll create a pledge link you can share instantly.
</p>
<button
onClick={() => setShowNewAppeal(true)}
className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
Create Appeal
</button>
</div>
{/* New appeal inline form */}
{showNewAppeal && <NewAppealForm
appealName={appealName} setAppealName={setAppealName}
appealDate={appealDate} setAppealDate={setAppealDate}
appealTarget={appealTarget} setAppealTarget={setAppealTarget}
creating={creatingAppeal} onCreate={createAppeal}
onCancel={() => setShowNewAppeal(false)}
/>}
</div>
)
}
return (
<div className="space-y-6">
{/* ── Header: Appeal context (quiet for single, selector for multi) ── */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
{events.length === 1 ? (
<p className="text-sm text-gray-500 mt-0.5">{activeEvent?.name}</p>
) : (
<div className="relative mt-1">
<button
onClick={() => setEventSwitcherOpen(!eventSwitcherOpen)}
className="inline-flex items-center gap-1.5 text-sm font-bold text-[#1E40AF] hover:underline"
>
{activeEvent?.name} <ChevronDown className={`h-3.5 w-3.5 transition-transform ${eventSwitcherOpen ? "rotate-180" : ""}`} />
</button>
{eventSwitcherOpen && (
<div className="absolute z-20 mt-1 bg-white border border-gray-200 shadow-lg w-72 max-h-64 overflow-y-auto">
{events.map(ev => (
<button
key={ev.id}
onClick={() => { setActiveEventId(ev.id); setEventSwitcherOpen(false) }}
className={`w-full text-left px-4 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors ${ev.id === activeEventId ? "bg-[#1E40AF]/5" : ""}`}
>
<div>
<p className="text-sm font-medium text-[#111827]">{ev.name}</p>
<p className="text-[10px] text-gray-500">{ev.pledgeCount} pledges · {formatPence(ev.totalPledged)}</p>
</div>
{ev.id === activeEventId && <Check className="h-4 w-4 text-[#1E40AF]" />}
</button>
))}
<button
onClick={() => { setShowNewAppeal(true); setEventSwitcherOpen(false) }}
className="w-full text-left px-4 py-3 border-t border-gray-100 text-sm font-semibold text-[#1E40AF] hover:bg-gray-50 flex items-center gap-1.5"
>
<Plus className="h-3.5 w-3.5" /> New appeal
</button>
</div>
)}
</div>
)}
</div>
<div className="flex gap-2">
{events.length === 1 && (
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-gray-500 hover:text-[#111827] border border-gray-200 px-3 py-1.5 transition-colors">
+ New appeal
</button>
)}
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
<Plus className="h-4 w-4" /> New Link
</button>
</div>
</div>
{/* ── Appeal stats (compact — the appeal is context, not hero) ── */}
{activeEvent && (
<div className="grid grid-cols-4 gap-px bg-gray-200">
{[
{ value: String(activeEvent.pledgeCount), label: "Pledges" },
{ value: formatPence(activeEvent.totalPledged), label: "Promised" },
{ value: formatPence(activeEvent.totalCollected), label: "Received", accent: "text-[#16A34A]" },
{ value: String(sources.length), label: "Links" },
].map(stat => (
<div key={stat.label} className="bg-white p-3 md:p-4">
<p className={`text-lg md:text-xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
<p className="text-[10px] text-gray-500">{stat.label}</p>
</div>
))}
</div>
)}
{/* ── Inline "create link" — fast, no dialog ── */}
{showCreate && (
<div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
<p className="text-sm font-bold text-[#111827]">Name your new link</p>
<p className="text-xs text-gray-500">
Give each volunteer, table, WhatsApp group, or social post its own link so you can see where pledges come from.
</p>
<div className="flex gap-2">
<input
value={newLinkName}
onChange={e => setNewLinkName(e.target.value)}
placeholder='e.g. "Ahmed", "Table 5", "WhatsApp Family", "Instagram Bio"'
autoFocus
onKeyDown={e => e.key === "Enter" && createLink()}
className="flex-1 h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] focus:ring-4 focus:ring-[#1E40AF]/10 outline-none transition-all"
/>
<button
onClick={createLink}
disabled={!newLinkName.trim() || creating}
className="bg-[#111827] px-5 h-11 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center gap-1.5"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Create</>}
</button>
</div>
{/* Quick presets */}
<div className="flex flex-wrap gap-1.5">
{["Table 1", "Table 2", "Table 3", "WhatsApp Group", "Instagram", "Email Campaign", "Website"].map(preset => (
<button
key={preset}
onClick={() => setNewLinkName(preset)}
className="text-[10px] font-medium text-gray-500 border border-gray-200 px-2 py-1 hover:bg-gray-50 hover:border-gray-300 transition-colors"
>
{preset}
</button>
))}
</div>
<button onClick={() => { setShowCreate(false); setNewLinkName("") }} className="text-xs text-gray-400 hover:text-gray-600">
Cancel
</button>
</div>
)}
{/* ── Links — the hero of this page ── */}
{loadingSources ? (
<div className="text-center py-10"><Loader2 className="h-5 w-5 text-[#1E40AF] animate-spin mx-auto" /></div>
) : sources.length === 0 ? (
<div className="border-2 border-dashed border-gray-200 p-8 text-center">
<Link2 className="h-8 w-8 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-bold text-[#111827]">No pledge links yet</p>
<p className="text-xs text-gray-500 mt-1">Create a link to start collecting pledges</p>
<button
onClick={() => setShowCreate(true)}
className="mt-3 bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 transition-colors"
>
Create your first link
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-[#111827]">Your links ({sources.length})</h2>
{sources.length > 1 && (
<Link href={`/dashboard/events/${activeEventId}/leaderboard`} className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
<Trophy className="h-3 w-3" /> Leaderboard
</Link>
)}
</div>
{sortedSources.map((src, idx) => (
<LinkCard
key={src.id}
src={src}
rank={sources.length > 1 ? idx + 1 : null}
baseUrl={baseUrl}
eventId={activeEventId!}
copiedCode={copiedCode}
showQr={showQr}
onCopy={copyLink}
onWhatsApp={shareWhatsApp}
onEmail={shareEmail}
onShare={shareNative}
onToggleQr={(code) => setShowQr(showQr === code ? null : code)}
/>
))}
</div>
)}
{/* ── Embedded mini leaderboard (only if 3+ links with pledges) ── */}
{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&apos;s collecting the most
</h3>
<Link href={`/dashboard/events/${activeEventId}/leaderboard`} className="text-xs text-[#1E40AF] hover:underline">Full view </Link>
</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 · {src.scanCount} clicks</p>
</div>
<p className="text-sm font-black text-[#111827]">{formatPence(src.totalPledged)}</p>
</div>
)
})}
</div>
</div>
)}
{/* ── Tips (only show when they have links but few pledges) ── */}
{sources.length > 0 && sources.reduce((s, l) => s + l.pledgeCount, 0) < 5 && (
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
<p className="text-xs font-bold text-[#111827]">Tips to get more pledges</p>
<ul className="text-xs text-gray-600 space-y-1.5">
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">01</span> Give each volunteer their own link friendly competition works</li>
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">02</span> Put the QR code on each table at your event</li>
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">03</span> Share directly to WhatsApp groups it takes 1 tap for them to pledge</li>
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">04</span> Post the link on your Instagram or Facebook story</li>
</ul>
</div>
)}
{/* ── New appeal inline form ── */}
{showNewAppeal && <NewAppealForm
appealName={appealName} setAppealName={setAppealName}
appealDate={appealDate} setAppealDate={setAppealDate}
appealTarget={appealTarget} setAppealTarget={setAppealTarget}
creating={creatingAppeal} onCreate={createAppeal}
onCancel={() => setShowNewAppeal(false)}
/>}
</div>
)
}
// ─── Link Card Component ─────────────────────────────────────
function LinkCard({ src, rank, baseUrl, eventId, copiedCode, showQr, onCopy, onWhatsApp, onEmail, onShare, onToggleQr }: {
src: SourceInfo; rank: number | null; baseUrl: string; eventId: string
copiedCode: string | null; showQr: string | null
onCopy: (code: string) => void; onWhatsApp: (code: string, label: string) => void
onEmail: (code: string, label: string) => void; onShare: (code: string, label: string) => void
onToggleQr: (code: string) => void
}) {
const url = `${baseUrl}/p/${src.code}`
const isCopied = copiedCode === src.code
const isQrOpen = showQr === src.code
const conversion = src.scanCount > 0 ? Math.round((src.pledgeCount / src.scanCount) * 100) : 0
return (
<div className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
<div className="p-4 md:p-5">
{/* Top row: label + rank + stats */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
{rank !== null && rank <= 3 ? (
<div className={`w-6 h-6 flex items-center justify-center text-[10px] font-black text-white shrink-0 ${
rank === 1 ? "bg-[#F59E0B]" : rank === 2 ? "bg-gray-400" : "bg-[#CD7F32]"
}`}>{rank}</div>
) : rank !== null ? (
<div className="w-6 h-6 flex items-center justify-center text-[10px] font-bold text-gray-400 bg-gray-100 shrink-0">{rank}</div>
) : null}
<div className="min-w-0">
<p className="text-sm font-bold text-[#111827] truncate">{src.label}</p>
{src.volunteerName && src.volunteerName !== src.label && (
<p className="text-[10px] text-gray-500 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 leading-none">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 leading-none">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 leading-none">raised</p>
</div>
</div>
</div>
{/* URL line */}
<div className="bg-[#F9FAFB] px-3 py-2 mb-3 flex items-center justify-between gap-2">
<p className="text-xs font-mono text-gray-500 truncate">{url}</p>
{src.scanCount > 0 && (
<span className="text-[9px] font-bold text-gray-400 shrink-0">{conversion}% convert</span>
)}
</div>
{/* Share buttons — THE primary CTA, big and obvious */}
<div className="grid grid-cols-4 gap-1.5">
<button
onClick={() => onCopy(src.code)}
className={`py-2.5 text-xs font-bold transition-colors flex items-center justify-center gap-1.5 ${
isCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"
}`}
>
{isCopied ? <><Check className="h-3.5 w-3.5" /> Copied</> : <><Copy className="h-3.5 w-3.5" /> Copy</>}
</button>
<button
onClick={() => onWhatsApp(src.code, src.label)}
className="bg-[#25D366] text-white py-2.5 text-xs font-bold hover:bg-[#25D366]/90 transition-colors flex items-center justify-center gap-1.5"
>
<MessageCircle className="h-3.5 w-3.5" /> WhatsApp
</button>
<button
onClick={() => onEmail(src.code, src.label)}
className="border border-gray-200 text-[#111827] py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5"
>
<Mail className="h-3.5 w-3.5" /> Email
</button>
<button
onClick={() => onShare(src.code, src.label)}
className="border border-gray-200 text-[#111827] py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5"
>
<Share2 className="h-3.5 w-3.5" /> Share
</button>
</div>
{/* Secondary row: QR toggle, download, volunteer view */}
<div className="flex gap-2 mt-2">
<button
onClick={() => onToggleQr(src.code)}
className="flex-1 text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors"
>
<QrCodeIcon className="h-3 w-3" /> {isQrOpen ? "Hide QR" : "Show QR"}
</button>
<a href={`/api/events/${eventId}/qr/${src.id}/download?code=${src.code}`} download className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<Download className="h-3 w-3" /> Download QR
</button>
</a>
<a href={`/v/${src.code}`} target="_blank" className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<Users className="h-3 w-3" /> Volunteer view
</button>
</a>
<a href={`/p/${src.code}`} target="_blank" className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<ExternalLink className="h-3 w-3" /> Preview
</button>
</a>
</div>
{/* QR code (toggled) */}
{isQrOpen && (
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-col items-center gap-2">
<div className="bg-white p-2 border border-gray-100">
<QRCodeCanvas url={url} size={160} />
</div>
<p className="text-[10px] text-gray-400">Right-click or long-press to save the QR image</p>
</div>
)}
</div>
</div>
)
}
// ─── New Appeal Inline Form ──────────────────────────────────
function NewAppealForm({ appealName, setAppealName, appealDate, setAppealDate, appealTarget, setAppealTarget, creating, onCreate, onCancel }: {
appealName: string; setAppealName: (v: string) => void
appealDate: string; setAppealDate: (v: string) => void
appealTarget: string; setAppealTarget: (v: string) => void
creating: boolean; onCreate: () => void; onCancel: () => void
}) {
return (
<div className="bg-white border-2 border-[#1E40AF] p-5 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">New appeal</h3>
<p className="text-xs text-gray-500 mt-0.5">We&apos;ll create a pledge link automatically.</p>
</div>
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">What are you raising for?</label>
<input
value={appealName}
onChange={e => setAppealName(e.target.value)}
placeholder="e.g. Ramadan Gala Dinner 2026"
autoFocus
onKeyDown={e => e.key === "Enter" && onCreate()}
className="w-full h-12 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Date <span className="font-normal text-gray-400">(optional)</span></label>
<input type="date" value={appealDate} onChange={e => setAppealDate(e.target.value)} className="w-full h-10 px-3 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none transition-all" />
</div>
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Target £ <span className="font-normal text-gray-400">(optional)</span></label>
<input type="number" value={appealTarget} onChange={e => setAppealTarget(e.target.value)} placeholder="50000" className="w-full h-10 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all" />
</div>
</div>
<div className="flex gap-3">
<button onClick={onCancel} className="flex-1 border border-gray-200 py-2.5 text-xs font-bold text-[#111827] hover:bg-gray-50 transition-colors">Cancel</button>
<button onClick={onCreate} disabled={!appealName.trim() || creating} className="flex-1 bg-[#111827] py-2.5 text-xs font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center justify-center gap-1.5">
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Create <ArrowRight className="h-3.5 w-3.5" /></>}
</button>
</div>
</div>
)
}