diff --git a/pledge-now-pay-later/screenshots/dashboard-automations.png b/pledge-now-pay-later/screenshots/dashboard-automations.png new file mode 100644 index 0000000..3b7243e Binary files /dev/null and b/pledge-now-pay-later/screenshots/dashboard-automations.png differ diff --git a/pledge-now-pay-later/screenshots/dashboard-home.png b/pledge-now-pay-later/screenshots/dashboard-home.png new file mode 100644 index 0000000..d221717 Binary files /dev/null and b/pledge-now-pay-later/screenshots/dashboard-home.png differ diff --git a/pledge-now-pay-later/screenshots/dashboard-money-scroll.png b/pledge-now-pay-later/screenshots/dashboard-money-scroll.png new file mode 100644 index 0000000..4db30cd Binary files /dev/null and b/pledge-now-pay-later/screenshots/dashboard-money-scroll.png differ diff --git a/pledge-now-pay-later/screenshots/dashboard-money.png b/pledge-now-pay-later/screenshots/dashboard-money.png new file mode 100644 index 0000000..ad1d22f Binary files /dev/null and b/pledge-now-pay-later/screenshots/dashboard-money.png differ diff --git a/pledge-now-pay-later/screenshots/dashboard-reports.png b/pledge-now-pay-later/screenshots/dashboard-reports.png new file mode 100644 index 0000000..eeccf4b Binary files /dev/null and b/pledge-now-pay-later/screenshots/dashboard-reports.png differ diff --git a/pledge-now-pay-later/screenshots/dashboard-settings.png b/pledge-now-pay-later/screenshots/dashboard-settings.png new file mode 100644 index 0000000..23e5f82 Binary files /dev/null and b/pledge-now-pay-later/screenshots/dashboard-settings.png differ 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 e296833..a914412 100644 --- a/pledge-now-pay-later/src/app/dashboard/collect/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/collect/page.tsx @@ -1,11 +1,12 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect } from "react" import { formatPence } from "@/lib/utils" import { Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail, - Download, ExternalLink, Users, Trophy, Link2, - ArrowRight, QrCode as QrCodeIcon + Download, Trophy, Link2, + ArrowRight, QrCode as QrCodeIcon, Code2, + Building2, CreditCard, Globe } from "lucide-react" import Image from "next/image" import Link from "next/link" @@ -14,20 +15,22 @@ import { QRCodeCanvas } from "@/components/qr-code" /** * /dashboard/collect — "I want to share my link" * - * This page is redesigned around ONE insight: - * The primary object is the LINK, not the appeal. + * FUNDAMENTAL REDESIGN: * - * For single-appeal orgs (90%): - * Links are shown directly. No appeal card to click through. - * The appeal is just a quiet context header. + * The primary object is the LINK. Not the appeal. * - * For multi-appeal orgs: - * An appeal selector at the top. Links below it. + * Creating a link = one flow that handles: + * 1. What you're raising for (appeal — created behind the scenes) + * 2. How donors pay (bank / external platform / card) + * 3. Link name * - * 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?" + * Every link card shows: + * - Payment method badge (so the user always knows how THIS link works) + * - 3 sharing tabs: Link, QR Code, Website Widget + * - Stats + * + * The widget is NOT a separate concept — it's just another way to share + * the same link. Copy the embed code, paste on your website. */ interface EventSummary { @@ -43,185 +46,484 @@ interface SourceInfo { scanCount: number; pledgeCount: number; totalPledged: number; totalCollected?: number } +const PLATFORM_LABELS: Record = { + justgiving: "JustGiving", launchgood: "LaunchGood", gofundme: "GoFundMe", + enthuse: "Enthuse", other: "External link" +} + 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) + const [copiedEmbed, setCopiedEmbed] = useState(null) + const [expandedLink, setExpandedLink] = useState(null) + const [shareTab, setShareTab] = useState>({}) - // Inline create - const [newLinkName, setNewLinkName] = useState("") - const [creating, setCreating] = useState(false) + // Unified creation flow const [showCreate, setShowCreate] = useState(false) + const [createStep, setCreateStep] = useState<"name" | "payment" | "link">("name") + const [newAppealName, setNewAppealName] = useState("") + const [newPaymentMode, setNewPaymentMode] = useState<"bank" | "external" | "card">("bank") + const [newPlatform, setNewPlatform] = useState("") + const [newExternalUrl, setNewExternalUrl] = useState("") + const [newLinkName, setNewLinkName] = useState("Main link") + const [creating, setCreating] = useState(false) - // New appeal - const [showNewAppeal, setShowNewAppeal] = useState(false) - const [appealName, setAppealName] = useState("") - const [appealDate, setAppealDate] = useState("") - const [appealTarget, setAppealTarget] = useState("") - const [creatingAppeal, setCreatingAppeal] = useState(false) + // Quick add link (to existing appeal) + const [showQuickAdd, setShowQuickAdd] = useState(false) + const [quickLinkName, setQuickLinkName] = useState("") + const [quickCreating, setQuickCreating] = useState(false) + + const [hasStripe, setHasStripe] = useState(false) const baseUrl = typeof window !== "undefined" ? window.location.origin : "" - // Load events + // Load everything 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)) + Promise.all([ + fetch("/api/events").then(r => r.json()), + fetch("/api/settings").then(r => r.json()).catch(() => ({})), + ]).then(([evData, settingsData]) => { + if (Array.isArray(evData)) { + setEvents(evData) + // Load sources for all events + const allSourcePromises = evData.map((ev: EventSummary) => + fetch(`/api/events/${ev.id}/qr`).then(r => r.json()).catch(() => []) + ) + Promise.all(allSourcePromises).then(results => { + const allSources: (SourceInfo & { _eventId: string; _eventName: string; _paymentMode: string; _externalPlatform: string | null })[] = [] + results.forEach((srcs, i) => { + if (Array.isArray(srcs)) { + srcs.forEach((s: SourceInfo) => { + allSources.push({ + ...s, + _eventId: evData[i].id, + _eventName: evData[i].name, + _paymentMode: evData[i].paymentMode || "self", + _externalPlatform: evData[i].externalPlatform || null, + }) + }) + } + }) + setSources(allSources) + }) + } + if (settingsData.stripeSecretKey) setHasStripe(true) + }).catch(() => {}).finally(() => setLoading(false)) }, []) - // 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) + const activeEvent = events[0] // Most orgs have one appeal // ── 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) + setCopiedCode(code); setTimeout(() => setCopiedCode(null), 2000) + } + const copyEmbed = async (code: string) => { + const snippet = `` + await navigator.clipboard.writeText(snippet) + setCopiedEmbed(code); setTimeout(() => setCopiedEmbed(null), 2000) } - const shareWhatsApp = (code: string, label: string) => { window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge here 🤲\n\n${label}\n${baseUrl}/p/${code}`)}`, "_blank") } - const shareEmail = (code: string, label: string) => { window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${label}`)}&body=${encodeURIComponent(`Please pledge here:\n\n${baseUrl}/p/${code}`)}`) } - const shareNative = (code: string, label: string) => { - const url = `${baseUrl}/p/${code}` - if (navigator.share) navigator.share({ title: label, text: `Pledge here: ${url}`, url }) + if (navigator.share) navigator.share({ title: label, text: `Pledge here: ${baseUrl}/p/${code}`, url: `${baseUrl}/p/${code}` }) else copyLink(code) } - // Inline link create — just type a name and go - const createLink = async () => { - if (!newLinkName.trim() || !activeEventId) return + // Unified create: makes appeal + first link in one go + const handleCreate = async () => { + if (!newAppealName.trim()) 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() }), + // Create the appeal + const appealRes = await fetch("/api/events", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: newAppealName.trim(), + paymentMode: newPaymentMode === "card" ? "self" : newPaymentMode === "external" ? "external" : "self", + externalPlatform: newPaymentMode === "external" ? (newPlatform || "other") : undefined, + externalUrl: newPaymentMode === "external" ? newExternalUrl : undefined, + }), }) - if (res.ok) { - const src = await res.json() - setSources(prev => [{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev]) - setNewLinkName("") - setShowCreate(false) + if (!appealRes.ok) throw new Error("Failed") + const appeal = await appealRes.json() + + // Create the first link + const linkRes = await fetch(`/api/events/${appeal.id}/qr`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: newLinkName.trim() || "Main link" }), + }) + if (linkRes.ok) { + const src = await linkRes.json() + const newEvent = { ...appeal, pledgeCount: 0, qrSourceCount: 1, totalPledged: 0, totalCollected: 0, paymentMode: appeal.paymentMode, externalPlatform: appeal.externalPlatform, externalUrl: appeal.externalUrl } + setEvents(prev => [newEvent, ...prev]) + const newSource = { + ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0, + _eventId: appeal.id, _eventName: appeal.name, + _paymentMode: appeal.paymentMode || "self", + _externalPlatform: appeal.externalPlatform || null, + } + setSources(prev => [newSource, ...prev]) + setExpandedLink(src.code) } + + // Reset + setShowCreate(false); setCreateStep("name") + setNewAppealName(""); setNewPaymentMode("bank"); setNewPlatform(""); setNewExternalUrl(""); setNewLinkName("Main link") } catch { /* */ } setCreating(false) } - // Create new appeal - const createAppeal = async () => { - if (!appealName.trim()) return - setCreatingAppeal(true) + // Quick add link to existing appeal + const handleQuickAdd = async () => { + if (!quickLinkName.trim() || !activeEvent) return + setQuickCreating(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, - }), + const res = await fetch(`/api/events/${activeEvent.id}/qr`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: quickLinkName.trim() }), }) if (res.ok) { - const 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 }]) + const src = await res.json() + const newSource = { + ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0, + _eventId: activeEvent.id, _eventName: activeEvent.name, + _paymentMode: activeEvent.paymentMode || "self", + _externalPlatform: activeEvent.externalPlatform || null, } + setSources(prev => [newSource, ...prev]) + setExpandedLink(src.code) + setShowQuickAdd(false); setQuickLinkName("") } } catch { /* */ } - setCreatingAppeal(false) + setQuickCreating(false) } - // Sort sources: most pledges first - const sortedSources = [...sources].sort((a, b) => b.totalPledged - a.totalPledged) + const getShareTab = (code: string) => shareTab[code] || "link" + const setShareTabFor = (code: string, tab: "link" | "qr" | "embed") => setShareTab(prev => ({ ...prev, [code]: tab })) - // ── Loading ── - if (loading) { - return
- } + // Stats + const totalPledges = events.reduce((s, e) => s + e.pledgeCount, 0) + const totalPledged = events.reduce((s, e) => s + e.totalPledged, 0) + // Sort sources by pledges + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sortedSources = [...sources].sort((a: any, b: any) => b.totalPledged - a.totalPledged) - // ── No events yet ── - if (events.length === 0) { + if (loading) return
+ + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // EMPTY STATE — Guided first-time setup + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + if (events.length === 0 || showCreate) { 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)} - />} + {/* Step indicator */} +
+ {["What you're raising for", "How donors pay", "Your link"].map((label, i) => { + const steps = ["name", "payment", "link"] as const + const isActive = steps[i] === createStep + const isDone = steps.indexOf(createStep) > i + return ( +
+ {i > 0 &&
} +
+ {isDone ? : {i + 1}} + {label} +
+
+ ) + })} + {events.length > 0 && ( + + )} +
+ + {/* Step 1: What are you raising for? */} + {createStep === "name" && ( +
+
+

What are you raising for?

+

This could be a gala dinner, Ramadan appeal, building fund, or any cause.

+
+
+ setNewAppealName(e.target.value)} + placeholder="e.g. Ramadan Gala Dinner 2026" + autoFocus + onKeyDown={e => e.key === "Enter" && newAppealName.trim() && setCreateStep("payment")} + className="w-full h-14 px-4 border-2 border-gray-200 text-lg placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all" + /> +
+ +
+ )} + + {/* Step 2: How will donors pay? */} + {createStep === "payment" && ( +
+
+

How will donors pay?

+

+ We capture the pledge (name, phone, amount, Gift Aid) for all three options. + This is how you tell donors to actually send the money. +

+
+ +
+ {/* Bank transfer */} + + + {/* External platform */} + + + {/* Card payment */} + +
+ + {/* External platform details */} + {newPaymentMode === "external" && ( +
+
+ +
+ {[ + { value: "justgiving", label: "JustGiving" }, + { value: "launchgood", label: "LaunchGood" }, + { value: "gofundme", label: "GoFundMe" }, + { value: "enthuse", label: "Enthuse" }, + { value: "other", label: "Other" }, + ].map(p => ( + + ))} +
+
+
+ + setNewExternalUrl(e.target.value)} + placeholder="https://www.justgiving.com/your-page" + className="w-full h-10 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" + /> +

Donors are sent to this link after pledging.

+
+
+ )} + +
+ + +
+
+ )} + + {/* Step 3: Name your link */} + {createStep === "link" && ( +
+
+

Name your link

+

+ You can create multiple links — one per table, per volunteer, or per WhatsApp group. + Start with a main link and add more later. +

+
+ + {/* Summary of what they've set up */} +
+
+ Raising for + {newAppealName} +
+
+ Donors pay via + + {newPaymentMode === "bank" ? "Bank transfer" : + newPaymentMode === "external" ? PLATFORM_LABELS[newPlatform] || "External link" : + "Card (Stripe)"} + +
+
+ +
+ setNewLinkName(e.target.value)} + placeholder="e.g. Main link, Table 5, Imam Yusuf" + autoFocus + onKeyDown={e => e.key === "Enter" && handleCreate()} + className="w-full h-14 px-4 border-2 border-gray-200 text-lg placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" + /> +
+ +
+ + +
+
+ )} +
+ + {/* RIGHT COLUMN — Education */} +
+
+
+

How it works

+
+
+ {[ + { n: "01", title: "You create a pledge link", desc: "Give it a name and choose how donors pay. Print the QR, share the link, or embed on your website." }, + { n: "02", title: "Donors pledge in 60 seconds", desc: "They enter name, phone, amount, and Gift Aid. No app, no account, no friction." }, + { n: "03", title: "We tell them how to pay", desc: "Bank details, external link, or card checkout — based on what you chose. Plus a unique reference to track it." }, + { n: "04", title: "Reminders go out automatically", desc: "WhatsApp messages on day 2, 7, and 14. With a link to pay. Stops when they do." }, + { n: "05", title: "You match payments", desc: "Upload your bank statement. We auto-match. Or payments confirm instantly via Stripe/external." }, + ].map(s => ( +
+ {s.n} +
+

{s.title}

+

{s.desc}

+
+
+ ))} +
+
+ + {/* Payment method comparison */} +
+

Which payment method should I choose?

+
+
+

Bank transfer

+

Best for most charities. No fees. Donors transfer to your account directly. Use the bank statement upload to match payments.

+
+
+

JustGiving / LaunchGood

+

Already have a fundraising page? We capture the pledge and send donors to your page to pay. You get pledge tracking + reminders for free.

+
+
+

Card payment (Stripe)

+

Instant payment. Best conversion rate. Stripe charges 1.4% + 20p per transaction. Connect in Settings first.

+
+
+
+ + {/* Can I mix? */} +
+

Can I mix payment methods?

+

+ Yes. Create a separate appeal for each payment method. For example: "Ramadan — Bank Transfer" and "Ramadan — JustGiving". + Each gets its own pledge links. +

+
+
+
) } + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // HAS DATA — Links are the primary display + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ return (
- - {/* ━━ HERO — Brand photography + context ━━━━━━━━━━━━━━━━━━━ */} + {/* Hero */}

Collect

-

- Share your link, collect pledges -

-

- {activeEvent ? `${sources.length} link${sources.length !== 1 ? "s" : ""} · ${activeEvent.pledgeCount} pledges · ${formatPence(activeEvent.totalPledged)} raised` : "Create an appeal and start collecting"} -

+
+
+

{sources.length}

+

{sources.length === 1 ? "pledge link" : "pledge links"}

+
+
+

{totalPledges}

+

pledges

+
+
+

{formatPence(totalPledged)}

+

raised

+
+
- {/* ── Content with padding ── */} + {/* Content */}
- {/* ── Appeals as visible cards (not hidden in a dropdown) ── */} - {events.length > 1 && ( -
-
-

Your appeals ({events.length})

- -
-
- {events.map(ev => { - const isSelected = ev.id === activeEventId - const rate = ev.totalPledged > 0 ? Math.round((ev.totalCollected / ev.totalPledged) * 100) : 0 - const platformLabel = ev.externalPlatform - ? ev.externalPlatform.charAt(0).toUpperCase() + ev.externalPlatform.slice(1) - : ev.paymentMode === "self" ? "Bank transfer" : "Bank transfer" - return ( - - ) - })} -
-
- )} - - {/* Single appeal: show name + new appeal button */} - {events.length === 1 && ( -
-

{activeEvent?.name}

- -
- )} - - {/* ── Appeal stats + payment method clarity ── */} - {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}

-
- ))} -
- {/* Payment method indicator — so users know how their platform works */} - {activeEvent.externalPlatform && activeEvent.externalUrl && ( -
- -

- Donors are redirected to {activeEvent.externalPlatform.charAt(0).toUpperCase() + activeEvent.externalPlatform.slice(1)} to pay -

-
- )} -
- )} - - {/* ── New Link button ── */} -
- -
- - {/* ── 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. -

+ {/* Action bar */} +
+

Your pledge links

- 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 → + {/* Quick add link inline */} + {showQuickAdd && ( +
+
+ + setQuickLinkName(e.target.value)} + placeholder="e.g. Table 5, Imam Yusuf, WhatsApp group" + autoFocus onKeyDown={e => e.key === "Enter" && handleQuickAdd()} + className="w-full h-10 px-3 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none" + /> +
+ +
-
- {sortedSources.filter(s => s.pledgeCount > 0).slice(0, 5).map((src, i) => { - const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"] + )} + + {/* TWO-COLUMN: Links left, Education right */} +
+ + {/* LEFT: Link cards */} +
+ {sortedSources.map((src) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = src as any + const url = `${baseUrl}/p/${src.code}` + const isCopied = copiedCode === src.code + const isEmbedCopied = copiedEmbed === src.code + const isExpanded = expandedLink === src.code + const conversion = src.scanCount > 0 ? Math.round((src.pledgeCount / src.scanCount) * 100) : 0 + const tab = getShareTab(src.code) + const payMode = s._paymentMode || "self" + const platform = s._externalPlatform + const eventName = s._eventName + return ( -
-
- {i + 1} -
-
-

{src.volunteerName || src.label}

-

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

-
-

{formatPence(src.totalPledged)}

+
+ {/* Header row */} + + + {/* Expanded: Share section */} + {isExpanded && ( +
+ {/* Share tabs */} +
+ {[ + { key: "link" as const, label: "Link", icon: Link2 }, + { key: "qr" as const, label: "QR Code", icon: QrCodeIcon }, + { key: "embed" as const, label: "Website Widget", icon: Code2 }, + ].map(t => ( + + ))} +
+ + {/* Tab content */} + {tab === "link" && ( +
+

{url}

+
+ + + + +
+
+ )} + + {tab === "qr" && ( +
+
+ +
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + Download QR + +

Print on table cards, flyers, or banners

+
+ )} + + {tab === "embed" && ( +
+

Add the pledge form to your website. Copy this code and paste it into your HTML:

+
+ {``} +
+ +
+

Works on any website — WordPress, Squarespace, Wix, custom HTML.

+

The form adapts to mobile. Donors never leave your site.

+
+
+ )} + + {/* Payment method note for external */} + {payMode === "external" && platform && ( +
+ +

+ After pledging, donors are sent to {PLATFORM_LABELS[platform] || "your page"} to pay +

+
+ )} +
+ )}
) })}
-
- )} - {/* ── How it works — landing page style education ── */} -
- {/* Tips */} -
-

Where to share your link

-
- {[ - { n: "01", text: "Print the QR code on each table at your event" }, - { n: "02", text: "Send the link to WhatsApp groups — one tap to pledge" }, - { n: "03", text: "Post it on Instagram or Facebook stories" }, - { n: "04", text: "Give each volunteer their own link — friendly competition works" }, - { n: "05", text: "Add it to your email newsletter or website" }, - ].map(t => ( -
- {t.n} -

{t.text}

-
- ))} -
-
- - {/* How platforms work */} -
-

How payment works

-

- When someone pledges, they see your payment details with a unique reference. - Depending on how you set up your appeal: -

-
- {[ - { label: "Bank transfer", desc: "Donor sees your sort code and account number" }, - { label: "JustGiving / LaunchGood", desc: "Donor is redirected to your fundraising page" }, - { label: "Card payment", desc: "Donor pays by Visa, Mastercard, or Apple Pay via Stripe" }, - ].map(p => ( -
- -
-

{p.label}

-

{p.desc}

+ {/* RIGHT: Education */} +
+ {/* Leaderboard */} + {sortedSources.filter(s => s.pledgeCount > 0).length >= 3 && ( +
+
+

+ Who's collecting the most +

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

+
+

{formatPence(src.totalPledged)}

+
+ ) + })}
- ))} + )} + + {/* Where to share */} +
+

Where to share your link

+
+ {[ + { n: "01", text: "Print the QR code on each table at your event" }, + { n: "02", text: "Send the link to WhatsApp groups — one tap to pledge" }, + { n: "03", text: "Post it on Instagram or Facebook stories" }, + { n: "04", text: "Embed the widget on your mosque or charity website" }, + { n: "05", text: "Give each volunteer their own link — competition works" }, + ].map(t => ( +
+ {t.n} +

{t.text}

+
+ ))} +
+
+ + {/* Payment methods */} +
+

How payment works

+

+ Every pledge link captures the donor's details first. What happens next depends on how you set up the appeal: +

+
+ {[ + { icon: Building2, label: "Bank transfer", desc: "Donor sees your sort code and account number with a unique reference" }, + { icon: Globe, label: "External platform", desc: "Donor is sent to JustGiving, LaunchGood, or your fundraising page to pay" }, + { icon: CreditCard, label: "Card payment", desc: "Donor pays instantly by Visa/Mastercard. Money goes to your Stripe account" }, + ].map(p => ( +
+ +
+

{p.label}

+

{p.desc}

+
+
+ ))} +
+
+ + {/* What's an appeal? */} + {events.length > 0 && ( +
+

What's an appeal?

+

+ An appeal is your fundraiser — one dinner, one campaign, one cause. + All links within an appeal share the same payment method. + Most organisations only need one appeal with several links. +

+ {events.length > 1 && ( +
+

Your appeals

+ {events.map(ev => ( +
+ {ev.name} + {ev.paymentMode === "external" + ? PLATFORM_LABELS[ev.externalPlatform || ""] || "External" + : "Bank transfer"} · {ev.pledgeCount} pledges +
+ ))} +
+ )} +
+ )}
-

- Set your payment method when creating an appeal. You can change it anytime. -

- - {/* ── 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/temp_files/fix2/AdminPanelProvider.php b/temp_files/fix2/AdminPanelProvider.php new file mode 100644 index 0000000..c84a891 --- /dev/null +++ b/temp_files/fix2/AdminPanelProvider.php @@ -0,0 +1,93 @@ +default() + ->id('admin') + ->path('admin') + ->login() + ->colors(['primary' => config('branding.colours')]) + ->viteTheme('resources/css/filament/admin/theme.css') + ->sidebarCollapsibleOnDesktop() + ->sidebarWidth('16rem') + ->globalSearch(true) + ->globalSearchKeyBindings(['command+k', 'ctrl+k']) + ->globalSearchDebounce('300ms') + ->navigationGroups([ + // ── Daily Work (always visible, top of sidebar) ── + NavigationGroup::make('Daily') + ->collapsible(false), + + // ── Giving (30 Nights, 10 Days, Night of Power) ── + NavigationGroup::make('Giving') + ->icon('heroicon-o-calendar-days') + ->collapsible(), + + // ── Fundraising (appeals, review queue) ── + NavigationGroup::make('Fundraising') + ->icon('heroicon-o-megaphone') + ->collapsible(), + + // ── Setup (rarely touched config) ── + NavigationGroup::make('Setup') + ->icon('heroicon-o-cog-6-tooth') + ->collapsible() + ->collapsed(), + ]) + ->brandLogo(Helpers::getCurrentLogo(true)) + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') + ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') + ->pages([\Filament\Pages\Dashboard::class]) + ->userMenuItems([ + 'profile' => MenuItem::make() + ->label('Edit profile') + ->url(url('user/profile')), + + 'back2site' => MenuItem::make() + ->label('Return to site') + ->icon('heroicon-o-home') + ->url(url('/')), + ]) + ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]) + ->login(null) + ->registration(null) + ->darkMode(false) + ->databaseNotifications(); + } +} diff --git a/temp_files/fix2/ListScheduledGivingDonations.php b/temp_files/fix2/ListScheduledGivingDonations.php new file mode 100644 index 0000000..6415c4e --- /dev/null +++ b/temp_files/fix2/ListScheduledGivingDonations.php @@ -0,0 +1,164 @@ +currentSeasonScope()->count(); + return "{$current} subscribers this season."; + } + + /** Real subscriber: has customer, has payments, amount > 0, not soft-deleted */ + private function realScope(): Builder + { + return ScheduledGivingDonation::query() + ->whereNotNull('customer_id') + ->where('total_amount', '>', 0) + ->whereNull('scheduled_giving_donations.deleted_at') + ->whereHas('payments', fn ($q) => $q->whereNull('deleted_at')); + } + + /** Current season: real + has at least one future payment */ + private function currentSeasonScope(): Builder + { + return $this->realScope() + ->whereHas('payments', fn ($q) => $q + ->whereNull('deleted_at') + ->where('expected_at', '>', now())); + } + + /** Applies real + current season filters to the query */ + private function applyCurrentSeason(Builder $q): Builder + { + return $q + ->whereNotNull('customer_id') + ->where('total_amount', '>', 0) + ->whereNull('scheduled_giving_donations.deleted_at') + ->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at')) + ->whereHas('payments', fn ($sub) => $sub + ->whereNull('deleted_at') + ->where('expected_at', '>', now())); + } + + /** Applies real + expired (no future payments) filters */ + private function applyExpired(Builder $q): Builder + { + return $q + ->whereNotNull('customer_id') + ->where('total_amount', '>', 0) + ->whereNull('scheduled_giving_donations.deleted_at') + ->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at')) + ->whereDoesntHave('payments', fn ($sub) => $sub + ->whereNull('deleted_at') + ->where('expected_at', '>', now())); + } + + /** Applies real subscriber filters */ + private function applyReal(Builder $q): Builder + { + return $q + ->whereNotNull('customer_id') + ->where('total_amount', '>', 0) + ->whereNull('scheduled_giving_donations.deleted_at') + ->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at')); + } + + public function getTabs(): array + { + $campaigns = ScheduledGivingCampaign::all(); + + $currentCount = $this->currentSeasonScope()->count(); + + $tabs = []; + + // Current season — the primary tab + $tabs['current'] = Tab::make('This Season') + ->icon('heroicon-o-sun') + ->badge($currentCount) + ->badgeColor('success') + ->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)); + + // Per-campaign tabs for current season + foreach ($campaigns as $c) { + $slug = str($c->title)->slug()->toString(); + $count = $this->currentSeasonScope() + ->where('scheduled_giving_campaign_id', $c->id) + ->count(); + + if ($count === 0) continue; // Skip campaigns with no current subscribers + + $tabs[$slug] = Tab::make($c->title) + ->icon('heroicon-o-calendar') + ->badge($count) + ->badgeColor('primary') + ->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q) + ->where('scheduled_giving_campaign_id', $c->id)); + } + + // Failed (current season only) + $failedCount = $this->currentSeasonScope() + ->whereHas('payments', fn ($q) => $q + ->where('is_paid', false) + ->where('attempts', '>', 0) + ->whereNull('deleted_at')) + ->count(); + + if ($failedCount > 0) { + $tabs['failed'] = Tab::make('Failed') + ->icon('heroicon-o-exclamation-triangle') + ->badge($failedCount) + ->badgeColor('danger') + ->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q) + ->whereHas('payments', fn ($sub) => $sub + ->where('is_paid', false) + ->where('attempts', '>', 0) + ->whereNull('deleted_at'))); + } + + // Past seasons + $expiredCount = $this->realScope() + ->whereDoesntHave('payments', fn ($q) => $q + ->whereNull('deleted_at') + ->where('expected_at', '>', now())) + ->count(); + + $tabs['past'] = Tab::make('Past Seasons') + ->icon('heroicon-o-archive-box') + ->badge($expiredCount > 0 ? $expiredCount : null) + ->badgeColor('gray') + ->modifyQueryUsing(fn (Builder $q) => $this->applyExpired($q)); + + // All real + $tabs['all'] = Tab::make('All') + ->icon('heroicon-o-squares-2x2') + ->modifyQueryUsing(fn (Builder $q) => $this->applyReal($q)); + + return $tabs; + } + + protected function getHeaderActions(): array + { + return []; + } + + public function getDefaultActiveTab(): string|int|null + { + return 'current'; + } +} diff --git a/temp_files/fix2/ScheduledGivingCampaignResource.php b/temp_files/fix2/ScheduledGivingCampaignResource.php new file mode 100644 index 0000000..5c6ad3c --- /dev/null +++ b/temp_files/fix2/ScheduledGivingCampaignResource.php @@ -0,0 +1,365 @@ +schema([ + Toggle::make('active'), + Section::make('Info')->schema([ + TextInput::make('title') + ->required() + ->maxLength(255) + ->live() + ->debounce(1000) + ->afterStateUpdated(function (\Filament\Forms\Get $get, \Filament\Forms\Set $set) { + $set('slug', Str::slug($get('title'))); + }) + ->placeholder('30 Nights of Giving'), + + TextInput::make('slug') + ->required() + ->maxLength(255) + ->placeholder('30-nights-of-giving'), + + FileUpload::make('logo_image') + ->image() + ->helperText('Leave blank to use Charity Right logo.'), + + Grid::make()->schema([ + ColorPicker::make('primary_colour') + ->label('Primary Colour') + ->hex() + ->placeholder('#E42281'), + + ColorPicker::make('text_colour') + ->label('Text Colour') + ->hex() + ->placeholder('#000000'), + ])->columns(1)->columnSpan(1), + + TextInput::make('minimum_donation') + ->required() + ->label('Minimum Donation Amount') + ->prefix('£') + ->numeric() + ->default(30.0), + + Toggle::make('catch_up_payments') + ->required() + ->label('Should we attempt to take out missed payments if a subscription is made during the dates?'), + + Toggle::make('accepts_appeals') + ->required() + ->label('Can donors set up subscriptions against appeals via this campaign?'), + + Select::make('split_types') + ->label('Use-able Split Options') + ->required() + ->multiple() + ->options([ + ScheduledGivingSplitTypeFlag::EQUAL => 'Equal', + ScheduledGivingSplitTypeFlag::DOUBLE_ON_ODD => 'Double on odd', + ScheduledGivingSplitTypeFlag::DOUBLE_ON_EVEN => 'Double on even', + ScheduledGivingSplitTypeFlag::DOUBLE_ON_FRIDAYS => 'Double on Fridays', + ScheduledGivingSplitTypeFlag::DOUBLE_ON_LAST_10_NIGHTS => 'Double on last 10', + ScheduledGivingSplitTypeFlag::DOUBLE_ON27TH_NIGHT => 'Double on 27th', + ScheduledGivingSplitTypeFlag::DOUBLE_ON_DAY_OF_ARAFAH => 'Double on 9th', + ]), + ]) + ->columns(), + + Section::make('Date Configuration')->collapsible()->schema([ + Select::make('date_config_type') + ->options([ + -1 => 'Custom Configuration', + DefaultCalendarSchedule::RAMADAN_DAYS => 'Ramadan Days', + DefaultCalendarSchedule::RAMADAN_NIGHTS => 'Ramadan Nights', + DefaultCalendarSchedule::DHUL_HIJJAH_DAYS => 'Dhul Hijjah Days', + DefaultCalendarSchedule::DHUL_HIJJAH_NIGHTS => 'Dhul Hijjah Nights', + DefaultCalendarSchedule::RAMADAN_NIGHTS_LAST_10 => 'Ramadan Nights (Last 10)', + DefaultCalendarSchedule::DHUL_HIJJAH_DAYS_FIRST_10 => 'Dhul Hijjah Days (First 10)', + ]) + ->label('Builtin Date Configuration') + ->helperText('Select the built-in date configuration - based on previous campaigns - or choose \'Custom Configuration\' to set up the timings manually. Please note it may take some time to generate the dates when selecting anything other than Custom.') + ->live() + ->afterStateUpdated(function ($old, \Filament\Forms\Get $get, \Filament\Forms\Set $set) { + $configType = (int) $get('date_config_type'); + + if ($configType === -1) { + return; + } + + $data = app(ScheduleGeneratorService::class)->generate($configType); + $set('dates', $data); // This becomes VERY heavy when we are doing it reactively. + // I don't know why, but it seems to fry the server when it passes the data into the repeater. + // Let's just use it when creating the resource and indicate to the user to wait. + }) + ->visibleOn('create'), + + Repeater::make('dates') + ->label('Dates') + ->schema([ + Select::make('month') + ->options(self::formatHijriMonths()) + ->live(), + + Select::make('day') + ->visible(fn (\Filament\Forms\Get $get) => $get('month')) + ->live() + ->options(fn (\Filament\Forms\Get $get) => static::formatHijriDays($get('month'))), + + Select::make('timing') + ->options([ + ScheduledGivingTime::DAYTIME => 'Daytime (GMT) / 10:00am', + ScheduledGivingTime::NIGHTTIME => 'Nighttime (GMT) / 10:00pm', + ScheduledGivingTime::CUSTOM => 'Custom', + ]) + ->required() + ->live() + ->visible(fn (\Filament\Forms\Get $get) => $get('day')) + ->required(fn (\Filament\Forms\Get $get) => $get('day')), + + TimePicker::make('timing_custom') + ->visible(fn (\Filament\Forms\Get $get) => $get('timing') == 2) + ->required(fn (\Filament\Forms\Get $get) => $get('timing') == 2) + ->live() + ->columnSpanFull(), + ]) + ->grid(2) + ->columns(2) + ->columnSpanFull() + ->visible(fn (\Filament\Forms\Get $get) => (bool) $get('date_config_type')) + ->live() + ->helperText('Select the dates to use for this campaign. Ordering does not matter as it will be chronologically ordered once you save this page.'), + ]) + ->columns(1), + + Section::make('Allocations')->collapsible()->schema([ + Repeater::make('allocation_types')->schema([ + Select::make('donation_type_id') + ->label('Donation Item') + ->options(function (\Filament\Forms\Get $get) { + $donationTypes = []; + $firstDonationCountryId = static::donationCountries()->first()->id; + + if (($countryTypeId = $get('country_type_id')) && ($countryTypeId != $firstDonationCountryId)) { + $donationTypes = DonationCountry::find($countryTypeId) + ?->donationTypes() + ->where('is_scheduling', true) + ->get() ?? collect([]); + } else { + $donationTypes = static::donationTypes(); + } + + return $donationTypes->mapWithKeys( + fn ($record) => [$record->id => $record->display_name] + )->toArray(); + }) + ->required() + ->live(), + + Select::make('country_type_id') + ->label('Country') + ->options(function (\Filament\Forms\Get $get) { + $donationCountries = []; + $firstDonationTypeId = static::donationTypes()->first()->id; + + if (($donationTypeId = $get('donation_type_id')) && ($donationTypeId != $firstDonationTypeId)) { + $donationCountries = DonationType::find($donationTypeId) + ?->donationCountries ?? collect([]); + } else { + $donationCountries = DonationCountry::all(); + } + + return $donationCountries->mapWithKeys( + fn ($record) => [$record->id => $record->name] + )->toArray(); + }) + ->required() + ->live(), + + TextInput::make('title') + ->placeholder('Hunger After Eid') + ->required() + ->columnSpanFull(), + + TextInput::make('description') + ->placeholder('Make a contribution towards causes with the greatest need, including emergencies.') + ->required() + ->columnSpanFull(), + ]) + ->columns() + ->helperText('Set up the possible allocation types for this donation.'), + ]), + + Section::make('Page Metadata')->schema([ + TextInput::make('meta_title') + ->label('Title') + ->maxLength(512) + ->placeholder(fn (\Filament\Forms\Get $get) => $get('title')), + + Textarea::make('meta_description') + ->label('Description') + ->columnSpanFull(), + + TextInput::make('meta_keywords') + ->label('Keywords') + ->maxLength(512), + ]) + ->collapsible(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + ToggleColumn::make('active'), + + TextColumn::make('user.name') + ->label('Creator') + ->numeric() + ->sortable() + ->toggleable(isToggledHiddenByDefault: false), + + TextColumn::make('title') + ->description(fn ($record) => $record->slug) + ->searchable(), + + TextColumn::make('split_types_formatted') + ->label('Split Types'), + + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->actions([ + EditAction::make() + ->visible(fn () => Auth::user()?->hasRole('Superadmin')), // On Asim's request, this is only available to top-level administrators. + ]); + } + + public static function getRelations(): array + { + return [ + ScheduledGivingDonationsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListScheduledGivingCampaigns::route('/'), + 'create' => CreateScheduledGivingCampaign::route('/create'), + 'edit' => EditScheduledGivingCampaign::route('/{record}/edit'), + ]; + } + + protected static function formatHijriMonths(): array + { + $c = new HijriCalendar; + $islamicMonths = config('calendar.hijri.months'); + $monthOffset = $c->getCurrentIslamicMonth(); + $hijriYear = $c->getCurrentIslamicYear(); + + $rotatedMonths = array_merge( + array_slice($islamicMonths, $monthOffset - 1, null, true), // From current month to the end + array_slice($islamicMonths, 0, $monthOffset - 1, true) // From start to just before current month + ); + + $output = []; + foreach ($rotatedMonths as $index => $monthInfo) { + $englishName = $monthInfo['en']; + $arabicName = $monthInfo['ar']; + $output[$monthInfo['number']] = "{$arabicName} ({$englishName}) {$hijriYear}"; + + if ($monthInfo['number'] == 12) { + $hijriYear++; + } + } + + return $output; + } + + /** + * Will always return 30 days. This formats the days of the week to show. + */ + protected static function formatHijriDays(int $monthIndex): array + { + $c = new HijriCalendar; + + $monthIndex = Str::padLeft($monthIndex, 2, '0'); + $currentYear = $c->getCurrentIslamicYear(); + $currentDate = "01-{$monthIndex}-{$currentYear}"; + + $dates = []; + for ($i = 1; $i <= 30; $i++) { + $dates[$i] = $currentDate; + $currentDate = $c->adjustHijriDate($currentDate, 1); + } + + return $dates; + } + + private static function donationTypes(): Collection + { + return DonationType::where('is_scheduling', true)->orderBy('display_name')->get(); + } + + private static function donationCountries(): Collection + { + return DonationCountry::orderBy('name')->get(); + } +} diff --git a/temp_files/fix2/ScheduledGivingDashboard.php b/temp_files/fix2/ScheduledGivingDashboard.php new file mode 100644 index 0000000..7616da5 --- /dev/null +++ b/temp_files/fix2/ScheduledGivingDashboard.php @@ -0,0 +1,288 @@ + 0 + not soft-deleted). + */ +class ScheduledGivingDashboard extends Page +{ + protected static ?string $navigationIcon = 'heroicon-o-calendar-days'; + + protected static ?string $navigationGroup = 'Giving'; + + protected static ?int $navigationSort = 0; + + protected static ?string $navigationLabel = 'Dashboard'; + + protected static ?string $title = 'Scheduled Giving'; + + protected static string $view = 'filament.pages.scheduled-giving-dashboard'; + + /** Real subscriber IDs (has customer + payments + amount > 0 + not soft-deleted) */ + private function realSubscriberIds(?int $campaignId = null) + { + $q = DB::table('scheduled_giving_donations as d') + ->whereNotNull('d.customer_id') + ->where('d.total_amount', '>', 0) + ->whereNull('d.deleted_at') + ->whereExists(function ($sub) { + $sub->select(DB::raw(1)) + ->from('scheduled_giving_payments as p') + ->whereColumn('p.scheduled_giving_donation_id', 'd.id') + ->whereNull('p.deleted_at'); + }); + + if ($campaignId) { + $q->where('d.scheduled_giving_campaign_id', $campaignId); + } + + return $q->pluck('d.id'); + } + + /** IDs with at least one future payment = current season */ + private function currentSeasonIds(?int $campaignId = null) + { + $realIds = $this->realSubscriberIds($campaignId); + if ($realIds->isEmpty()) return collect(); + + return DB::table('scheduled_giving_donations as d') + ->whereIn('d.id', $realIds) + ->whereExists(function ($sub) { + $sub->select(DB::raw(1)) + ->from('scheduled_giving_payments as p') + ->whereColumn('p.scheduled_giving_donation_id', 'd.id') + ->whereNull('p.deleted_at') + ->whereRaw('p.expected_at > NOW()'); + }) + ->pluck('d.id'); + } + + public function getCampaignData(): array + { + $campaigns = ScheduledGivingCampaign::all(); + $result = []; + + foreach ($campaigns as $c) { + $realIds = $this->realSubscriberIds($c->id); + if ($realIds->isEmpty()) { + $result[] = $this->emptyCampaign($c); + continue; + } + + $currentIds = $this->currentSeasonIds($c->id); + $expiredIds = $realIds->diff($currentIds); + + // Current season payment stats + $currentPayments = null; + if ($currentIds->isNotEmpty()) { + $currentPayments = DB::table('scheduled_giving_payments') + ->whereIn('scheduled_giving_donation_id', $currentIds) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as total, + SUM(is_paid = 1) as paid, + SUM(is_paid = 0) as pending, + SUM(is_paid = 0 AND attempts > 0) as failed, + SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected, + SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) as pending_amount, + AVG(CASE WHEN is_paid = 1 THEN amount ELSE NULL END) as avg_amount, + MIN(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN expected_at ELSE NULL END) as next_payment + ") + ->first(); + } + + // All-time payment stats (for totals) + $allPayments = DB::table('scheduled_giving_payments') + ->whereIn('scheduled_giving_donation_id', $realIds) + ->whereNull('deleted_at') + ->selectRaw(" + SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected, + SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid, + COUNT(*) as total + ") + ->first(); + + // Completion for current season + $totalNights = count($c->dates ?? []); + $fullyPaid = 0; + if ($totalNights > 0 && $currentIds->isNotEmpty()) { + $ids = $currentIds->implode(','); + $row = DB::selectOne("SELECT COUNT(*) as cnt FROM (SELECT scheduled_giving_donation_id FROM scheduled_giving_payments WHERE scheduled_giving_donation_id IN ({$ids}) AND deleted_at IS NULL GROUP BY scheduled_giving_donation_id HAVING SUM(is_paid) >= {$totalNights}) sub"); + $fullyPaid = $row->cnt ?? 0; + } + + $result[] = [ + 'campaign' => $c, + 'all_time_subscribers' => $realIds->count(), + 'all_time_collected' => ($allPayments->collected ?? 0) / 100, + 'all_time_payments' => (int) ($allPayments->total ?? 0), + 'all_time_paid' => (int) ($allPayments->paid ?? 0), + // Current season + 'current_subscribers' => $currentIds->count(), + 'expired_subscribers' => $expiredIds->count(), + 'current_payments' => (int) ($currentPayments->total ?? 0), + 'current_paid' => (int) ($currentPayments->paid ?? 0), + 'current_pending' => (int) ($currentPayments->pending ?? 0), + 'current_failed' => (int) ($currentPayments->failed ?? 0), + 'current_collected' => ($currentPayments->collected ?? 0) / 100, + 'current_pending_amount' => ($currentPayments->pending_amount ?? 0) / 100, + 'avg_per_night' => ($currentPayments->avg_amount ?? 0) / 100, + 'fully_completed' => $fullyPaid, + 'dates' => $c->dates ?? [], + 'total_nights' => $totalNights, + 'next_payment' => $currentPayments->next_payment ?? null, + ]; + } + + return $result; + } + + public function getGlobalStats(): array + { + $realIds = $this->realSubscriberIds(); + $currentIds = $this->currentSeasonIds(); + + $allTime = DB::table('scheduled_giving_payments') + ->whereIn('scheduled_giving_donation_id', $realIds) + ->whereNull('deleted_at') + ->selectRaw(" + SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected, + SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid, + COUNT(*) as total + ") + ->first(); + + $currentStats = null; + if ($currentIds->isNotEmpty()) { + $currentStats = DB::table('scheduled_giving_payments') + ->whereIn('scheduled_giving_donation_id', $currentIds) + ->whereNull('deleted_at') + ->selectRaw(" + SUM(is_paid = 1) as paid, + SUM(is_paid = 0 AND attempts > 0) as failed, + SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected, + SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) / 100 as pending, + COUNT(*) as total + ") + ->first(); + } + + return [ + 'total_subscribers' => $realIds->count(), + 'current_subscribers' => $currentIds->count(), + 'expired_subscribers' => $realIds->count() - $currentIds->count(), + 'all_time_collected' => (float) ($allTime->collected ?? 0), + 'current_collected' => (float) ($currentStats->collected ?? 0), + 'current_pending' => (float) ($currentStats->pending ?? 0), + 'current_failed' => (int) ($currentStats->failed ?? 0), + 'collection_rate' => ($currentStats->total ?? 0) > 0 + ? round($currentStats->paid / $currentStats->total * 100, 1) + : 0, + ]; + } + + public function getFailedPayments(): array + { + $currentIds = $this->currentSeasonIds(); + if ($currentIds->isEmpty()) return []; + + return DB::table('scheduled_giving_payments as p') + ->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id') + ->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id') + ->leftJoin('customers as cu', 'cu.id', '=', 'd.customer_id') + ->whereIn('d.id', $currentIds) + ->where('p.is_paid', false) + ->where('p.attempts', '>', 0) + ->whereNull('p.deleted_at') + ->orderByDesc('p.updated_at') + ->limit(15) + ->get([ + 'p.id as payment_id', + 'p.amount', + 'p.expected_at', + 'p.attempts', + 'd.id as donation_id', + 'c.title as campaign', + DB::raw("CONCAT(cu.first_name, ' ', cu.last_name) as donor_name"), + 'cu.email as donor_email', + ]) + ->toArray(); + } + + public function getUpcomingPayments(): array + { + $currentIds = $this->currentSeasonIds(); + if ($currentIds->isEmpty()) return []; + + return DB::table('scheduled_giving_payments as p') + ->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id') + ->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id') + ->whereIn('d.id', $currentIds) + ->where('p.is_paid', false) + ->where('p.attempts', 0) + ->where('d.is_active', true) + ->whereNull('p.deleted_at') + ->whereBetween('p.expected_at', [now(), now()->addHours(48)]) + ->selectRaw(" + c.title as campaign, + COUNT(*) as payment_count, + SUM(p.amount) / 100 as total_amount, + MIN(p.expected_at) as earliest + ") + ->groupBy('c.title') + ->orderBy('earliest') + ->get() + ->toArray(); + } + + public function getDataQuality(): array + { + $total = DB::table('scheduled_giving_donations')->count(); + $softDeleted = DB::table('scheduled_giving_donations')->whereNotNull('deleted_at')->count(); + $noCustomer = DB::table('scheduled_giving_donations')->whereNull('customer_id')->whereNull('deleted_at')->count(); + $noPayments = DB::table('scheduled_giving_donations as d') + ->whereNull('d.deleted_at') + ->whereNotNull('d.customer_id') + ->whereNotExists(function ($q) { + $q->select(DB::raw(1)) + ->from('scheduled_giving_payments as p') + ->whereColumn('p.scheduled_giving_donation_id', 'd.id') + ->whereNull('p.deleted_at'); + })->count(); + $zeroAmount = DB::table('scheduled_giving_donations') + ->whereNull('deleted_at') + ->where('total_amount', '<=', 0) + ->count(); + + return [ + 'total_records' => $total, + 'soft_deleted' => $softDeleted, + 'no_customer' => $noCustomer, + 'no_payments' => $noPayments, + 'zero_amount' => $zeroAmount, + ]; + } + + private function emptyCampaign($c): array + { + return [ + 'campaign' => $c, + 'all_time_subscribers' => 0, 'all_time_collected' => 0, 'all_time_payments' => 0, 'all_time_paid' => 0, + 'current_subscribers' => 0, 'expired_subscribers' => 0, + 'current_payments' => 0, 'current_paid' => 0, 'current_pending' => 0, 'current_failed' => 0, + 'current_collected' => 0, 'current_pending_amount' => 0, 'avg_per_night' => 0, + 'fully_completed' => 0, 'dates' => $c->dates ?? [], 'total_nights' => count($c->dates ?? []), + 'next_payment' => null, + ]; + } +} diff --git a/temp_files/fix2/ScheduledGivingDonationResource.php b/temp_files/fix2/ScheduledGivingDonationResource.php new file mode 100644 index 0000000..be138a5 --- /dev/null +++ b/temp_files/fix2/ScheduledGivingDonationResource.php @@ -0,0 +1,312 @@ +schema([ + Section::make('Donation Details') + ->schema([ + Grid::make(5)->schema([ + Placeholder::make('status') + ->label('Status') + ->content(function (ScheduledGivingDonation $scheduledGivingDonation) { + if ($scheduledGivingDonation->is_active) { + return new HtmlString('Enabled'); + } else { + return new HtmlString('Disabled'); + } + }), + + Placeholder::make('scheduledGivingCampaign.title') + ->label('Campaign') + ->content(fn (ScheduledGivingDonation $record): HtmlString => new HtmlString('' . $record->scheduledGivingCampaign->title . '')), + + Placeholder::make('Amount') + ->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('' . Helpers::formatMoneyGlobal($record->total_amount) . '')), + + Placeholder::make('admin_contribution') + ->label('Admin Contribution') + ->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('' . Helpers::formatMoneyGlobal($record->amount_admin) . '')), + + Placeholder::make('is_zakat') + ->label('Zakat?') + ->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('' . ($record->is_zakat ? 'Yes' : 'No') . '')), + + Placeholder::make('is_gift_aid') + ->label('Gift Aid?') + ->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('' . ($record->is_gift_aid ? 'Yes' : 'No') . '')), + ]), + + Fieldset::make('Customer Details') + ->columns(3) + ->schema([ + Placeholder::make('name') + ->label('Name') + ->content(fn (ScheduledGivingDonation $donation) => $donation->customer->name), + + Placeholder::make('email') + ->label('Email') + ->content(fn (ScheduledGivingDonation $donation) => $donation->customer->email), + + Placeholder::make('phone') + ->label('Phone') + ->content(fn (ScheduledGivingDonation $donation) => strlen(trim($phone = $donation->customer->phone)) > 0 ? $phone : new HtmlString('(Not given)')), + ]) + ->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->customer), + + Fieldset::make('Address') + ->columns(3) + ->schema([ + Placeholder::make('house') + ->label('House') + ->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->house)) ? $v : new HtmlString('(Not given)')), + + Placeholder::make('street') + ->label('Street') + ->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->street)) ? $v : new HtmlString('(Not given)')), + + Placeholder::make('town') + ->label('Town') + ->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->town)) ? $v : new HtmlString('(Not given)')), + + Placeholder::make('state') + ->label('State') + ->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->state)) ? $v : new HtmlString('(Not given)')), + + Placeholder::make('postcode') + ->label('Postcode') + ->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->postcode)) ? $v : new HtmlString('(Not given)')), + + Placeholder::make('country_code') + ->label('Country') + ->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->country_code)) ? $v : new HtmlString('(Not given)')), + ]) + ->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->address), + + Fieldset::make('Appeal') + ->columns(2) + ->schema([ + Placeholder::make('appeal.name') + ->label('Name') + ->content(fn (ScheduledGivingDonation $donation) => $donation->appeal->name), + + Placeholder::make('appeal.url') + ->label('URL') + ->content(fn (ScheduledGivingDonation $donation): HtmlString => new HtmlString('' . $donation->appeal->url() . '')), + ]) + ->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->appeal), + + Repeater::make('allocations') + ->schema([ + TextInput::make('type') + ->label('Donation Type') + ->required() + ->disabled(), + + TextInput::make('amount') + ->label('Amount') + ->numeric() + ->required() + ->disabled(), + ]) + ->columns() + ->formatStateUsing(function ($state) { + return collect($state) + ->map(fn ($amount, $type) => ['type' => DonationType::find($type)?->display_name, 'amount' => $amount]) + ->values() + ->toArray(); + }) + ->addable(false) + ->deletable(false), + ]), + ]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withCount(['payments as paid_payments_count' => fn ($q) => $q->where('is_paid', true)->whereNull('deleted_at')]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + IconColumn::make('is_active') + ->label('') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger') + ->tooltip(fn (ScheduledGivingDonation $r) => $r->is_active ? 'Active' : 'Cancelled'), + + TextColumn::make('customer.name') + ->label('Donor') + ->description(fn (ScheduledGivingDonation $d) => $d->customer?->email) + ->sortable() + ->searchable(query: function (Builder $query, string $search) { + $query->whereHas('customer', fn (Builder $q) => $q + ->where('first_name', 'like', "%{$search}%") + ->orWhere('last_name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%")); + }), + + TextColumn::make('scheduledGivingCampaign.title') + ->label('Campaign') + ->badge() + ->color(fn ($state) => match ($state) { + '30 Nights of Giving' => 'primary', + '10 Days of Giving' => 'success', + 'Night of Power' => 'warning', + default => 'gray', + }), + + TextColumn::make('total_amount') + ->label('Per Night') + ->money('gbp', divideBy: 100) + ->sortable(), + + TextColumn::make('payments_count') + ->label('Progress') + ->counts('payments') + ->formatStateUsing(function ($state, ScheduledGivingDonation $record) { + $total = (int) $state; + if ($total === 0) return '—'; + $paid = (int) ($record->paid_payments_count ?? 0); + $pct = round($paid / $total * 100); + return "{$paid}/{$total} ({$pct}%)"; + }) + ->color(function ($state, ScheduledGivingDonation $record) { + $total = (int) $state; + if ($total === 0) return 'gray'; + $paid = (int) ($record->paid_payments_count ?? 0); + $pct = $paid / $total * 100; + return $pct >= 80 ? 'success' : ($pct >= 40 ? 'warning' : 'danger'); + }) + ->badge(), + + TextColumn::make('created_at') + ->label('Signed Up') + ->date('d M Y') + ->description(fn (ScheduledGivingDonation $d) => $d->created_at?->diffForHumans()) + ->sortable(), + + TextColumn::make('reference_code') + ->label('Ref') + ->searchable() + ->fontFamily('mono') + ->copyable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->searchable() + ->bulkActions(static::getBulkActions()) + ->actions(static::getTableRowActions()) + ->defaultSort('created_at', 'desc') + ->filters([ + Filter::make('confirmed') + ->query(fn (Builder $query): Builder => $query->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))), + + SelectFilter::make('scheduled_giving_campaign_id') + ->options(ScheduledGivingCampaign::get()->pluck('title', 'id')), + ]); + } + + public static function getRelations(): array + { + return [ + ScheduledGivingDonationPayments::class, + InternalNotesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListScheduledGivingDonations::route('/'), + 'edit' => EditScheduledGivingDonation::route('/{record}/edit'), + ]; + } + + private static function getTableRowActions(): array + { + return [ + ViewAction::make(), + ActionGroup::make([ + Action::make('view_customer') + ->label('View Customer') + ->icon('heroicon-s-eye') + ->visible(fn () => (Auth::user()?->hasPermissionTo('view-customer') || Auth::user()?->hasRole('Superadmin')) || Auth::user()?->hasRole('Superadmin')), + ]), + ]; + } + + private static function getBulkActions() + { + return BulkActionGroup::make([ + BulkAction::make('send_to_zapier') + ->label('Send to Zapier') + ->icon('heroicon-s-envelope') + ->visible(fn (ScheduledGivingDonation $donation) => (Auth::user()?->hasPermissionTo('view-donation') || Auth::user()?->hasRole('Superadmin'))) + ->action(function ($records) { + foreach ($records as $donation) { + dispatch(new SendCustomer($donation->customer)); + Notification::make() + ->success() + ->title($donation->reference_code . ' pushed to Zapier.') + ->send(); + } + }), + ]); + } +} diff --git a/temp_files/fix2/scheduled-giving-dashboard.blade.php b/temp_files/fix2/scheduled-giving-dashboard.blade.php new file mode 100644 index 0000000..758181e --- /dev/null +++ b/temp_files/fix2/scheduled-giving-dashboard.blade.php @@ -0,0 +1,244 @@ + + @php + $global = $this->getGlobalStats(); + $campaigns = $this->getCampaignData(); + $failed = $this->getFailedPayments(); + $upcoming = $this->getUpcomingPayments(); + $quality = $this->getDataQuality(); + @endphp + + {{-- ── Current Season Overview ─────────────────────────────── --}} + + +
+ + Ramadan {{ now()->year }} — Current Season +
+
+ +
+
+
{{ number_format($global['current_subscribers']) }}
+
Active This Season
+
{{ number_format($global['expired_subscribers']) }} from past seasons
+
+
+
£{{ number_format($global['current_collected'], 0) }}
+
Collected This Season
+
£{{ number_format($global['all_time_collected'], 0) }} all-time
+
+
+
£{{ number_format($global['current_pending'], 0) }}
+
Pending
+ @if ($global['current_failed'] > 0) +
{{ $global['current_failed'] }} failed
+ @endif +
+
+
+ {{ $global['collection_rate'] }}% +
+
Collection Rate
+
+
+
+ + {{-- ── Campaign Cards ──────────────────────────────────────── --}} +
+ @foreach ($campaigns as $data) + @php + $c = $data['campaign']; + $hasCurrent = $data['current_subscribers'] > 0; + $progressPct = $data['current_payments'] > 0 + ? round($data['current_paid'] / $data['current_payments'] * 100) + : 0; + @endphp + + + +
+ {{ $c->title }} + @if ($hasCurrent) + ● Active + @else + ○ No current season + @endif +
+
+ + @if ($hasCurrent) + {{-- Current Season --}} +
This Season
+
+
+
Subscribers
+
{{ $data['current_subscribers'] }}
+
+
+
Avg / Night
+
£{{ number_format($data['avg_per_night'], 2) }}
+
+
+
Collected
+
£{{ number_format($data['current_collected'], 0) }}
+
+
+
Pending
+
£{{ number_format($data['current_pending_amount'], 0) }}
+
+
+ + {{-- Payment progress bar --}} +
+
+ {{ number_format($data['current_paid']) }} / {{ number_format($data['current_payments']) }} payments + {{ $progressPct }}% +
+
+
+
+
+ +
+
+
Nights
+
{{ $data['total_nights'] }}
+
+
+
Completed
+
{{ $data['fully_completed'] }}
+
+
+
Failed
+
{{ $data['current_failed'] }}
+
+
+ @endif + + {{-- All-time summary --}} +
+
All Time
+
+ {{ $data['all_time_subscribers'] }} subscribers ({{ $data['expired_subscribers'] }} expired) + £{{ number_format($data['all_time_collected'], 0) }} +
+
+ + +
+ @endforeach +
+ + {{-- ── Upcoming Payments ───────────────────────────────────── --}} + @if (count($upcoming) > 0) + + +
+ + Upcoming Payments (Next 48h) +
+
+
+ @foreach ($upcoming as $u) +
+
+
{{ $u->campaign }}
+
{{ $u->payment_count }} payments
+
+
+
£{{ number_format($u->total_amount, 2) }}
+
{{ \Carbon\Carbon::parse($u->earliest)->diffForHumans() }}
+
+
+ @endforeach +
+
+ @endif + + {{-- ── Failed Payments ─────────────────────────────────────── --}} + @if (count($failed) > 0) + + +
+ + Failed Payments — This Season ({{ count($failed) }}) +
+
+
+ + + + + + + + + + + + + @foreach ($failed as $f) + + + + + + + + + @endforeach + +
DonorCampaignAmountExpectedAttempts
+
{{ $f->donor_name }}
+
{{ $f->donor_email }}
+
{{ $f->campaign }}£{{ number_format($f->amount / 100, 2) }}{{ \Carbon\Carbon::parse($f->expected_at)->format('d M') }} + + {{ $f->attempts }}× + + + View → +
+
+
+ @endif + + {{-- ── Data Quality ────────────────────────────────────────── --}} + + +
+ + Data Quality +
+
+

+ {{ number_format($quality['total_records']) }} total records in database. + Only {{ number_format($global['total_subscribers']) }} are real subscribers with payments. + The rest are incomplete sign-ups, test data, or soft-deleted. +

+
+
+
{{ number_format($quality['soft_deleted']) }}
+
Soft-deleted
+
+
+
{{ number_format($quality['no_customer']) }}
+
No customer
+
+
+
{{ number_format($quality['no_payments']) }}
+
No payments
+
+
+
{{ number_format($quality['zero_amount']) }}
+
Zero amount
+
+
+
+