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:
@@ -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'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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user