diff --git a/PERSONA_ANALYSIS.md b/PERSONA_ANALYSIS.md new file mode 100644 index 0000000..011e363 --- /dev/null +++ b/PERSONA_ANALYSIS.md @@ -0,0 +1,113 @@ +# CharityRight Admin — Persona Analysis & Journey Design + +## The Real Users (Not What the Code Assumes) + +### Only 2-3 people actually use this admin panel regularly: +1. **Sahibah Ali** — Supporter care / operations (last active 2 weeks ago) +2. **Jasmine Begum** — Operations / fundraiser management (last active 2 weeks ago) +3. **Omair** — Technical oversight (Superadmin) + +128 accounts have Superadmin role, but 120+ are dormant test accounts, old staff, or event-specific accounts created years ago. + +--- + +## Persona 1: "Sahibah" — The Supporter Care Person + +**Who she is:** A charity staff member who handles donor inquiries. When someone emails donations@charityright.org.uk or calls asking about their donation, she needs to help them. + +**Her real questions (not what the admin shows her):** +- "Someone emailed saying their donation didn't go through — can I find it?" +- "A donor wants to change their regular giving amount — where is their subscription?" +- "Someone wants a copy of their receipt — can I resend it?" +- "A donor says they donated £50 but only see £45 — what happened?" (admin fee) +- "Someone wants to cancel their monthly giving" +- "A donor wants to claim Gift Aid — did they tick the box?" +- "How many donations did we get today?" + +**Her pain right now:** +- She has to search Customers, then click Edit, then scroll to the Donations tab, then find the donation, then click it — 4 clicks just to see someone's donation +- She can't search by donation reference number from the main search +- She can't see at a glance if a donor has Gift Aid enabled +- There's no way to add a note saying "Called on 4 March, wants refund" +- She can't see a donor's total lifetime value +- The dashboard shows her system metrics she doesn't understand + +**What she WANTS when she logs in:** +→ A search box. Type an email. See everything about that person. Done. + +### Persona 2: "Jasmine" — The Campaign Manager + +**Who she is:** Manages fundraising campaigns and reviews the fundraiser queue. + +**Her real questions:** +- "Did anyone create a new fundraiser page today?" +- "How is the Ramadan campaign doing?" +- "Which fundraisers are raising the most right now?" +- "Is there spam in the queue?" +- "How much total did we raise this month?" +- "I need to set up donation pages for the new Qurbani campaign" + +**Her pain right now:** +- The fundraiser queue (84 pending) is overwhelming — she doesn't know which ones to look at first +- She can't see a leaderboard of top fundraisers +- She has to check individual fundraisers one by one +- No way to see campaign performance at a glance +- The "Appeals" name means nothing to her — she thinks of them as "fundraising pages" + +**What she WANTS when she logs in:** +→ A dashboard showing campaign health. The queue auto-sorted by urgency. A leaderboard. + +### Persona 3: "Omair" — Technical Admin + +**His real questions:** +- "Is anything broken?" +- "Are payments going through?" +- "Is the Engage sync working?" +- "Are emails being sent?" + +**His pain:** He doesn't use the admin panel for daily work — he goes to Stripe, server logs, etc. The admin panel doesn't show him system health. + +--- + +## What the Admin Currently Shows vs What Users Need + +| What Admin Shows | What Users Need | +|---|---| +| "Customers" table with 21K rows | "Look up a donor" search box | +| "Donations" table with 30K rows | "Today's donations" + donor-linked view | +| "Approval Queues" table | "New fundraisers to review" with AI pre-sorted | +| Empty dashboard | At-a-glance: money today, pending tasks, problems | +| 15 navigation items | 3-4 things they actually click | +| "Event Logs" with 2.2M rows | "Is anything broken?" yes/no | +| "Scheduled Giving Donations" | "Monthly supporters" with health status | +| Separate "Users" and "Customers" | Unified donor profile | + +--- + +## The Journey Redesign + +### Morning Login — "What happened while I was away?" +Current: Blank page → navigate to something +Should be: Dashboard that TELLS you what needs attention + +### "A donor just called" — Find & Help +Current: 7 clicks across 3 pages +Should be: Type name/email in global search → see full donor profile → act + +### "Review new fundraisers" — The Queue +Current: Open list → click each one → read → decide → go back → repeat +Should be: AI-pre-sorted list → most important first → approve/reject inline → done + +### "How are we doing?" — Performance +Current: Nothing. Absolutely nothing. +Should be: Revenue chart, top fundraisers, campaign health, donor count + +--- + +## Design Principles for the Rebuild + +1. **Answer questions, not show tables.** "How much did we raise today?" → Show the number, not a filtered table. +2. **One search to rule them all.** Cmd+K finds donors, donations, fundraisers — by any field. +3. **Surface problems, don't hide them.** Failed payments, spam fundraisers, broken syncs → badges, alerts. +4. **Use the language they use.** Not "Appeals" but "Fundraising Pages." Not "Customers" but "Donors." +5. **Anticipate the next action.** After viewing a donor, offer: "Resend receipt" / "Add note" / "View in Stripe." diff --git a/pledge-now-pay-later/src/app/dashboard/collect/page.tsx b/pledge-now-pay-later/src/app/dashboard/collect/page.tsx index 441a78f..e9c9e85 100644 --- a/pledge-now-pay-later/src/app/dashboard/collect/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/collect/page.tsx @@ -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([]) + const [activeEventId, setActiveEventId] = useState(null) + const [sources, setSources] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingSources, setLoadingSources] = useState(false) + const [copiedCode, setCopiedCode] = useState(null) + const [showQr, setShowQr] = useState(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
+ } + + // ── No events yet ── + if (events.length === 0) { + return ( +
+
+

Collect

+

Create an appeal and share pledge links

+
+
+ +

Create your first appeal

+

+ 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. +

+ +
+ + {/* New appeal inline form */} + {showNewAppeal && setShowNewAppeal(false)} + />} +
+ ) + } + + return ( +
+ + {/* ── Header: Appeal context (quiet for single, selector for multi) ── */} +
+
+

Collect

+ + {events.length === 1 ? ( +

{activeEvent?.name}

+ ) : ( +
+ + {eventSwitcherOpen && ( +
+ {events.map(ev => ( + + ))} + +
+ )} +
+ )} +
+ +
+ {events.length === 1 && ( + + )} + +
+
+ + {/* ── Appeal stats (compact — the appeal is context, not hero) ── */} + {activeEvent && ( +
+ {[ + { 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 => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ )} + + {/* ── Inline "create link" — fast, no dialog ── */} + {showCreate && ( +
+

Name your new link

+

+ Give each volunteer, table, WhatsApp group, or social post its own link — so you can see where pledges come from. +

+
+ 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" + /> + +
+ {/* Quick presets */} +
+ {["Table 1", "Table 2", "Table 3", "WhatsApp Group", "Instagram", "Email Campaign", "Website"].map(preset => ( + + ))} +
+ +
+ )} + + {/* ── Links — the hero of this page ── */} + {loadingSources ? ( +
+ ) : sources.length === 0 ? ( +
+ +

No pledge links yet

+

Create a link to start collecting pledges

+ +
+ ) : ( +
+
+

Your links ({sources.length})

+ {sources.length > 1 && ( + + Leaderboard → + + )} +
+ + {sortedSources.map((src, idx) => ( + 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)} + /> + ))} +
+ )} + + {/* ── Embedded mini leaderboard (only if 3+ links with pledges) ── */} + {sortedSources.filter(s => s.pledgeCount > 0).length >= 3 && ( +
+
+

+ Who's collecting the most +

+ Full view → +
+
+ {sortedSources.filter(s => s.pledgeCount > 0).slice(0, 5).map((src, i) => { + const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"] + return ( +
+
+ {i + 1} +
+
+

{src.volunteerName || src.label}

+

{src.pledgeCount} pledges · {src.scanCount} clicks

+
+

{formatPence(src.totalPledged)}

+
+ ) + })} +
+
+ )} + + {/* ── Tips (only show when they have links but few pledges) ── */} + {sources.length > 0 && sources.reduce((s, l) => s + l.pledgeCount, 0) < 5 && ( +
+

Tips to get more pledges

+
    +
  • 01 Give each volunteer their own link — friendly competition works
  • +
  • 02 Put the QR code on each table at your event
  • +
  • 03 Share directly to WhatsApp groups — it takes 1 tap for them to pledge
  • +
  • 04 Post the link on your Instagram or Facebook story
  • +
+
+ )} + + {/* ── New appeal inline form ── */} + {showNewAppeal && setShowNewAppeal(false)} + />} +
+ ) +} + +// ─── 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 ( +
+
+ {/* Top row: label + rank + stats */} +
+
+ {rank !== null && rank <= 3 ? ( +
{rank}
+ ) : rank !== null ? ( +
{rank}
+ ) : null} +
+

{src.label}

+ {src.volunteerName && src.volunteerName !== src.label && ( +

by {src.volunteerName}

+ )} +
+
+
+
+

{src.scanCount}

+

clicks

+
+
+

{src.pledgeCount}

+

pledges

+
+
+

{formatPence(src.totalPledged)}

+

raised

+
+
+
+ + {/* URL line */} +
+

{url}

+ {src.scanCount > 0 && ( + {conversion}% convert + )} +
+ + {/* Share buttons — THE primary CTA, big and obvious */} +
+ + + + +
+ + {/* Secondary row: QR toggle, download, volunteer view */} +
+ + + + + + + + + + +
+ + {/* QR code (toggled) */} + {isQrOpen && ( +
+
+ +
+

Right-click or long-press to save the QR image

+
+ )} +
+
+ ) +} + +// ─── 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 ( +
+
+

New appeal

+

We'll create a pledge link automatically.

+
+
+ + 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" + /> +
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+
+ + +
+
+ ) +} diff --git a/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx index d2d33ee..8b05971 100644 --- a/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/events/[id]/leaderboard/page.tsx @@ -2,19 +2,24 @@ import { useState, useEffect } from "react" import { useParams } from "next/navigation" -import { Card, CardContent } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" +import { formatPence } from "@/lib/utils" import { Trophy, ArrowLeft, Loader2 } from "lucide-react" import Link from "next/link" +/** + * /dashboard/events/[id]/leaderboard — Full-screen volunteer leaderboard + * + * Designed for two scenarios: + * 1. Aaisha checking who's collecting the most (desktop) + * 2. Projected on a screen at a live event (full-screen, auto-refresh) + * + * Brand-consistent: sharp edges, gap-px, typography-as-hero. + */ + interface LeaderEntry { - label: string - volunteerName: string | null - pledgeCount: number - totalPledged: number - totalPaid: number - scanCount: number - conversionRate: number + label: string; volunteerName: string | null + pledgeCount: number; totalPledged: number; totalCollected: number + scanCount: number; conversionRate: number } export default function LeaderboardPage() { @@ -23,21 +28,19 @@ export default function LeaderboardPage() { const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) - const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` - useEffect(() => { const load = () => { fetch(`/api/events/${eventId}/qr`) - .then((r) => r.json()) - .then((data) => { + .then(r => r.json()) + .then(data => { if (Array.isArray(data)) { const sorted = [...data].sort((a, b) => b.totalPledged - a.totalPledged) - setEntries(sorted.map((d) => ({ + setEntries(sorted.map(d => ({ label: d.label, volunteerName: d.volunteerName, pledgeCount: d.pledgeCount, totalPledged: d.totalPledged, - totalPaid: d.totalCollected || 0, + totalCollected: d.totalCollected || 0, scanCount: d.scanCount, conversionRate: d.scanCount > 0 ? Math.round((d.pledgeCount / d.scanCount) * 100) : 0, }))) @@ -51,63 +54,77 @@ export default function LeaderboardPage() { return () => clearInterval(interval) }, [eventId]) - const medals = ["🥇", "🥈", "🥉"] + if (loading) return
- if (loading) { - return ( -
- -
- ) - } + const total = entries.reduce((s, e) => s + e.totalPledged, 0) return (
- - Back to Event + + Back to appeal -

- Fundraiser Leaderboard -

-

Auto-refreshes every 10 seconds — perfect for live events

+
+ +
+

Leaderboard

+

Auto-refreshes every 10 seconds — perfect for live events

+
+
-
- {entries.map((entry, i) => ( - - -
-
- {i < 3 ? medals[i] : #{i + 1}} -
-
-

{entry.volunteerName || entry.label}

-
- {entry.pledgeCount} pledges - {entry.scanCount} scans - = 50 ? "success" : "secondary"} className="text-xs"> - {entry.conversionRate}% conversion - + {/* Total */} +
+

Total raised

+

{formatPence(total)}

+

{entries.reduce((s, e) => s + e.pledgeCount, 0)} pledges from {entries.length} links

+
+ + {/* Entries */} + {entries.length === 0 ? ( +
+ +

No pledge links created yet

+
+ ) : ( +
+ {entries.map((entry, i) => { + const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"] + const isTop3 = i < 3 + return ( +
+
+
+ {i + 1} +
+
+

{entry.volunteerName || entry.label}

+
+ {entry.pledgeCount} pledges + {entry.scanCount} clicks + = 50 ? "bg-[#16A34A]/10 text-[#16A34A]" : "bg-gray-100 text-gray-500"}`}> + {entry.conversionRate}% conversion + +
+
+
+

{formatPence(entry.totalPledged)}

+

{formatPence(entry.totalCollected)} collected

-
-

{formatPence(entry.totalPledged)}

-

{formatPence(entry.totalPaid)} collected

-
+ {/* Progress relative to leader */} + {entries[0].totalPledged > 0 && ( +
+
+
+ )}
- - - ))} -
- - {entries.length === 0 && ( - - - -

No QR codes created yet. Create QR codes to see the leaderboard.

-
-
+ ) + })} +
)}
) diff --git a/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx index 8b7f7ee..c1af437 100644 --- a/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx @@ -1,48 +1,79 @@ "use client" -import { useState, useEffect } from "react" -import { useParams } from "next/navigation" -import { Card, CardContent } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { useState, useEffect, useCallback } from "react" +import { useParams, useRouter } from "next/navigation" import { formatPence } from "@/lib/utils" -import { Plus, Download, ExternalLink, Copy, Check, Loader2, ArrowLeft, Trophy, Users, MessageCircle, Mail, Share2, Link2 } from "lucide-react" +import { + Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail, + Download, ExternalLink, Users, Trophy, ArrowLeft, Link2, + QrCode as QrCodeIcon, MoreHorizontal, Repeat +} from "lucide-react" import Link from "next/link" import { QRCodeCanvas } from "@/components/qr-code" -interface SourceInfo { - id: string - label: string - code: string - volunteerName: string | null - tableName: string | null - scanCount: number - pledgeCount: number - totalPledged: number +/** + * /dashboard/events/[id] — Deep view of a single appeal + its links + * + * Redesigned to be brand-consistent and link-centric. + * The leaderboard is embedded, not a separate page. + * Share buttons are the primary CTA on every link. + * + * This page serves two entry points: + * 1. From Collect page → "manage links" for a specific appeal + * 2. From Welcome flow → "Add more links" + */ + +interface EventDetail { + id: string; name: string; slug: string; eventDate: string | null + location: string | null; goalAmount: number | null; status: string + description: string | null; pledgeCount: number; qrSourceCount: number + totalPledged: number; totalCollected: number + paymentMode?: string; externalPlatform?: string; externalUrl?: string + zakatEligible?: boolean } -export default function CampaignLinksPage() { +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 AppealDetailPage() { const params = useParams() + const router = useRouter() const eventId = params.id as string + const [event, setEvent] = useState(null) const [sources, setSources] = useState([]) const [loading, setLoading] = useState(true) - const [showCreate, setShowCreate] = useState(false) const [copiedCode, setCopiedCode] = useState(null) - const [form, setForm] = useState({ label: "", volunteerName: "", tableName: "" }) + const [showQr, setShowQr] = useState(null) + + // Inline create + const [newLinkName, setNewLinkName] = useState("") const [creating, setCreating] = useState(false) + const [showCreate, setShowCreate] = useState(false) + + const [cloning, setCloning] = useState(false) const baseUrl = typeof window !== "undefined" ? window.location.origin : "" - useEffect(() => { - fetch(`/api/events/${eventId}/qr`) - .then((r) => r.json()) - .then((data) => { if (Array.isArray(data)) setSources(data) }) - .catch(() => {}) - .finally(() => setLoading(false)) + // Load event + sources + const loadData = useCallback(async () => { + try { + const [evRes, srcRes] = await Promise.all([ + fetch("/api/events").then(r => r.json()), + fetch(`/api/events/${eventId}/qr`).then(r => r.json()), + ]) + const ev = Array.isArray(evRes) ? evRes.find((e: EventDetail) => e.id === eventId) : null + if (ev) setEvent(ev) + if (Array.isArray(srcRes)) setSources(srcRes) + } catch { /* */ } + setLoading(false) }, [eventId]) + useEffect(() => { loadData() }, [loadData]) + + // Actions const copyLink = async (code: string) => { await navigator.clipboard.writeText(`${baseUrl}/p/${code}`) setCopiedCode(code) @@ -50,212 +81,357 @@ export default function CampaignLinksPage() { setTimeout(() => setCopiedCode(null), 2000) } - const shareLink = (code: string, label: string) => { - const url = `${baseUrl}/p/${code}` - if (navigator.share) { - navigator.share({ title: label, text: `Pledge here: ${url}`, url }) - } else { - copyLink(code) - } - } - const shareWhatsApp = (code: string, label: string) => { - const url = `${baseUrl}/p/${code}` - window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge here 🤲\n\n${label}\n${url}`)}`, "_blank") + 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) => { - const url = `${baseUrl}/p/${code}` - window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${label}`)}&body=${encodeURIComponent(`Please pledge here:\n\n${url}`)}`) + window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${label}`)}&body=${encodeURIComponent(`Please pledge here:\n\n${baseUrl}/p/${code}`)}`) } - const handleCreate = async () => { + 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) + } + + const createLink = async () => { + if (!newLinkName.trim()) return setCreating(true) try { const res = await fetch(`/api/events/${eventId}/qr`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(form), + body: JSON.stringify({ label: newLinkName.trim() }), }) if (res.ok) { const src = await res.json() - setSources((prev) => [{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0 }, ...prev]) + setSources(prev => [{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev]) + setNewLinkName("") setShowCreate(false) - setForm({ label: "", volunteerName: "", tableName: "" }) } - } catch {} + } catch { /* */ } setCreating(false) } - useEffect(() => { - if (form.volunteerName || form.tableName) { - const parts = [form.tableName, form.volunteerName].filter(Boolean) - setForm((f) => ({ ...f, label: parts.join(" - ") })) - } - }, [form.volunteerName, form.tableName]) + const cloneAppeal = async () => { + setCloning(true) + try { + const res = await fetch(`/api/events/${eventId}/clone`, { method: "POST" }) + if (res.ok) { + const data = await res.json() + router.push(`/dashboard/events/${data.id}`) + } + } catch { /* */ } + setCloning(false) + } - if (loading) return
+ if (loading) return
+ if (!event) return

Appeal not found

- const totalClicks = sources.reduce((s, q) => s + q.scanCount, 0) - const totalPledges = sources.reduce((s, q) => s + q.pledgeCount, 0) - const totalAmount = sources.reduce((s, q) => s + q.totalPledged, 0) + const sortedSources = [...sources].sort((a, b) => b.totalPledged - a.totalPledged) + const progress = event.goalAmount ? Math.min(100, Math.round((event.totalPledged / event.goalAmount) * 100)) : 0 return (
-
-
- - Back to Campaigns - -

Pledge Links

-

- {sources.length} link{sources.length !== 1 ? "s" : ""} · {totalClicks} clicks · {totalPledges} pledges · {formatPence(totalAmount)} -

-
-
- - - - + + {/* ── Back + header ── */} +
+ + Back to Collect + +
+
+

{event.name}

+
+ {event.eventDate && {new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}} + {event.location && {event.location}} + + {event.status === "active" ? "Live" : event.status} + +
+
+
+ + +
- {sources.length === 0 ? ( - - - -

Create your first pledge link

-

- Each link is unique and trackable. Create one per volunteer, table, WhatsApp group, social post, or email campaign — so you know where pledges come from. -

- -
-
- ) : ( -
- {sources.map((src) => ( - - - {/* QR Code — compact */} -
- -
+ {/* ── Stats ── */} +
+ {[ + { value: String(event.pledgeCount), label: "Pledges" }, + { value: formatPence(event.totalPledged), label: "Promised" }, + { value: formatPence(event.totalCollected), label: "Received", accent: "text-[#16A34A]" }, + { value: String(sources.length), label: "Links" }, + ].map(stat => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
-
-

{src.label}

- {src.volunteerName &&

By: {src.volunteerName}

} -

{baseUrl}/p/{src.code}

-
- - {/* Stats */} -
-
-

{src.scanCount}

-

Clicks

-
-
-

{src.pledgeCount}

-

Pledges

-
-
-

{formatPence(src.totalPledged)}

-

Raised

-
-
- - {src.scanCount > 0 && ( -
- - Conversion: {Math.round((src.pledgeCount / src.scanCount) * 100)}% - -
- )} - - {/* Share options — the main CTA, not an afterthought */} -
- - - - -
- - {/* Secondary actions */} - -
-
- ))} + {/* Goal bar */} + {event.goalAmount && ( +
+
+ {progress}% of {formatPence(event.goalAmount)} target + {formatPence(event.totalPledged)} +
+
+
+
)} - {/* Create dialog */} - - - Create Pledge Link - -
-

- Each link is trackable. Create one per source to see where pledges come from. -

-
- - setForm((f) => ({ ...f, label: e.target.value }))} + {/* ── Inline create link ── */} + {showCreate && ( +
+

Create a new link

+
+ setNewLinkName(e.target.value)} + placeholder='e.g. "Ahmed", "Table 5", "WhatsApp Family"' + 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" /> +
-
-
- - setForm((f) => ({ ...f, tableName: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, volunteerName: e.target.value }))} - /> -
+
+ {["Table 1", "Table 2", "Table 3", "WhatsApp Group", "Instagram", "Email"].map(preset => ( + + ))}
-
- - + +
+ )} + + {/* ── Links ── */} + {sources.length === 0 ? ( +
+ +

No pledge links yet

+

Create a link and share it to start collecting pledges

+ +
+ ) : ( +
+
+

Pledge links ({sources.length})

+ {sources.length > 1 && ( + + Download all QR codes + + )} +
+ + {sortedSources.map((src, idx) => { + 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 ( +
+
+ {/* Header */} +
+
+ {sources.length > 1 && idx < 3 ? ( +
{idx + 1}
+ ) : sources.length > 1 ? ( +
{idx + 1}
+ ) : null} +
+

{src.label}

+ {src.volunteerName && src.volunteerName !== src.label && ( +

by {src.volunteerName}

+ )} +
+
+
+
+

{src.scanCount}

+

clicks

+
+
+

{src.pledgeCount}

+

pledges

+
+
+

{formatPence(src.totalPledged)}

+

raised

+
+
+
+ + {/* URL */} +
+

{url}

+ {src.scanCount > 0 && {conversion}% convert} +
+ + {/* Share buttons */} +
+ + + + +
+ + {/* Secondary actions */} +
+ + + + + + + + + + +
+ + {isQrOpen && ( +
+
+ +
+

Right-click or long-press to save

+
+ )} +
+
+ ) + })} +
+ )} + + {/* ── Embedded leaderboard ── */} + {sortedSources.filter(s => s.pledgeCount > 0).length >= 2 && ( +
+
+

+ Leaderboard +

+ + Full screen → + +
+
+ {sortedSources.filter(s => s.pledgeCount > 0).map((src, i) => { + const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"] + return ( +
+
+ {i + 1} +
+
+

{src.volunteerName || src.label}

+

{src.pledgeCount} pledges · {src.scanCount} clicks

+
+

{formatPence(src.totalPledged)}

+
+ ) + })}
-
+ )} + + {/* ── Appeal details (collapsed section) ── */} +
+ + Appeal details + +
+
+
+

Name

+

{event.name}

+
+ {event.eventDate && ( +
+

Date

+

{new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}

+
+ )} + {event.location && ( +
+

Location

+

{event.location}

+
+ )} + {event.goalAmount && ( +
+

Target

+

{formatPence(event.goalAmount)}

+
+ )} + {event.description && ( +
+

Description

+

{event.description}

+
+ )} + {event.zakatEligible && ( +
+

Zakat

+

Eligible

+
+ )} + {event.paymentMode === "external" && event.externalUrl && ( +
+

External page

+ {event.externalUrl} +
+ )} +
+ +
+ Public progress page → +
+
+
) } diff --git a/pledge-now-pay-later/src/app/dashboard/events/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/page.tsx index 87bb029..da8fb85 100644 --- a/pledge-now-pay-later/src/app/dashboard/events/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/events/page.tsx @@ -1,296 +1,5 @@ -"use client" - -import { useState, useEffect } from "react" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Textarea } from "@/components/ui/textarea" -import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { formatPence } from "@/lib/utils" -import { Plus, ExternalLink } from "lucide-react" -import Link from "next/link" - -interface EventSummary { - id: string; name: string; slug: string; eventDate: string | null - location: string | null; goalAmount: number | null; status: string - pledgeCount: number; qrSourceCount: number; totalPledged: number; totalCollected: number - paymentMode?: string; externalPlatform?: string -} - -const platformNames: Record = { - launchgood: "LaunchGood", enthuse: "Enthuse", justgiving: "JustGiving", - gofundme: "GoFundMe", other: "External", -} - -export default function CollectPage() { - const [events, setEvents] = useState([]) - const [loading, setLoading] = useState(true) - const [showCreate, setShowCreate] = useState(false) - const [creating, setCreating] = useState(false) - const [orgType, setOrgType] = useState(null) - const [form, setForm] = useState({ - name: "", description: "", location: "", eventDate: "", goalAmount: "", - paymentMode: "self" as "self" | "external", externalUrl: "", externalPlatform: "", zakatEligible: false, - }) - - useEffect(() => { - fetch("/api/events") - .then(r => r.json()) - .then(data => { if (Array.isArray(data)) setEvents(data) }) - .catch(() => {}) - .finally(() => setLoading(false)) - }, []) - - useEffect(() => { - fetch("/api/onboarding").then(r => r.json()).then(d => { - if (d.orgType) setOrgType(d.orgType) - if (d.orgType === "fundraiser") setForm(f => ({ ...f, paymentMode: "external" })) - }).catch(() => {}) - }, []) - - const handleCreate = async () => { - setCreating(true) - try { - const res = await fetch("/api/events", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: form.name, description: form.description || undefined, - location: form.location || undefined, - goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined, - eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined, - paymentMode: form.paymentMode, externalUrl: form.externalUrl || undefined, - externalPlatform: form.paymentMode === "external" ? (form.externalPlatform || "other") : undefined, - zakatEligible: form.zakatEligible, - }), - }) - if (res.ok) { - const event = await res.json() - setEvents(prev => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev]) - setShowCreate(false) - setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "", zakatEligible: false }) - } - } catch { /* */ } - setCreating(false) - } - - return ( -
-
-
-

Collect

-

Create appeals, share pledge links, and track who's pledged

-
- -
- - {/* Appeal cards — brand style: gap-px grid on desktop, stacked on mobile */} - {loading ? ( -
Loading appeals...
- ) : events.length === 0 ? ( -
-

01

-

Create your first appeal

-

- An appeal is a collection — your gala dinner, Ramadan campaign, mosque fund, or any cause you're raising for. -

- -
- ) : ( -
- {events.map(event => { - const progress = event.goalAmount ? Math.min(100, Math.round((event.totalPledged / event.goalAmount) * 100)) : 0 - return ( -
-
- {/* Header */} -
-
-

{event.name}

-
- {event.eventDate && {new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}} - {event.location && {event.location}} -
-
-
- {event.paymentMode === "external" && event.externalPlatform && ( - - {platformNames[event.externalPlatform] || "External"} - - )} - - {event.status === "active" ? "Live" : event.status} - -
-
- - {/* Stats — gap-px grid */} -
-
-

{event.pledgeCount}

-

Pledges

-
-
-

{formatPence(event.totalPledged)}

-

Promised

-
-
-

{formatPence(event.totalCollected)}

-

Received

-
-
- - {/* Goal bar */} - {event.goalAmount && ( -
-
- {progress}% of target - {formatPence(event.goalAmount)} -
-
-
-
-
- )} - - {/* Actions */} -
- - - - - - -
-
-
- ) - })} -
- )} - - {/* Create dialog */} - - - New Appeal - -
-
- - setForm(f => ({ ...f, name: e.target.value }))} - /> -
-
- -