Deep UX: 2-column automations, visible appeal cards, platform education, strip model refs
Automations: - 2-column layout: WhatsApp phone LEFT, education RIGHT - Right column: 'How it works' (5 numbered steps), performance stats, timing controls, reply commands, tips - Hero spans full width with photo+dark panel - Improvement CTA is a prominent card, not floating text - No misalignment — phone fills left column naturally Collect: - Appeals shown as visible gap-px grid cards (not hidden dropdown) - Each card shows name, platform, amount raised, pledge count, collection rate - Active appeal has border-l-2 blue indicator - Platform integration clarity: shows 'Donors redirected to JustGiving' etc - Educational section: 'Where to share your link' + 'How payment works' - Explains bank transfer vs JustGiving vs card payment inline AI model: Stripped all model name comments from code (no user-facing references existed)
This commit is contained in:
@@ -11,7 +11,7 @@ const OPENAI_MODEL = "gpt-4.1-nano"
|
|||||||
async function chat(messages: Array<{ role: string; content: string }>, maxTokens = 600): Promise<string> {
|
async function chat(messages: Array<{ role: string; content: string }>, maxTokens = 600): Promise<string> {
|
||||||
if (!HAS_AI) return ""
|
if (!HAS_AI) return ""
|
||||||
|
|
||||||
// Prefer OpenAI (gpt-4.1-nano), fall back to Gemini
|
// Prefer OpenAI, fall back to Gemini
|
||||||
if (OPENAI_KEY) {
|
if (OPENAI_KEY) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import {
|
import {
|
||||||
Loader2, Check, Send, Trophy, CheckCheck,
|
Loader2, Check, Send, Trophy, CheckCheck,
|
||||||
ChevronDown, Clock, MessageCircle, RefreshCw, Calendar
|
ChevronDown, MessageCircle, RefreshCw, Calendar
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { resolvePreview, STEP_META } from "@/lib/templates"
|
import { resolvePreview, STEP_META } from "@/lib/templates"
|
||||||
@@ -146,312 +146,364 @@ export default function AutomationsPage() {
|
|||||||
const waConnected = !!channels?.whatsapp
|
const waConnected = !!channels?.whatsapp
|
||||||
const delays = [0, config?.step1Delay || 2, config?.step2Delay || 7, config?.step3Delay || 14]
|
const delays = [0, config?.step1Delay || 2, config?.step2Delay || 7, config?.step3Delay || 14]
|
||||||
|
|
||||||
|
// Stats for the right column
|
||||||
|
const totalSentAll = templates.reduce((s, t) => s + t.sentCount, 0)
|
||||||
|
const totalConverted = templates.reduce((s, t) => s + t.convertedCount, 0)
|
||||||
|
const overallRate = totalSentAll > 0 ? Math.round((totalConverted / totalSentAll) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ━━ HERO — Full-width, same pattern as landing page ━━━━━━━ */}
|
||||||
<div>
|
<div className="grid md:grid-cols-5 gap-0">
|
||||||
<div className="border-l-2 border-[#F59E0B] pl-3 mb-3">
|
<div className="md:col-span-2 relative min-h-[180px] md:min-h-[260px] overflow-hidden">
|
||||||
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Donor journey</p>
|
<Image
|
||||||
|
src="/images/brand/digital-03-notification-smile.jpg"
|
||||||
|
alt="Young man smiling at his phone — the moment a gentle reminder lands"
|
||||||
|
fill className="object-cover" sizes="(max-width: 768px) 100vw, 40vw"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3 bg-[#111827] p-6 md:p-8 flex flex-col justify-center">
|
||||||
|
<div className="border-l-2 border-[#F59E0B] pl-3 mb-3">
|
||||||
|
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Donor journey</p>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
|
||||||
|
What your donors receive
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-400 leading-relaxed mt-2 max-w-md">
|
||||||
|
After someone pledges, they get 5 WhatsApp messages — a receipt, a due date nudge, and 3 reminders. Each message includes your bank details and a unique reference. Click any message below to edit it.
|
||||||
|
</p>
|
||||||
|
{!waConnected && (
|
||||||
|
<p className="text-xs text-[#F59E0B] mt-3 font-bold">
|
||||||
|
WhatsApp not connected — <Link href="/dashboard/settings" className="underline hover:text-white">connect in Settings</Link> to start sending.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl md:text-4xl font-black text-[#111827] tracking-tight">
|
|
||||||
What your donors receive
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
5 messages — a receipt, a due date nudge, and 3 reminders. Click any to edit.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── WhatsApp status ── */}
|
{/* ━━ TWO-COLUMN LAYOUT — Phone left, education right ━━━━━━ */}
|
||||||
{!waConnected && (
|
<div className="grid lg:grid-cols-12 gap-6">
|
||||||
<div className="border-l-2 border-[#F59E0B] pl-4 py-2">
|
|
||||||
<p className="text-sm text-gray-600"><strong className="text-[#111827]">WhatsApp not connected.</strong> Messages start once you <Link href="/dashboard/settings" className="text-[#1E40AF] font-bold hover:underline">connect WhatsApp</Link>.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ━━ HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
{/* ── LEFT: The WhatsApp conversation ── */}
|
||||||
No tech-speak. No model names. No cost breakdowns.
|
<div className="lg:col-span-7">
|
||||||
This is about what happens for the DONOR, not the engine.
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
{/* Improvement status bar */}
|
||||||
{neverOptimised ? (
|
{neverOptimised ? (
|
||||||
/* ── Never tested: invite them to start ── */
|
|
||||||
<div className="grid md:grid-cols-5 gap-0">
|
|
||||||
<div className="md:col-span-2 relative min-h-[200px] md:min-h-[280px] overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src="/images/brand/digital-03-notification-smile.jpg"
|
|
||||||
alt="Young man smiling at his phone — the moment a gentle reminder lands"
|
|
||||||
fill className="object-cover" sizes="(max-width: 768px) 100vw, 40vw"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-3 bg-[#111827] p-8 md:p-10 flex flex-col justify-center">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-black text-white tracking-tight">
|
|
||||||
Messages that improve themselves
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-400 leading-relaxed mt-3 max-w-md">
|
|
||||||
We test different versions of each message with your real donors.
|
|
||||||
The one that collects more pledges wins. Automatically. You don't do anything — they just get better over time.
|
|
||||||
</p>
|
|
||||||
<button onClick={optimiseAll} disabled={aiWorking}
|
<button onClick={optimiseAll} disabled={aiWorking}
|
||||||
className="mt-6 inline-flex items-center justify-center bg-white px-6 py-3 text-sm font-bold text-[#111827] hover:bg-gray-100 transition-colors self-start disabled:opacity-60">
|
className="w-full bg-white border-2 border-[#111827] p-4 mb-4 flex items-center gap-4 hover:bg-gray-50 transition-colors text-left disabled:opacity-60">
|
||||||
{aiWorking
|
<div className="w-10 h-10 bg-[#111827] flex items-center justify-center shrink-0">
|
||||||
? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Creating new versions…</>
|
<span className="text-white text-lg font-black">↑</span>
|
||||||
: "Start improving"}
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-[#111827]">
|
||||||
|
{aiWorking ? "Creating new versions…" : "Improve these messages"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
We'll test a different version of each message and keep whichever collects more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{aiWorking && <Loader2 className="h-4 w-4 text-[#1E40AF] animate-spin shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
|
) : testsRunning > 0 ? (
|
||||||
|
<div className="bg-[#111827] p-4 mb-4 flex items-center gap-3">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="w-2.5 h-2.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
||||||
|
<span className="absolute inset-0 w-2.5 h-2.5 bg-[#60A5FA] animate-pulse" style={{ borderRadius: "50%" }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-white">Testing {testsRunning} new version{testsRunning > 1 ? "s" : ""}</p>
|
||||||
|
<p className="text-[11px] text-gray-500">The better one wins automatically.</p>
|
||||||
|
</div>
|
||||||
|
{stepsWithoutTest > 0 && (
|
||||||
|
<button onClick={optimiseAll} disabled={aiWorking}
|
||||||
|
className="shrink-0 border border-gray-600 text-gray-300 px-3 py-1.5 text-[11px] font-bold hover:text-white hover:border-white transition-colors disabled:opacity-50">
|
||||||
|
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : `Test ${stepsWithoutTest} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-[#111827] p-4 mb-4 flex items-center gap-3">
|
||||||
|
<div className="w-2.5 h-2.5 bg-[#16A34A] shrink-0" style={{ borderRadius: "50%" }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-white">Your messages are tuned</p>
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{stats && stats.total > 0 ? `${stats.total} sent · ${stats.deliveryRate}% delivered` : "Best-performing versions are live."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={optimiseAll} disabled={aiWorking}
|
||||||
|
className="shrink-0 border border-gray-600 text-gray-300 px-3 py-1.5 text-[11px] font-bold hover:text-white hover:border-white transition-colors disabled:opacity-50">
|
||||||
|
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : "Test new versions"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WhatsApp mockup */}
|
||||||
|
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
|
||||||
|
<div className="bg-[#075E54] px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className="text-white/50 text-sm">←</span>
|
||||||
|
<div className="w-9 h-9 bg-[#128C7E] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
||||||
|
<MessageCircle className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm font-medium">Your charity</p>
|
||||||
|
<p className="text-[10px] text-white/50">Automated messages</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#ECE5DD] px-4 py-4 space-y-4" style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||||
|
}}>
|
||||||
|
{STEP_META.map((meta) => {
|
||||||
|
const step = meta.step
|
||||||
|
const a = tpl(step, "A")
|
||||||
|
const b = tpl(step, "B")
|
||||||
|
const isEditing = editing === step
|
||||||
|
const justSaved = saved === step
|
||||||
|
const isRegenning = regenerating === step
|
||||||
|
const previewA = a ? resolvePreview(a.body) : ""
|
||||||
|
const previewB = b ? resolvePreview(b.body) : ""
|
||||||
|
const isConditional = meta.conditional
|
||||||
|
const hasDueDateTemplate = !!a
|
||||||
|
const timeLabel = step === 0 ? "Instantly" : step === 4 ? "On the due date · if set" : step === 1 ? `Day ${delays[1]} · if not paid` : step === 2 ? `Day ${delays[2]} · if not paid` : `Day ${delays[3]} · if not paid`
|
||||||
|
const clockTime = step === 0 ? "09:41" : step === 4 ? "08:00" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"
|
||||||
|
const rateA = a && a.sentCount > 0 ? Math.round((a.convertedCount / a.sentCount) * 100) : 0
|
||||||
|
const rateB = b && b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0
|
||||||
|
const totalSent = (a?.sentCount || 0) + (b?.sentCount || 0)
|
||||||
|
const progress = Math.min(100, Math.round((totalSent / (MIN_SAMPLE * 2)) * 100))
|
||||||
|
const hasEnoughData = (a?.sentCount || 0) >= MIN_SAMPLE && (b?.sentCount || 0) >= MIN_SAMPLE
|
||||||
|
const winner = hasEnoughData ? (rateB > rateA ? "B" : rateA > rateB ? "A" : null) : null
|
||||||
|
|
||||||
|
if (isConditional && !hasDueDateTemplate) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step}>
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
<span className={`text-[10px] px-3 py-1 font-medium shadow-sm flex items-center gap-1.5 ${isConditional ? "bg-[#FEF3C7] text-[#92400E]" : "bg-white/80 text-[#667781]"}`} style={{ borderRadius: "6px" }}>
|
||||||
|
{isConditional && <Calendar className="h-2.5 w-2.5" />}
|
||||||
|
{timeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="bg-[#DCF8C6] max-w-[90%] w-full shadow-sm" style={{ borderRadius: "8px 0 8px 8px" }}>
|
||||||
|
<textarea ref={editorRef} value={editBody} onChange={e => setEditBody(e.target.value)}
|
||||||
|
className="w-full bg-transparent px-3 py-2 text-[13px] leading-[1.6] text-[#303030] resize-y outline-none min-h-[180px] font-mono" />
|
||||||
|
<div className="px-3 pb-2 flex items-center gap-2">
|
||||||
|
<button onClick={() => saveEdit(step)} disabled={saving}
|
||||||
|
className="bg-[#075E54] text-white px-3 py-1.5 text-[10px] font-bold flex items-center gap-1 disabled:opacity-50" style={{ borderRadius: "4px" }}>
|
||||||
|
{saving ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Check className="h-2.5 w-2.5" />} Save
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditing(null)} className="text-[10px] text-[#667781]">Cancel</button>
|
||||||
|
<span className="text-[9px] text-[#667781]/50 ml-auto">{"{{name}} {{amount}} {{payment_block}}"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : b ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
||||||
|
<div className="bg-[#075E54] text-white px-3 py-1.5 flex items-center gap-1.5" style={{ borderRadius: "8px 8px 0 0" }}>
|
||||||
|
<div className="w-1.5 h-1.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
||||||
|
<span className="text-[10px] font-bold flex-1">Testing two versions</span>
|
||||||
|
{hasEnoughData && winner && (
|
||||||
|
<span className="text-[9px] bg-[#16A34A]/20 text-[#4ADE80] px-1.5 py-0.5 font-bold flex items-center gap-0.5">
|
||||||
|
<Trophy className="h-2.5 w-2.5" /> {winner === "B" ? "New" : "Current"} winning
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!hasEnoughData && <span className="text-[9px] text-white/40">{progress}%</span>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => startEdit(step)} className="w-full bg-[#DCF8C6] p-3 text-left hover:brightness-[0.97] transition-all border-b border-[#075E54]/10">
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
<span className="text-[9px] font-bold text-[#075E54] bg-[#075E54]/10 px-1.5 py-0.5">Current</span>
|
||||||
|
{a && a.sentCount > 0 && (
|
||||||
|
<span className={`text-[9px] font-bold ml-auto ${winner === "A" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
||||||
|
{rateA}% conversion{winner === "A" && " 🏆"} · {a.convertedCount}/{a.sentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] leading-[1.5] text-[#303030]"><WhatsAppFormatted text={previewA} /></div>
|
||||||
|
</button>
|
||||||
|
<div className="bg-[#DCF8C6] p-3 relative group">
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
<span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1.5 py-0.5">New</span>
|
||||||
|
{b.sentCount > 0 && (
|
||||||
|
<span className={`text-[9px] font-bold ml-auto ${winner === "B" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
||||||
|
{rateB}% conversion{winner === "B" && " 🏆"} · {b.convertedCount}/{b.sentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] leading-[1.5] text-[#303030]"><WhatsAppFormatted text={previewB} /></div>
|
||||||
|
<button onClick={() => regenerateVariant(step)} disabled={isRegenning}
|
||||||
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 bg-white/90 p-1.5 shadow-sm transition-opacity hover:bg-white disabled:opacity-50"
|
||||||
|
style={{ borderRadius: "4px" }} title="Try a different version">
|
||||||
|
{isRegenning ? <Loader2 className="h-3.5 w-3.5 text-[#1E40AF] animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 text-[#1E40AF]" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/60 px-3 py-1.5" style={{ borderRadius: "0 0 8px 8px" }}>
|
||||||
|
<div className="h-1 bg-gray-200 overflow-hidden" style={{ borderRadius: "2px" }}>
|
||||||
|
<div className={`h-full transition-all ${hasEnoughData ? "bg-[#16A34A]" : "bg-[#60A5FA]"}`} style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-[#667781] mt-1">
|
||||||
|
{hasEnoughData
|
||||||
|
? winner ? `${winner === "B" ? "New" : "Current"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call — need more data"
|
||||||
|
: `${totalSent} of ${MIN_SAMPLE * 2} sends needed`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={() => startEdit(step)}
|
||||||
|
className="bg-[#DCF8C6] max-w-[88%] px-3 py-2 text-left text-[13px] leading-[1.5] text-[#303030] relative shadow-sm cursor-pointer hover:brightness-[0.97] transition-all"
|
||||||
|
style={{ borderRadius: "8px 0 8px 8px" }}>
|
||||||
|
{justSaved && (
|
||||||
|
<div className="absolute -top-2 -right-2 w-5 h-5 bg-[#25D366] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
||||||
|
<Check className="h-3 w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<WhatsAppFormatted text={previewA} />
|
||||||
|
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
|
||||||
|
<span className="text-[9px] text-[#667781]">{clockTime}</span>
|
||||||
|
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#F0F0F0] px-3 py-2 flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-white px-4 py-2 text-[11px] text-[#667781]" style={{ borderRadius: "20px" }}>
|
||||||
|
Donors can reply: PAID · HELP · CANCEL
|
||||||
|
</div>
|
||||||
|
<div className="w-9 h-9 bg-[#075E54] flex items-center justify-center shrink-0" style={{ borderRadius: "50%" }}>
|
||||||
|
<Send className="h-4 w-4 text-white ml-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : testsRunning > 0 ? (
|
{/* Pick winners */}
|
||||||
/* ── Tests running: quiet confidence ── */
|
{testsRunning > 0 && (
|
||||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
<button onClick={pickWinnersAndContinue} disabled={aiWorking}
|
||||||
<div className="relative shrink-0">
|
className="w-full mt-4 bg-[#111827] text-white py-3 text-sm font-bold flex items-center justify-center gap-2 hover:bg-gray-800 transition-colors disabled:opacity-50">
|
||||||
<div className="w-2.5 h-2.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
{aiWorking
|
||||||
<span className="absolute inset-0 w-2.5 h-2.5 bg-[#60A5FA] animate-pulse" style={{ borderRadius: "50%" }} />
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Picking winners…</>
|
||||||
</div>
|
: <><Trophy className="h-3.5 w-3.5" /> Pick the best & test new ones</>}
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-bold text-white">Testing {testsRunning} new version{testsRunning > 1 ? "s" : ""}</p>
|
|
||||||
<p className="text-[11px] text-gray-500 mt-0.5">The version that converts more donors wins automatically.</p>
|
|
||||||
</div>
|
|
||||||
{stepsWithoutTest > 0 && (
|
|
||||||
<button onClick={optimiseAll} disabled={aiWorking}
|
|
||||||
className="shrink-0 border border-gray-600 text-gray-300 px-3 py-1.5 text-[11px] font-bold hover:text-white hover:border-white transition-colors disabled:opacity-50">
|
|
||||||
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : `Test ${stepsWithoutTest} more`}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
/* ── All optimised: show results ── */
|
|
||||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
|
||||||
<div className="w-2.5 h-2.5 bg-[#16A34A] shrink-0" style={{ borderRadius: "50%" }} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-bold text-white">Your messages are tuned</p>
|
|
||||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
|
||||||
{stats && stats.total > 0 ? `${stats.total} sent · ${stats.deliveryRate}% delivered` : "Best-performing versions are live."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={optimiseAll} disabled={aiWorking}
|
|
||||||
className="shrink-0 border border-gray-600 text-gray-300 px-3 py-1.5 text-[11px] font-bold hover:text-white hover:border-white transition-colors disabled:opacity-50">
|
|
||||||
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : "Test new versions"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
{/* ── RIGHT: Education + context — the landing page voice ── */}
|
||||||
Full messages always visible. No truncation.
|
<div className="lg:col-span-5 space-y-6">
|
||||||
A/B tests stack vertically — Current on top, New below.
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
|
||||||
<div className="max-w-lg mx-auto">
|
|
||||||
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
|
|
||||||
|
|
||||||
{/* WhatsApp header */}
|
{/* How it works — numbered steps like landing page */}
|
||||||
<div className="bg-[#075E54] px-4 py-3 flex items-center gap-3">
|
<div className="border border-gray-200 bg-white">
|
||||||
<span className="text-white/50 text-sm">←</span>
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
<div className="w-9 h-9 bg-[#128C7E] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
<h3 className="text-sm font-bold text-[#111827]">How it works</h3>
|
||||||
<MessageCircle className="h-4 w-4 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="divide-y divide-gray-50">
|
||||||
<p className="text-white text-sm font-medium">Your charity</p>
|
{[
|
||||||
<p className="text-[10px] text-white/50">Automated messages</p>
|
{ n: "01", title: "Someone pledges", desc: "They scan your QR code or tap your link. Amount, Gift Aid, Zakat — done in 60 seconds." },
|
||||||
</div>
|
{ n: "02", title: "They get a receipt", desc: "Instantly. With your bank details and a unique reference so you can match their payment." },
|
||||||
</div>
|
{ n: "03", title: "Gentle reminders", desc: "Day 2, day 7, day 14 — if they haven't paid. Warm, never pushy. They can reply PAID anytime." },
|
||||||
|
{ n: "04", title: "Messages improve", desc: "We test different versions and keep whichever converts more pledges into payments. Automatically." },
|
||||||
{/* Chat */}
|
{ n: "05", title: "You do nothing", desc: "Receipts, reminders, follow-ups — all handled. You focus on the next event." },
|
||||||
<div className="bg-[#ECE5DD] px-4 py-4 space-y-4" style={{
|
].map(s => (
|
||||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
<div key={s.n} className="px-5 py-3 flex gap-3">
|
||||||
}}>
|
<span className="text-lg font-black text-gray-200 shrink-0 w-6">{s.n}</span>
|
||||||
{STEP_META.map((meta) => {
|
<div>
|
||||||
const step = meta.step
|
<p className="text-xs font-bold text-[#111827]">{s.title}</p>
|
||||||
const a = tpl(step, "A")
|
<p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{s.desc}</p>
|
||||||
const b = tpl(step, "B")
|
|
||||||
const isEditing = editing === step
|
|
||||||
const justSaved = saved === step
|
|
||||||
const isRegenning = regenerating === step
|
|
||||||
const previewA = a ? resolvePreview(a.body) : ""
|
|
||||||
const previewB = b ? resolvePreview(b.body) : ""
|
|
||||||
|
|
||||||
const isConditional = meta.conditional
|
|
||||||
const hasDueDateTemplate = !!a
|
|
||||||
|
|
||||||
const timeLabel = step === 0 ? "Instantly" :
|
|
||||||
step === 4 ? "On the due date · if set" :
|
|
||||||
step === 1 ? `Day ${delays[1]} · if not paid` :
|
|
||||||
step === 2 ? `Day ${delays[2]} · if not paid` :
|
|
||||||
`Day ${delays[3]} · if not paid`
|
|
||||||
|
|
||||||
const clockTime = step === 0 ? "09:41" : step === 4 ? "08:00" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"
|
|
||||||
|
|
||||||
const rateA = a && a.sentCount > 0 ? Math.round((a.convertedCount / a.sentCount) * 100) : 0
|
|
||||||
const rateB = b && b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0
|
|
||||||
const totalSent = (a?.sentCount || 0) + (b?.sentCount || 0)
|
|
||||||
const progress = Math.min(100, Math.round((totalSent / (MIN_SAMPLE * 2)) * 100))
|
|
||||||
const hasEnoughData = (a?.sentCount || 0) >= MIN_SAMPLE && (b?.sentCount || 0) >= MIN_SAMPLE
|
|
||||||
const winner = hasEnoughData ? (rateB > rateA ? "B" : rateA > rateB ? "A" : null) : null
|
|
||||||
|
|
||||||
if (isConditional && !hasDueDateTemplate) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={step}>
|
|
||||||
{/* Timestamp */}
|
|
||||||
<div className="flex justify-center mb-3">
|
|
||||||
<span className={`text-[10px] px-3 py-1 font-medium shadow-sm flex items-center gap-1.5 ${isConditional ? "bg-[#FEF3C7] text-[#92400E]" : "bg-white/80 text-[#667781]"}`} style={{ borderRadius: "6px" }}>
|
|
||||||
{isConditional && <Calendar className="h-2.5 w-2.5" />}
|
|
||||||
{timeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing ? (
|
|
||||||
/* ── EDITING ── */
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="bg-[#DCF8C6] max-w-[90%] w-full shadow-sm" style={{ borderRadius: "8px 0 8px 8px" }}>
|
|
||||||
<textarea ref={editorRef} value={editBody} onChange={e => setEditBody(e.target.value)}
|
|
||||||
className="w-full bg-transparent px-3 py-2 text-[13px] leading-[1.6] text-[#303030] resize-y outline-none min-h-[180px] font-mono" />
|
|
||||||
<div className="px-3 pb-2 flex items-center gap-2">
|
|
||||||
<button onClick={() => saveEdit(step)} disabled={saving}
|
|
||||||
className="bg-[#075E54] text-white px-3 py-1.5 text-[10px] font-bold flex items-center gap-1 disabled:opacity-50" style={{ borderRadius: "4px" }}>
|
|
||||||
{saving ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Check className="h-2.5 w-2.5" />} Save
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setEditing(null)} className="text-[10px] text-[#667781]">Cancel</button>
|
|
||||||
<span className="text-[9px] text-[#667781]/50 ml-auto">{"{{name}} {{amount}} {{payment_block}}"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : b ? (
|
|
||||||
/* ── A/B TEST — Two versions, stacked ── */
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
|
||||||
{/* Header bar */}
|
|
||||||
<div className="bg-[#075E54] text-white px-3 py-1.5 flex items-center gap-1.5" style={{ borderRadius: "8px 8px 0 0" }}>
|
|
||||||
<div className="w-1.5 h-1.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
|
||||||
<span className="text-[10px] font-bold flex-1">Testing two versions</span>
|
|
||||||
{hasEnoughData && winner && (
|
|
||||||
<span className="text-[9px] bg-[#16A34A]/20 text-[#4ADE80] px-1.5 py-0.5 font-bold flex items-center gap-0.5">
|
|
||||||
<Trophy className="h-2.5 w-2.5" /> {winner === "B" ? "New" : "Current"} winning
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!hasEnoughData && <span className="text-[9px] text-white/40">{progress}%</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Variant A — Current (full message) */}
|
|
||||||
<button onClick={() => startEdit(step)} className="w-full bg-[#DCF8C6] p-3 text-left hover:brightness-[0.97] transition-all border-b border-[#075E54]/10">
|
|
||||||
<div className="flex items-center gap-1 mb-2">
|
|
||||||
<span className="text-[9px] font-bold text-[#075E54] bg-[#075E54]/10 px-1.5 py-0.5">Current</span>
|
|
||||||
{a && a.sentCount > 0 && (
|
|
||||||
<span className={`text-[9px] font-bold ml-auto ${winner === "A" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
|
||||||
{rateA}% conversion{winner === "A" && " 🏆"} · {a.convertedCount}/{a.sentCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] leading-[1.5] text-[#303030]">
|
|
||||||
<WhatsAppFormatted text={previewA} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Variant B — New (full message) */}
|
|
||||||
<div className="bg-[#DCF8C6] p-3 relative group">
|
|
||||||
<div className="flex items-center gap-1 mb-2">
|
|
||||||
<span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1.5 py-0.5">
|
|
||||||
New
|
|
||||||
</span>
|
|
||||||
{b.sentCount > 0 && (
|
|
||||||
<span className={`text-[9px] font-bold ml-auto ${winner === "B" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
|
||||||
{rateB}% conversion{winner === "B" && " 🏆"} · {b.convertedCount}/{b.sentCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] leading-[1.5] text-[#303030]">
|
|
||||||
<WhatsAppFormatted text={previewB} />
|
|
||||||
</div>
|
|
||||||
{/* Regenerate */}
|
|
||||||
<button onClick={() => regenerateVariant(step)} disabled={isRegenning}
|
|
||||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 bg-white/90 p-1.5 shadow-sm transition-opacity hover:bg-white disabled:opacity-50"
|
|
||||||
style={{ borderRadius: "4px" }} title="Try a different version">
|
|
||||||
{isRegenning ? <Loader2 className="h-3.5 w-3.5 text-[#1E40AF] animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 text-[#1E40AF]" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="bg-white/60 px-3 py-1.5" style={{ borderRadius: "0 0 8px 8px" }}>
|
|
||||||
<div className="h-1 bg-gray-200 overflow-hidden" style={{ borderRadius: "2px" }}>
|
|
||||||
<div className={`h-full transition-all ${hasEnoughData ? "bg-[#16A34A]" : "bg-[#60A5FA]"}`} style={{ width: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="text-[9px] text-[#667781] mt-1">
|
|
||||||
{hasEnoughData
|
|
||||||
? winner ? `${winner === "B" ? "New" : "Current"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call — need more data"
|
|
||||||
: `${totalSent} of ${MIN_SAMPLE * 2} sends needed`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ── NORMAL MESSAGE (full, no truncation) ── */
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button onClick={() => startEdit(step)}
|
|
||||||
className="bg-[#DCF8C6] max-w-[88%] px-3 py-2 text-left text-[13px] leading-[1.5] text-[#303030] relative shadow-sm cursor-pointer hover:brightness-[0.97] transition-all"
|
|
||||||
style={{ borderRadius: "8px 0 8px 8px" }}>
|
|
||||||
{justSaved && (
|
|
||||||
<div className="absolute -top-2 -right-2 w-5 h-5 bg-[#25D366] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
|
||||||
<Check className="h-3 w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<WhatsAppFormatted text={previewA} />
|
|
||||||
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
|
|
||||||
<span className="text-[9px] text-[#667781]">{clockTime}</span>
|
|
||||||
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input bar */}
|
|
||||||
<div className="bg-[#F0F0F0] px-3 py-2 flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-white px-4 py-2 text-[11px] text-[#667781]" style={{ borderRadius: "20px" }}>
|
|
||||||
Donors can reply: PAID · HELP · CANCEL
|
|
||||||
</div>
|
|
||||||
<div className="w-9 h-9 bg-[#075E54] flex items-center justify-center shrink-0" style={{ borderRadius: "50%" }}>
|
|
||||||
<Send className="h-4 w-4 text-white ml-0.5" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Pick winners ── */}
|
{/* Performance — if there's data */}
|
||||||
{testsRunning > 0 && (
|
{totalSentAll > 0 && (
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="bg-white border border-gray-200">
|
||||||
<button onClick={pickWinnersAndContinue} disabled={aiWorking}
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
className="w-full bg-[#111827] text-white py-3 text-sm font-bold flex items-center justify-center gap-2 hover:bg-gray-800 transition-colors disabled:opacity-50">
|
<h3 className="text-sm font-bold text-[#111827]">Performance</h3>
|
||||||
{aiWorking
|
|
||||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Picking winners…</>
|
|
||||||
: <><Trophy className="h-3.5 w-3.5" /> Pick the best & test new ones</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Timing ── */}
|
|
||||||
<div className="max-w-lg mx-auto">
|
|
||||||
<button onClick={() => setShowTiming(!showTiming)}
|
|
||||||
className="flex items-center gap-2 text-[11px] text-gray-400 hover:text-gray-600 transition-colors py-1 font-semibold tracking-wide uppercase">
|
|
||||||
<Clock className="h-3 w-3" /> Change timing
|
|
||||||
<ChevronDown className={`h-3 w-3 transition-transform ${showTiming ? "rotate-180" : ""}`} />
|
|
||||||
</button>
|
|
||||||
{showTiming && (
|
|
||||||
<div className="grid grid-cols-3 gap-px bg-gray-200 mt-3">
|
|
||||||
{[
|
|
||||||
{ label: "First reminder", key: "step1Delay", value: config?.step1Delay || 2 },
|
|
||||||
{ label: "Second reminder", key: "step2Delay", value: config?.step2Delay || 7 },
|
|
||||||
{ label: "Final reminder", key: "step3Delay", value: config?.step3Delay || 14 },
|
|
||||||
].map(t => (
|
|
||||||
<div key={t.key} className="bg-white p-4">
|
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wide mb-2">{t.label}</p>
|
|
||||||
<select value={t.value} onChange={e => saveTiming(t.key, parseInt(e.target.value))}
|
|
||||||
className="w-full border-2 border-gray-200 px-2 py-1.5 text-xs font-bold text-[#111827] bg-white focus:border-[#1E40AF] outline-none">
|
|
||||||
{[1, 2, 3, 5, 7, 10, 14, 21, 28].map(d => <option key={d} value={d}>Day {d}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="grid grid-cols-3 gap-px bg-gray-200">
|
||||||
|
<div className="bg-white p-4 text-center">
|
||||||
|
<p className="text-xl font-black text-[#111827]">{totalSentAll}</p>
|
||||||
|
<p className="text-[9px] text-gray-500 mt-0.5">Messages sent</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 text-center">
|
||||||
|
<p className="text-xl font-black text-[#16A34A]">{totalConverted}</p>
|
||||||
|
<p className="text-[9px] text-gray-500 mt-0.5">Pledges collected</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 text-center">
|
||||||
|
<p className="text-xl font-black text-[#111827]">{overallRate}%</p>
|
||||||
|
<p className="text-[9px] text-gray-500 mt-0.5">Conversion rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing controls — always visible in the right column */}
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<button onClick={() => setShowTiming(!showTiming)}
|
||||||
|
className="w-full px-5 py-3 flex items-center justify-between text-left border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-bold text-[#111827]">Reminder timing</h3>
|
||||||
|
<ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${showTiming ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{showTiming ? (
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{[
|
||||||
|
{ label: "First reminder", key: "step1Delay", value: config?.step1Delay || 2, desc: "Gentle check-in" },
|
||||||
|
{ label: "Second reminder", key: "step2Delay", value: config?.step2Delay || 7, desc: "Shows impact of their gift" },
|
||||||
|
{ label: "Final reminder", key: "step3Delay", value: config?.step3Delay || 14, desc: "Last message — no guilt, easy cancel" },
|
||||||
|
].map(t => (
|
||||||
|
<div key={t.key} className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-[#111827]">{t.label}</p>
|
||||||
|
<p className="text-[10px] text-gray-400">{t.desc}</p>
|
||||||
|
</div>
|
||||||
|
<select value={t.value} onChange={e => saveTiming(t.key, parseInt(e.target.value))}
|
||||||
|
className="border-2 border-gray-200 px-2 py-1.5 text-xs font-bold text-[#111827] bg-white focus:border-[#1E40AF] outline-none w-24">
|
||||||
|
{[1, 2, 3, 5, 7, 10, 14, 21, 28].map(d => <option key={d} value={d}>Day {d}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-5 py-3 text-xs text-gray-400">
|
||||||
|
Day {config?.step1Delay || 2} → Day {config?.step2Delay || 7} → Day {config?.step3Delay || 14}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* What donors can reply */}
|
||||||
|
<div className="border-l-2 border-[#25D366] pl-4 space-y-2">
|
||||||
|
<p className="text-xs font-bold text-[#111827]">Donors can reply to any message</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{[
|
||||||
|
{ cmd: "PAID", desc: "Marks their pledge as \"said they paid\" — you confirm via bank statement" },
|
||||||
|
{ cmd: "HELP", desc: "Sends them your bank details and reference again" },
|
||||||
|
{ cmd: "CANCEL", desc: "Cancels their pledge and stops all messages immediately" },
|
||||||
|
].map(c => (
|
||||||
|
<div key={c.cmd} className="flex items-start gap-2">
|
||||||
|
<code className="text-[10px] font-mono font-bold text-[#111827] bg-gray-100 px-1.5 py-0.5 shrink-0">{c.cmd}</code>
|
||||||
|
<p className="text-[10px] text-gray-500 leading-relaxed">{c.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-1.5">
|
||||||
|
<p className="text-xs font-bold text-[#111827]">Tips for better collection rates</p>
|
||||||
|
<p className="text-[10px] text-gray-500">Keep messages short — 3 lines converts better than 10.</p>
|
||||||
|
<p className="text-[10px] text-gray-500">Always include the reference — donors need it to pay.</p>
|
||||||
|
<p className="text-[10px] text-gray-500">The final message should make cancelling as easy as paying — no guilt means more trust next time.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"
|
|||||||
import { formatPence } from "@/lib/utils"
|
import { formatPence } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail,
|
Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail,
|
||||||
Download, ExternalLink, Users, Trophy, ChevronDown, Link2,
|
Download, ExternalLink, Users, Trophy, Link2,
|
||||||
ArrowRight, QrCode as QrCodeIcon
|
ArrowRight, QrCode as QrCodeIcon
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
@@ -64,8 +64,6 @@ export default function CollectPage() {
|
|||||||
const [appealTarget, setAppealTarget] = useState("")
|
const [appealTarget, setAppealTarget] = useState("")
|
||||||
const [creatingAppeal, setCreatingAppeal] = useState(false)
|
const [creatingAppeal, setCreatingAppeal] = useState(false)
|
||||||
|
|
||||||
const [eventSwitcherOpen, setEventSwitcherOpen] = useState(false)
|
|
||||||
|
|
||||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
|
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
|
||||||
|
|
||||||
// Load events
|
// Load events
|
||||||
@@ -246,78 +244,100 @@ export default function CollectPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Appeal context (quiet for single, selector for multi) ── */}
|
{/* ── Appeals as visible cards (not hidden in a dropdown) ── */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
{events.length > 1 && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="space-y-2">
|
||||||
{events.length === 1 ? (
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-bold text-[#111827]">{activeEvent?.name}</p>
|
<h2 className="text-sm font-bold text-[#111827]">Your appeals ({events.length})</h2>
|
||||||
) : (
|
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
|
||||||
<div className="relative mt-1">
|
<Plus className="h-3 w-3" /> New appeal
|
||||||
<button
|
|
||||||
onClick={() => setEventSwitcherOpen(!eventSwitcherOpen)}
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-bold text-[#1E40AF] hover:underline"
|
|
||||||
>
|
|
||||||
{activeEvent?.name} <ChevronDown className={`h-3.5 w-3.5 transition-transform ${eventSwitcherOpen ? "rotate-180" : ""}`} />
|
|
||||||
</button>
|
|
||||||
{eventSwitcherOpen && (
|
|
||||||
<div className="absolute z-20 mt-1 bg-white border border-gray-200 shadow-lg w-72 max-h-64 overflow-y-auto">
|
|
||||||
{events.map(ev => (
|
|
||||||
<button
|
|
||||||
key={ev.id}
|
|
||||||
onClick={() => { setActiveEventId(ev.id); setEventSwitcherOpen(false) }}
|
|
||||||
className={`w-full text-left px-4 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors ${ev.id === activeEventId ? "bg-[#1E40AF]/5" : ""}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[#111827]">{ev.name}</p>
|
|
||||||
<p className="text-[10px] text-gray-500">{ev.pledgeCount} pledges · {formatPence(ev.totalPledged)}</p>
|
|
||||||
</div>
|
|
||||||
{ev.id === activeEventId && <Check className="h-4 w-4 text-[#1E40AF]" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowNewAppeal(true); setEventSwitcherOpen(false) }}
|
|
||||||
className="w-full text-left px-4 py-3 border-t border-gray-100 text-sm font-semibold text-[#1E40AF] hover:bg-gray-50 flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" /> New appeal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{events.length === 1 && (
|
|
||||||
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-gray-500 hover:text-[#111827] border border-gray-200 px-3 py-1.5 transition-colors">
|
|
||||||
+ New appeal
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-gray-200">
|
||||||
onClick={() => setShowCreate(true)}
|
{events.map(ev => {
|
||||||
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
|
const isSelected = ev.id === activeEventId
|
||||||
>
|
const rate = ev.totalPledged > 0 ? Math.round((ev.totalCollected / ev.totalPledged) * 100) : 0
|
||||||
<Plus className="h-4 w-4" /> New Link
|
const platformLabel = ev.externalPlatform
|
||||||
</button>
|
? ev.externalPlatform.charAt(0).toUpperCase() + ev.externalPlatform.slice(1)
|
||||||
</div>
|
: ev.paymentMode === "self" ? "Bank transfer" : "Bank transfer"
|
||||||
</div>
|
return (
|
||||||
|
<button
|
||||||
{/* ── Appeal stats (compact — the appeal is context, not hero) ── */}
|
key={ev.id}
|
||||||
{activeEvent && (
|
onClick={() => setActiveEventId(ev.id)}
|
||||||
<div className="grid grid-cols-4 gap-px bg-gray-200">
|
className={`bg-white p-4 text-left hover:bg-gray-50 transition-colors ${isSelected ? "border-l-2 border-[#1E40AF]" : ""}`}
|
||||||
{[
|
>
|
||||||
{ value: String(activeEvent.pledgeCount), label: "Pledges" },
|
<div className="flex items-start justify-between gap-2">
|
||||||
{ value: formatPence(activeEvent.totalPledged), label: "Promised" },
|
<div className="min-w-0">
|
||||||
{ value: formatPence(activeEvent.totalCollected), label: "Received", accent: "text-[#16A34A]" },
|
<p className={`text-sm font-bold truncate ${isSelected ? "text-[#1E40AF]" : "text-[#111827]"}`}>{ev.name}</p>
|
||||||
{ value: String(sources.length), label: "Links" },
|
<p className="text-[10px] text-gray-500 mt-0.5">{platformLabel}</p>
|
||||||
].map(stat => (
|
</div>
|
||||||
<div key={stat.label} className="bg-white p-3 md:p-4">
|
{isSelected && <div className="w-2 h-2 bg-[#1E40AF] shrink-0 mt-1.5" />}
|
||||||
<p className={`text-lg md:text-xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
|
</div>
|
||||||
<p className="text-[10px] text-gray-500">{stat.label}</p>
|
<div className="flex items-center gap-3 mt-3">
|
||||||
</div>
|
<div>
|
||||||
))}
|
<p className="text-lg font-black text-[#111827]">{formatPence(ev.totalPledged)}</p>
|
||||||
|
<p className="text-[9px] text-gray-400">raised</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-auto">
|
||||||
|
<p className="text-xs font-bold text-[#111827]">{ev.pledgeCount} pledges</p>
|
||||||
|
<p className="text-[9px] text-gray-400">{rate}% collected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Single appeal: show name + new appeal button */}
|
||||||
|
{events.length === 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-bold text-[#111827]">{activeEvent?.name}</p>
|
||||||
|
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-gray-400 hover:text-[#111827] border border-gray-200 px-3 py-1.5 transition-colors">
|
||||||
|
+ New appeal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Appeal stats + payment method clarity ── */}
|
||||||
|
{activeEvent && (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-4 gap-px bg-gray-200">
|
||||||
|
{[
|
||||||
|
{ value: String(activeEvent.pledgeCount), label: "Pledges" },
|
||||||
|
{ value: formatPence(activeEvent.totalPledged), label: "Promised" },
|
||||||
|
{ value: formatPence(activeEvent.totalCollected), label: "Received", accent: "text-[#16A34A]" },
|
||||||
|
{ value: String(sources.length), label: "Links" },
|
||||||
|
].map(stat => (
|
||||||
|
<div key={stat.label} className="bg-white p-3 md:p-4">
|
||||||
|
<p className={`text-lg md:text-xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
|
||||||
|
<p className="text-[10px] text-gray-500">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Payment method indicator — so users know how their platform works */}
|
||||||
|
{activeEvent.externalPlatform && activeEvent.externalUrl && (
|
||||||
|
<div className="border-l-2 border-[#1E40AF] bg-[#1E40AF]/5 p-3 mt-2 flex items-center gap-2">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-[#1E40AF] shrink-0" />
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Donors are redirected to <strong className="text-[#111827]">{activeEvent.externalPlatform.charAt(0).toUpperCase() + activeEvent.externalPlatform.slice(1)}</strong> to pay
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── New Link button ── */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> New Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Inline "create link" — fast, no dialog ── */}
|
{/* ── Inline "create link" — fast, no dialog ── */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
|
<div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
|
||||||
@@ -434,18 +454,54 @@ export default function CollectPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Tips (only show when they have links but few pledges) ── */}
|
{/* ── How it works — landing page style education ── */}
|
||||||
{sources.length > 0 && sources.reduce((s, l) => s + l.pledgeCount, 0) < 5 && (
|
<div className="grid md:grid-cols-2 gap-6 mt-2">
|
||||||
|
{/* Tips */}
|
||||||
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
|
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
|
||||||
<p className="text-xs font-bold text-[#111827]">Tips to get more pledges</p>
|
<p className="text-xs font-bold text-[#111827]">Where to share your link</p>
|
||||||
<ul className="text-xs text-gray-600 space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">01</span> Give each volunteer their own link — friendly competition works</li>
|
{[
|
||||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">02</span> Put the QR code on each table at your event</li>
|
{ n: "01", text: "Print the QR code on each table at your event" },
|
||||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">03</span> Share directly to WhatsApp groups — it takes 1 tap for them to pledge</li>
|
{ n: "02", text: "Send the link to WhatsApp groups — one tap to pledge" },
|
||||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">04</span> Post the link on your Instagram or Facebook story</li>
|
{ n: "03", text: "Post it on Instagram or Facebook stories" },
|
||||||
</ul>
|
{ 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 => (
|
||||||
|
<div key={t.n} className="flex items-start gap-2">
|
||||||
|
<span className="text-[#1E40AF] font-bold text-xs shrink-0">{t.n}</span>
|
||||||
|
<p className="text-xs text-gray-600">{t.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* How platforms work */}
|
||||||
|
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-2">
|
||||||
|
<p className="text-xs font-bold text-[#111827]">How payment works</p>
|
||||||
|
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||||
|
When someone pledges, they see your payment details with a unique reference.
|
||||||
|
Depending on how you set up your appeal:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={p.label} className="flex items-start gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-[#F59E0B] shrink-0 mt-1.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-[#111827]">{p.label}</p>
|
||||||
|
<p className="text-[10px] text-gray-500">{p.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400">
|
||||||
|
Set your payment method when creating an appeal. You can change it anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── New appeal inline form ── */}
|
{/* ── New appeal inline form ── */}
|
||||||
{showNewAppeal && <NewAppealForm
|
{showNewAppeal && <NewAppealForm
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
const OPENAI_KEY = process.env.OPENAI_API_KEY
|
const OPENAI_KEY = process.env.OPENAI_API_KEY
|
||||||
const GEMINI_KEY = process.env.GEMINI_API_KEY
|
const GEMINI_KEY = process.env.GEMINI_API_KEY
|
||||||
const HAS_AI = !!(OPENAI_KEY || GEMINI_KEY)
|
const HAS_AI = !!(OPENAI_KEY || GEMINI_KEY)
|
||||||
const OPENAI_MODEL = "gpt-4.1-nano" // ~$0.10/1M input, ~$0.40/1M output
|
const OPENAI_MODEL = "gpt-4.1-nano"
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: "system" | "user" | "assistant"
|
role: "system" | "user" | "assistant"
|
||||||
@@ -17,7 +17,7 @@ interface ChatMessage {
|
|||||||
async function chat(messages: ChatMessage[], maxTokens = 300): Promise<string> {
|
async function chat(messages: ChatMessage[], maxTokens = 300): Promise<string> {
|
||||||
if (!HAS_AI) return ""
|
if (!HAS_AI) return ""
|
||||||
|
|
||||||
// Prefer OpenAI (gpt-4.1-nano), fall back to Gemini
|
// Prefer OpenAI, fall back to Gemini
|
||||||
if (OPENAI_KEY) return chatOpenAI(messages, maxTokens)
|
if (OPENAI_KEY) return chatOpenAI(messages, maxTokens)
|
||||||
return chatGemini(messages, maxTokens)
|
return chatGemini(messages, maxTokens)
|
||||||
}
|
}
|
||||||
|
|||||||
277
temp_files/care/EditCustomer.php
Normal file
277
temp_files/care/EditCustomer.php
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CustomerResource\Pages;
|
||||||
|
|
||||||
|
use App\Definitions\PaymentProviders;
|
||||||
|
use App\Filament\Resources\CustomerResource;
|
||||||
|
use App\Filament\Resources\DonationResource;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\StripeRefundService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
class EditCustomer extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = CustomerResource::class;
|
||||||
|
|
||||||
|
// ─── Heading: Show who this person IS, not just a name ───────
|
||||||
|
|
||||||
|
public function getHeading(): string|HtmlString
|
||||||
|
{
|
||||||
|
$customer = $this->record;
|
||||||
|
$total = $customer->donations()
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->sum('amount') / 100;
|
||||||
|
|
||||||
|
$giftAid = $customer->donations()
|
||||||
|
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$badges = '';
|
||||||
|
if ($total >= 1000) {
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-2">⭐ Major Donor</span>';
|
||||||
|
}
|
||||||
|
if ($giftAid) {
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2">Gift Aid</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sg = $customer->scheduledGivingDonations()->where('is_active', true)->first();
|
||||||
|
if ($sg) {
|
||||||
|
$amt = '£' . number_format($sg->total_amount / 100, 0);
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">💙 ' . $amt . '/night</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly donations (reoccurrence=2)
|
||||||
|
$monthly = $customer->donations()
|
||||||
|
->where('reoccurrence', 2)
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->first();
|
||||||
|
if ($monthly) {
|
||||||
|
$mAmt = '£' . number_format($monthly->amount / 100, 0);
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 ml-2">🔄 ' . $mAmt . '/month</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent incomplete donations
|
||||||
|
$incompleteRecent = $customer->donations()
|
||||||
|
->whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->where('created_at', '>=', now()->subDays(7))
|
||||||
|
->count();
|
||||||
|
if ($incompleteRecent > 0) {
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 ml-2">⚠ ' . $incompleteRecent . ' incomplete</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HtmlString(e($customer->name) . $badges);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Subheading: The one-line story of this donor ────────────
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
$customer = $this->record;
|
||||||
|
$confirmed = $customer->donations()
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'));
|
||||||
|
$total = $confirmed->sum('amount') / 100;
|
||||||
|
$count = $confirmed->count();
|
||||||
|
$first = $customer->donations()->oldest()->first();
|
||||||
|
$since = $first ? $first->created_at->format('M Y') : null;
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
if ($total > 0) {
|
||||||
|
$parts[] = '£' . number_format($total, 2) . ' donated across ' . $count . ' donations';
|
||||||
|
} else {
|
||||||
|
$parts[] = 'No confirmed donations yet';
|
||||||
|
}
|
||||||
|
if ($since) {
|
||||||
|
$parts[] = 'Supporter since ' . $since;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Header Actions ─────────────────────────────────────────
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$customer = $this->record;
|
||||||
|
|
||||||
|
// Pre-compute recurring counts for visibility checks
|
||||||
|
$activeScheduled = $customer->scheduledGivingDonations()
|
||||||
|
->where('is_active', true)->count();
|
||||||
|
|
||||||
|
$activeMonthly = $customer->donations()
|
||||||
|
->where('reoccurrence', 2)
|
||||||
|
->where('provider_type', PaymentProviders::STRIPE)
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$hasRecurring = $activeScheduled > 0 || $activeMonthly > 0;
|
||||||
|
|
||||||
|
return array_values(array_filter([
|
||||||
|
|
||||||
|
// ── CANCEL ALL RECURRING ────────────────────────────
|
||||||
|
$hasRecurring ? Action::make('cancel_all_recurring')
|
||||||
|
->label('Cancel All Recurring')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalIcon('heroicon-o-exclamation-triangle')
|
||||||
|
->modalHeading('Cancel All Recurring Giving')
|
||||||
|
->modalDescription(function () use ($customer, $activeScheduled, $activeMonthly) {
|
||||||
|
$parts = [];
|
||||||
|
if ($activeScheduled > 0) {
|
||||||
|
$sgTotal = $customer->scheduledGivingDonations()
|
||||||
|
->where('is_active', true)->sum('total_amount') / 100;
|
||||||
|
$parts[] = $activeScheduled . ' regular giving '
|
||||||
|
. ($activeScheduled === 1 ? 'subscription' : 'subscriptions')
|
||||||
|
. ' (£' . number_format($sgTotal, 2) . ' total)';
|
||||||
|
}
|
||||||
|
if ($activeMonthly > 0) {
|
||||||
|
$parts[] = $activeMonthly . ' monthly '
|
||||||
|
. ($activeMonthly === 1 ? 'donation' : 'donations');
|
||||||
|
}
|
||||||
|
|
||||||
|
return "This will cancel:\n"
|
||||||
|
. implode("\n", array_map(fn ($p) => "• {$p}", $parts))
|
||||||
|
. "\n\nAll Stripe payment methods will be detached. "
|
||||||
|
. "Previously collected payments will NOT be refunded.";
|
||||||
|
})
|
||||||
|
->modalSubmitActionLabel('Cancel All Recurring')
|
||||||
|
->action(function () use ($customer) {
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$cancelled = 0;
|
||||||
|
|
||||||
|
// Cancel active scheduled giving
|
||||||
|
$activeGiving = $customer->scheduledGivingDonations()
|
||||||
|
->where('is_active', true)->get();
|
||||||
|
foreach ($activeGiving as $sg) {
|
||||||
|
$sg->update(['is_active' => false]);
|
||||||
|
if ($sg->stripe_setup_intent_id) {
|
||||||
|
$service->detachPaymentMethod($sg->stripe_setup_intent_id);
|
||||||
|
}
|
||||||
|
if (method_exists($sg, 'internalNotes')) {
|
||||||
|
$sg->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Cancelled via donor profile "Cancel All Recurring".',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$cancelled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel monthly Stripe donations
|
||||||
|
$monthlyDonations = $customer->donations()
|
||||||
|
->where('reoccurrence', 2)
|
||||||
|
->where('provider_type', PaymentProviders::STRIPE)
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->get();
|
||||||
|
foreach ($monthlyDonations as $d) {
|
||||||
|
$ref = $d->provider_reference ?? '';
|
||||||
|
if (str_starts_with($ref, 'seti_')) {
|
||||||
|
$service->detachPaymentMethod($ref);
|
||||||
|
}
|
||||||
|
$d->donationConfirmation?->update(['confirmed_at' => null]);
|
||||||
|
$d->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Monthly giving cancelled via donor profile "Cancel All Recurring".',
|
||||||
|
]);
|
||||||
|
$cancelled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log on the customer too
|
||||||
|
$customer->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => "All recurring giving cancelled ({$cancelled} items). Payment methods detached from Stripe.",
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title("Cancelled {$cancelled} recurring " . ($cancelled === 1 ? 'item' : 'items'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── ADD NOTE ────────────────────────────────────────
|
||||||
|
Action::make('add_note')
|
||||||
|
->label('Add Note')
|
||||||
|
->icon('heroicon-o-chat-bubble-left-ellipsis')
|
||||||
|
->color('gray')
|
||||||
|
->form([
|
||||||
|
Textarea::make('body')
|
||||||
|
->label('Note')
|
||||||
|
->placeholder('e.g. Called on ' . now()->format('d M') . ' — wants to update their address')
|
||||||
|
->required()
|
||||||
|
->rows(3),
|
||||||
|
])
|
||||||
|
->action(function (array $data) use ($customer) {
|
||||||
|
$customer->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => $data['body'],
|
||||||
|
]);
|
||||||
|
Notification::make()->title('Note added')->success()->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── RESEND RECEIPT ──────────────────────────────────
|
||||||
|
$customer->donations()
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->exists()
|
||||||
|
? Action::make('resend_receipt')
|
||||||
|
->label('Resend Receipt')
|
||||||
|
->icon('heroicon-o-envelope')
|
||||||
|
->color('info')
|
||||||
|
->form([
|
||||||
|
Select::make('donation_id')
|
||||||
|
->label('Which donation?')
|
||||||
|
->options(function () use ($customer) {
|
||||||
|
return $customer->donations()
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->latest()
|
||||||
|
->take(10)
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function ($d) {
|
||||||
|
$label = '£' . number_format($d->amount / 100, 2)
|
||||||
|
. ' on ' . $d->created_at->format('d M Y')
|
||||||
|
. ' — ' . ($d->donationType?->display_name ?? 'Unknown');
|
||||||
|
return [$d->id => $label];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->required()
|
||||||
|
->helperText('Select the donation to resend the receipt for'),
|
||||||
|
])
|
||||||
|
->action(function (array $data) use ($customer) {
|
||||||
|
$donation = $customer->donations()->find($data['donation_id']);
|
||||||
|
if ($donation) {
|
||||||
|
try {
|
||||||
|
Mail::to($customer->email)
|
||||||
|
->send(new \App\Mail\DonationConfirmed($donation));
|
||||||
|
Notification::make()
|
||||||
|
->title('Receipt sent to ' . $customer->email)
|
||||||
|
->body('For £' . number_format($donation->amount / 100, 2) . ' on ' . $donation->created_at->format('d M Y'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── VIEW IN STRIPE ──────────────────────────────────
|
||||||
|
Action::make('view_in_stripe')
|
||||||
|
->label('Stripe')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url('https://dashboard.stripe.com/search?query=' . urlencode($customer->email))
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
|
||||||
|
// ── EMAIL ───────────────────────────────────────────
|
||||||
|
$customer->email ? Action::make('email_donor')
|
||||||
|
->label('Email')
|
||||||
|
->icon('heroicon-o-at-symbol')
|
||||||
|
->color('gray')
|
||||||
|
->url('mailto:' . $customer->email)
|
||||||
|
->openUrlInNewTab() : null,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
374
temp_files/care/EditDonation.php
Normal file
374
temp_files/care/EditDonation.php
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DonationResource\Pages;
|
||||||
|
|
||||||
|
use App\Definitions\PaymentProviders;
|
||||||
|
use App\Filament\Resources\CustomerResource;
|
||||||
|
use App\Filament\Resources\DonationResource;
|
||||||
|
use App\Mail\DonationConfirmed;
|
||||||
|
use App\Models\Donation;
|
||||||
|
use App\Services\StripeRefundService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Donation detail page — supporter care command centre.
|
||||||
|
*
|
||||||
|
* Design: read-only view + action buttons. No inline editing of
|
||||||
|
* donation data — all changes go through explicit actions with
|
||||||
|
* confirmation modals and audit logging.
|
||||||
|
*
|
||||||
|
* @property Donation $record
|
||||||
|
*/
|
||||||
|
class EditDonation extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = DonationResource::class;
|
||||||
|
|
||||||
|
// ── Heading: one-glance context ─────────────────────────────
|
||||||
|
|
||||||
|
public function getHeading(): string|HtmlString
|
||||||
|
{
|
||||||
|
$d = $this->record;
|
||||||
|
|
||||||
|
$status = $d->isConfirmed()
|
||||||
|
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">✓ Confirmed</span>'
|
||||||
|
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">✗ Incomplete</span>';
|
||||||
|
|
||||||
|
$amount = '£' . number_format($d->amount / 100, 2);
|
||||||
|
$provider = PaymentProviders::translate($d->provider_type);
|
||||||
|
|
||||||
|
$badges = $status;
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 ml-1">' . $provider . '</span>';
|
||||||
|
|
||||||
|
if ($d->isGiftAid()) {
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 ml-1">Gift Aid</span>';
|
||||||
|
}
|
||||||
|
if ($d->isZakat()) {
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700 ml-1">Zakat</span>';
|
||||||
|
}
|
||||||
|
if ($d->reoccurrence !== -1) {
|
||||||
|
$badges .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-1">Recurring</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HtmlString("{$amount} — " . e($d->customer?->name ?? 'Unknown donor') . '<div class="mt-1">' . $badges . '</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
$d = $this->record;
|
||||||
|
$parts = [];
|
||||||
|
$parts[] = $d->donationType?->display_name ?? 'Unknown cause';
|
||||||
|
$parts[] = $d->created_at?->format('d M Y H:i') . ' (' . $d->created_at?->diffForHumans() . ')';
|
||||||
|
if ($d->appeal) {
|
||||||
|
$parts[] = 'Fundraiser: ' . $d->appeal->name;
|
||||||
|
}
|
||||||
|
if ($d->reference_code) {
|
||||||
|
$parts[] = 'Ref: ' . $d->reference_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header Actions ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Priority order:
|
||||||
|
// 1. Refund (Stripe PI) — the #1 support request
|
||||||
|
// 2. Cancel Recurring (Stripe SetupIntent monthly)
|
||||||
|
// 3. Confirm / Unconfirm
|
||||||
|
// 4. Resend Receipt
|
||||||
|
// 5. Stripe Status check
|
||||||
|
// 6. Open in Stripe / View Donor / Email
|
||||||
|
//
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$donation = $this->record;
|
||||||
|
|
||||||
|
$isStripe = $donation->provider_type === PaymentProviders::STRIPE;
|
||||||
|
$isPayPal = $donation->provider_type === PaymentProviders::PAYPAL;
|
||||||
|
$isGoCardless = $donation->provider_type === PaymentProviders::GOCARDLESS;
|
||||||
|
$ref = $donation->provider_reference ?? '';
|
||||||
|
$hasPI = $isStripe && str_starts_with($ref, 'pi_');
|
||||||
|
$hasSI = $isStripe && str_starts_with($ref, 'seti_');
|
||||||
|
|
||||||
|
return array_values(array_filter([
|
||||||
|
|
||||||
|
// ── REFUND (Stripe PaymentIntent) ───────────────────
|
||||||
|
$hasPI && $donation->isConfirmed() ? Action::make('refund')
|
||||||
|
->label('Refund')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalIcon('heroicon-o-arrow-uturn-left')
|
||||||
|
->modalHeading('Refund Donation')
|
||||||
|
->modalDescription(
|
||||||
|
'Refund £' . number_format($donation->amount / 100, 2)
|
||||||
|
. ' to ' . e($donation->customer?->name ?? 'donor')
|
||||||
|
. '\'s card via Stripe. This cannot be undone.'
|
||||||
|
)
|
||||||
|
->modalSubmitActionLabel('Process Refund')
|
||||||
|
->form([
|
||||||
|
TextInput::make('refund_amount')
|
||||||
|
->label('Refund amount (£)')
|
||||||
|
->numeric()
|
||||||
|
->default(number_format($donation->amount / 100, 2, '.', ''))
|
||||||
|
->required()
|
||||||
|
->minValue(0.01)
|
||||||
|
->maxValue($donation->amount / 100)
|
||||||
|
->step(0.01)
|
||||||
|
->helperText('Full amount for complete refund. Reduce for partial.'),
|
||||||
|
])
|
||||||
|
->action(function (array $data) {
|
||||||
|
$donation = $this->record;
|
||||||
|
$amountPence = (int) round($data['refund_amount'] * 100);
|
||||||
|
$isPartial = $amountPence < $donation->amount;
|
||||||
|
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$result = $service->refundPaymentIntent(
|
||||||
|
$donation->provider_reference,
|
||||||
|
$isPartial ? $amountPence : null,
|
||||||
|
'Admin refund by ' . auth()->user()?->name
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
if (! $isPartial) {
|
||||||
|
$donation->donationConfirmation?->update(['confirmed_at' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => ($isPartial ? 'Partial' : 'Full') . ' refund of £'
|
||||||
|
. number_format($amountPence / 100, 2)
|
||||||
|
. ' processed via Stripe. Refund ID: ' . $result['refund_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Refund processed')
|
||||||
|
->body('£' . number_format($result['amount'] / 100, 2) . ' refunded. ID: ' . $result['refund_id'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Refund failed')
|
||||||
|
->body($result['error'])
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── CANCEL RECURRING (SetupIntent monthly) ──────────
|
||||||
|
$hasSI && $donation->isConfirmed() ? Action::make('cancel_recurring')
|
||||||
|
->label('Cancel Recurring')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalIcon('heroicon-o-x-circle')
|
||||||
|
->modalHeading('Cancel Monthly Giving')
|
||||||
|
->modalDescription(
|
||||||
|
'This will detach the payment method from Stripe, preventing '
|
||||||
|
. 'all future charges for ' . e($donation->customer?->name ?? 'this donor')
|
||||||
|
. '. Previously collected payments will NOT be refunded.'
|
||||||
|
)
|
||||||
|
->modalSubmitActionLabel('Cancel Monthly Giving')
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$result = $service->detachPaymentMethod($donation->provider_reference);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$donation->donationConfirmation?->update(['confirmed_at' => null]);
|
||||||
|
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Monthly giving cancelled. ' . ($result['message'] ?? 'Payment method detached from Stripe.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Monthly giving cancelled')
|
||||||
|
->body($result['message'] ?? 'Payment method detached')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cancellation failed')
|
||||||
|
->body($result['error'])
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── OPEN PAYPAL ─────────────────────────────────────
|
||||||
|
$isPayPal && $ref ? Action::make('open_paypal')
|
||||||
|
->label('Open PayPal')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('warning')
|
||||||
|
->url('https://www.paypal.com/activity/payment/' . urlencode($ref))
|
||||||
|
->openUrlInNewTab() : null,
|
||||||
|
|
||||||
|
// ── OPEN GOCARDLESS ─────────────────────────────────
|
||||||
|
$isGoCardless && $ref ? Action::make('open_gocardless')
|
||||||
|
->label('Open GoCardless')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('warning')
|
||||||
|
->url('https://manage.gocardless.com/payments/' . urlencode($ref))
|
||||||
|
->openUrlInNewTab() : null,
|
||||||
|
|
||||||
|
// ── UNCONFIRM ───────────────────────────────────────
|
||||||
|
$donation->isConfirmed() ? Action::make('unconfirm')
|
||||||
|
->label('Unconfirm')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Unconfirm Donation')
|
||||||
|
->modalDescription(
|
||||||
|
'Mark this donation as incomplete. This does NOT refund money '
|
||||||
|
. 'on Stripe/PayPal — use the Refund button for that.'
|
||||||
|
)
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
$donation->donationConfirmation?->update(['confirmed_at' => null]);
|
||||||
|
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Donation manually unconfirmed by admin.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->title('Donation unconfirmed')->warning()->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── CONFIRM ─────────────────────────────────────────
|
||||||
|
! $donation->isConfirmed() ? Action::make('confirm')
|
||||||
|
->label('Confirm')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Confirm Donation')
|
||||||
|
->modalDescription(
|
||||||
|
'Mark this donation as confirmed. Only do this if you have '
|
||||||
|
. 'verified the payment was received.'
|
||||||
|
)
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
$donation->confirm();
|
||||||
|
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Donation manually confirmed by admin.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->title('Donation confirmed')->success()->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── RESEND RECEIPT ──────────────────────────────────
|
||||||
|
$donation->isConfirmed() && $donation->customer?->email
|
||||||
|
? Action::make('resend_receipt')
|
||||||
|
->label('Receipt')
|
||||||
|
->icon('heroicon-o-envelope')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Send receipt to ' . $donation->customer->email)
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
try {
|
||||||
|
Mail::to($donation->customer->email)
|
||||||
|
->send(new DonationConfirmed($donation));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Receipt sent to ' . $donation->customer->email)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error($e);
|
||||||
|
Notification::make()
|
||||||
|
->title('Failed to send receipt')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── STRIPE STATUS ───────────────────────────────────
|
||||||
|
($hasPI || $hasSI) ? Action::make('check_stripe')
|
||||||
|
->label('Stripe Status')
|
||||||
|
->icon('heroicon-o-magnifying-glass')
|
||||||
|
->color('gray')
|
||||||
|
->action(function () use ($hasPI) {
|
||||||
|
$donation = $this->record;
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$details = $hasPI
|
||||||
|
? $service->getPaymentDetails($donation->provider_reference)
|
||||||
|
: $service->getSetupIntentDetails($donation->provider_reference);
|
||||||
|
|
||||||
|
if (! $details) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Could not retrieve Stripe details')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
foreach ($details as $key => $val) {
|
||||||
|
if ($val === null || $val === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = str_replace('_', ' ', ucfirst($key));
|
||||||
|
if (is_bool($val)) {
|
||||||
|
$val = $val ? 'Yes' : 'No';
|
||||||
|
}
|
||||||
|
if ($key === 'amount' || $key === 'amount_refunded') {
|
||||||
|
$val = '£' . number_format($val / 100, 2);
|
||||||
|
}
|
||||||
|
$lines[] = "{$label}: {$val}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Stripe Details')
|
||||||
|
->body(implode("\n", $lines))
|
||||||
|
->info()
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── OPEN IN STRIPE ──────────────────────────────────
|
||||||
|
$isStripe && $ref ? Action::make('open_stripe')
|
||||||
|
->label('Stripe ↗')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url('https://dashboard.stripe.com/search?query=' . urlencode($ref))
|
||||||
|
->openUrlInNewTab() : null,
|
||||||
|
|
||||||
|
// ── VIEW DONOR ──────────────────────────────────────
|
||||||
|
$donation->customer_id ? Action::make('view_donor')
|
||||||
|
->label('Donor')
|
||||||
|
->icon('heroicon-o-user')
|
||||||
|
->color('gray')
|
||||||
|
->url(CustomerResource::getUrl('edit', ['record' => $donation->customer_id]))
|
||||||
|
: null,
|
||||||
|
|
||||||
|
// ── EMAIL ───────────────────────────────────────────
|
||||||
|
$donation->customer?->email ? Action::make('email')
|
||||||
|
->label('Email')
|
||||||
|
->icon('heroicon-o-at-symbol')
|
||||||
|
->color('gray')
|
||||||
|
->url('mailto:' . $donation->customer->email)
|
||||||
|
->openUrlInNewTab() : null,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide save/cancel — donations are not directly editable
|
||||||
|
protected function getSaveFormAction(): Action
|
||||||
|
{
|
||||||
|
return parent::getSaveFormAction()->visible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCancelFormAction(): Action
|
||||||
|
{
|
||||||
|
return parent::getCancelFormAction()->visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
313
temp_files/care/EditScheduledGivingDonation.php
Normal file
313
temp_files/care/EditScheduledGivingDonation.php
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AppealResource\Pages\EditAppeal;
|
||||||
|
use App\Filament\Resources\CustomerResource\Pages\EditCustomer;
|
||||||
|
use App\Filament\Resources\ScheduledGivingDonationResource;
|
||||||
|
use App\Models\ScheduledGivingDonation;
|
||||||
|
use App\Services\StripeRefundService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled/Regular Giving detail page — supporter care.
|
||||||
|
*
|
||||||
|
* Shows payment progress, card info, and provides cancel/refund
|
||||||
|
* actions that sync with Stripe.
|
||||||
|
*
|
||||||
|
* @property ScheduledGivingDonation $record
|
||||||
|
*/
|
||||||
|
class EditScheduledGivingDonation extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ScheduledGivingDonationResource::class;
|
||||||
|
|
||||||
|
// ── Heading: status + progress at a glance ──────────────────
|
||||||
|
|
||||||
|
public function getHeading(): string|HtmlString
|
||||||
|
{
|
||||||
|
$d = $this->record;
|
||||||
|
|
||||||
|
$status = $d->is_active
|
||||||
|
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">● Active</span>'
|
||||||
|
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">● Cancelled</span>';
|
||||||
|
|
||||||
|
$amount = '£' . number_format($d->total_amount / 100, 2);
|
||||||
|
|
||||||
|
// Payment progress
|
||||||
|
$totalPayments = $d->payments()->count();
|
||||||
|
$paidPayments = $d->payments()->where('is_paid', true)->count();
|
||||||
|
$paidTotal = $d->payments()->where('is_paid', true)->sum('amount') / 100;
|
||||||
|
|
||||||
|
$progress = $totalPayments > 0
|
||||||
|
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-1">'
|
||||||
|
. $paidPayments . '/' . $totalPayments . ' payments · £' . number_format($paidTotal, 2) . ' collected</span>'
|
||||||
|
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 ml-1">No payments generated</span>';
|
||||||
|
|
||||||
|
if ($d->is_zakat) {
|
||||||
|
$progress .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700 ml-1">Zakat</span>';
|
||||||
|
}
|
||||||
|
if ($d->is_gift_aid) {
|
||||||
|
$progress .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 ml-1">Gift Aid</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HtmlString(
|
||||||
|
$amount . '/night — ' . e($d->customer?->name ?? 'Unknown')
|
||||||
|
. '<div class="mt-1">' . $status . ' ' . $progress . '</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
$d = $this->record;
|
||||||
|
$parts = [];
|
||||||
|
$parts[] = $d->scheduledGivingCampaign?->title ?? 'Unknown campaign';
|
||||||
|
$parts[] = 'Started ' . $d->created_at?->format('d M Y');
|
||||||
|
if ($d->customer?->email) {
|
||||||
|
$parts[] = $d->customer->email;
|
||||||
|
}
|
||||||
|
if ($d->reference_code) {
|
||||||
|
$parts[] = 'Ref: ' . $d->reference_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header Actions ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Priority order:
|
||||||
|
// 1. Cancel (deactivate + detach card)
|
||||||
|
// 2. Cancel & Refund All
|
||||||
|
// 3. Activate
|
||||||
|
// 4. Stripe Status
|
||||||
|
// 5. Open in Stripe / View Donor / View Appeal / Email
|
||||||
|
//
|
||||||
|
|
||||||
|
public function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$donation = $this->record;
|
||||||
|
$hasStripe = (bool) $donation->stripe_setup_intent_id;
|
||||||
|
|
||||||
|
$paidCount = $donation->payments()
|
||||||
|
->where('is_paid', true)
|
||||||
|
->whereNotNull('stripe_payment_intent_id')
|
||||||
|
->where('stripe_payment_intent_id', '!=', 'SKIPPED')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$paidTotal = $donation->payments()
|
||||||
|
->where('is_paid', true)
|
||||||
|
->whereNotNull('stripe_payment_intent_id')
|
||||||
|
->where('stripe_payment_intent_id', '!=', 'SKIPPED')
|
||||||
|
->sum('amount') / 100;
|
||||||
|
|
||||||
|
return array_values(array_filter([
|
||||||
|
|
||||||
|
// ── CANCEL ──────────────────────────────────────────
|
||||||
|
$donation->is_active ? Action::make('cancel')
|
||||||
|
->label('Cancel')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalIcon('heroicon-o-x-circle')
|
||||||
|
->modalHeading('Cancel Regular Giving')
|
||||||
|
->modalDescription(
|
||||||
|
'This will deactivate ' . e($donation->customer?->name ?? 'this donor') . '\'s regular giving.'
|
||||||
|
. ($hasStripe ? "\n\nThe payment method will be detached from Stripe, preventing any future charges." : '')
|
||||||
|
. "\n\nPreviously collected payments will NOT be refunded."
|
||||||
|
)
|
||||||
|
->modalSubmitActionLabel('Cancel Regular Giving')
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
$hasStripe = (bool) $donation->stripe_setup_intent_id;
|
||||||
|
|
||||||
|
$donation->update(['is_active' => false]);
|
||||||
|
$stripeMsg = '';
|
||||||
|
|
||||||
|
if ($hasStripe) {
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$result = $service->detachPaymentMethod($donation->stripe_setup_intent_id);
|
||||||
|
$stripeMsg = $result['success']
|
||||||
|
? ($result['message'] ?? 'Payment method detached.')
|
||||||
|
: 'Warning: ' . ($result['error'] ?? 'Could not detach payment method.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Regular giving cancelled by admin. ' . $stripeMsg,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Regular giving cancelled')
|
||||||
|
->body($stripeMsg ?: 'Deactivated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── CANCEL & REFUND ALL ─────────────────────────────
|
||||||
|
$donation->is_active && $paidCount > 0 ? Action::make('cancel_and_refund')
|
||||||
|
->label('Cancel & Refund All')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalIcon('heroicon-o-exclamation-triangle')
|
||||||
|
->modalHeading('Cancel & Refund All Payments')
|
||||||
|
->modalDescription(
|
||||||
|
"This will:\n"
|
||||||
|
. "1. Deactivate the regular giving\n"
|
||||||
|
. "2. Detach the payment method from Stripe\n"
|
||||||
|
. "3. Refund all {$paidCount} collected payments (£" . number_format($paidTotal, 2) . ")\n\n"
|
||||||
|
. "This cannot be undone."
|
||||||
|
)
|
||||||
|
->modalSubmitActionLabel('Cancel & Refund Everything')
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
$donation->update(['is_active' => false]);
|
||||||
|
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
|
||||||
|
// Detach card
|
||||||
|
if ($donation->stripe_setup_intent_id) {
|
||||||
|
$service->detachPaymentMethod($donation->stripe_setup_intent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund all paid payments
|
||||||
|
$paidPayments = $donation->payments()
|
||||||
|
->where('is_paid', true)
|
||||||
|
->whereNotNull('stripe_payment_intent_id')
|
||||||
|
->where('stripe_payment_intent_id', '!=', 'SKIPPED')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$refunded = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$totalRefunded = 0;
|
||||||
|
|
||||||
|
foreach ($paidPayments as $payment) {
|
||||||
|
$result = $service->refundPaymentIntent(
|
||||||
|
$payment->stripe_payment_intent_id,
|
||||||
|
null,
|
||||||
|
'Bulk cancel & refund — payment #' . $payment->id
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$payment->update(['is_paid' => false]);
|
||||||
|
$refunded++;
|
||||||
|
$totalRefunded += $result['amount'];
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Cancelled & refunded all payments. '
|
||||||
|
. $refunded . ' refunded (£' . number_format($totalRefunded / 100, 2) . ')'
|
||||||
|
. ($failed > 0 ? ", {$failed} failed" : ''),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Cancelled & Refunded')
|
||||||
|
->body(
|
||||||
|
$refunded . ' payments refunded (£' . number_format($totalRefunded / 100, 2) . ')'
|
||||||
|
. ($failed > 0 ? ". {$failed} failed — check Stripe." : '')
|
||||||
|
)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── REACTIVATE ──────────────────────────────────────
|
||||||
|
! $donation->is_active ? Action::make('activate')
|
||||||
|
->label('Reactivate')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Reactivate Regular Giving')
|
||||||
|
->modalDescription(
|
||||||
|
'Re-enable payment collection. Note: if the payment method '
|
||||||
|
. 'was previously detached, future charges may fail.'
|
||||||
|
)
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
$donation->update(['is_active' => true]);
|
||||||
|
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => 'Regular giving reactivated by admin.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->title('Regular giving reactivated')->success()->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── STRIPE STATUS ───────────────────────────────────
|
||||||
|
$hasStripe ? Action::make('stripe_status')
|
||||||
|
->label('Stripe Status')
|
||||||
|
->icon('heroicon-o-magnifying-glass')
|
||||||
|
->color('gray')
|
||||||
|
->action(function () {
|
||||||
|
$donation = $this->record;
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$details = $service->getSetupIntentDetails($donation->stripe_setup_intent_id);
|
||||||
|
|
||||||
|
if (! $details) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Could not retrieve Stripe details')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = 'Status: ' . $details['status'];
|
||||||
|
if ($details['card_brand'] && $details['card_last4']) {
|
||||||
|
$body .= "\nCard: " . ucfirst($details['card_brand']) . ' ····' . $details['card_last4'];
|
||||||
|
}
|
||||||
|
if ($details['card_exp_month'] && $details['card_exp_year']) {
|
||||||
|
$body .= ' (expires ' . $details['card_exp_month'] . '/' . $details['card_exp_year'] . ')';
|
||||||
|
}
|
||||||
|
$body .= "\nCreated: " . $details['created'];
|
||||||
|
if ($details['customer_id']) {
|
||||||
|
$body .= "\nStripe Customer: " . $details['customer_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Stripe SetupIntent')
|
||||||
|
->body($body)
|
||||||
|
->info()
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
}) : null,
|
||||||
|
|
||||||
|
// ── OPEN IN STRIPE ──────────────────────────────────
|
||||||
|
$hasStripe ? Action::make('open_stripe')
|
||||||
|
->label('Stripe ↗')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url('https://dashboard.stripe.com/search?query=' . urlencode($donation->stripe_setup_intent_id))
|
||||||
|
->openUrlInNewTab() : null,
|
||||||
|
|
||||||
|
// ── VIEW DONOR ──────────────────────────────────────
|
||||||
|
$donation->customer ? Action::make('view_customer')
|
||||||
|
->label('Donor')
|
||||||
|
->icon('heroicon-o-user')
|
||||||
|
->color('gray')
|
||||||
|
->url(EditCustomer::getUrl(['record' => $donation->customer->id]))
|
||||||
|
: null,
|
||||||
|
|
||||||
|
// ── VIEW APPEAL ─────────────────────────────────────
|
||||||
|
$donation->appeal ? Action::make('view_appeal')
|
||||||
|
->label('Fundraiser')
|
||||||
|
->icon('heroicon-o-heart')
|
||||||
|
->color('gray')
|
||||||
|
->url(EditAppeal::getUrl(['record' => $donation->appeal->id]))
|
||||||
|
: null,
|
||||||
|
|
||||||
|
// ── EMAIL ───────────────────────────────────────────
|
||||||
|
$donation->customer?->email ? Action::make('email')
|
||||||
|
->label('Email')
|
||||||
|
->icon('heroicon-o-at-symbol')
|
||||||
|
->color('gray')
|
||||||
|
->url('mailto:' . $donation->customer->email)
|
||||||
|
->openUrlInNewTab() : null,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
300
temp_files/care/ScheduledGivingDonationPayments.php
Normal file
300
temp_files/care/ScheduledGivingDonationPayments.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ScheduledGivingDonationResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Jobs\ProcessScheduleGivingDonationPayment;
|
||||||
|
use App\Models\ScheduledGivingPayment;
|
||||||
|
use App\Services\AppealScheduledDonationService;
|
||||||
|
use App\Services\StripeRefundService;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Actions\BulkAction;
|
||||||
|
use Filament\Tables\Actions\BulkActionGroup;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment schedule with per-row charge / refund / skip actions.
|
||||||
|
*
|
||||||
|
* Visual status:
|
||||||
|
* ✓ green = paid via Stripe
|
||||||
|
* ✓ gray = paid (skipped / no PI)
|
||||||
|
* ! red = overdue
|
||||||
|
* ○ gray = upcoming
|
||||||
|
*/
|
||||||
|
class ScheduledGivingDonationPayments extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'payments';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Payment Schedule';
|
||||||
|
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('amount')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('expected_at')
|
||||||
|
->label('Date')
|
||||||
|
->date('d M Y')
|
||||||
|
->description(fn ($record) => $record->expected_at?->diffForHumans())
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
TextColumn::make('amount')
|
||||||
|
->label('Amount')
|
||||||
|
->money('gbp', divideBy: 100)
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
IconColumn::make('is_paid')
|
||||||
|
->label('Status')
|
||||||
|
->icon(fn ($state, $record) => match (true) {
|
||||||
|
(bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'heroicon-o-forward',
|
||||||
|
(bool) $state && (bool) $record->stripe_payment_intent_id => 'heroicon-o-check-circle',
|
||||||
|
(bool) $state => 'heroicon-o-check',
|
||||||
|
$record->expected_at?->isPast() ?? false => 'heroicon-o-exclamation-circle',
|
||||||
|
default => 'heroicon-o-clock',
|
||||||
|
})
|
||||||
|
->color(fn ($state, $record) => match (true) {
|
||||||
|
(bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'warning',
|
||||||
|
(bool) $state => 'success',
|
||||||
|
$record->expected_at?->isPast() ?? false => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
})
|
||||||
|
->tooltip(fn ($state, $record) => match (true) {
|
||||||
|
(bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'Skipped by admin',
|
||||||
|
(bool) $state && (bool) $record->stripe_payment_intent_id => 'Paid — ' . $record->stripe_payment_intent_id,
|
||||||
|
(bool) $state => 'Marked as paid (no Stripe ref)',
|
||||||
|
$record->expected_at?->isPast() ?? false => 'Overdue — not yet charged',
|
||||||
|
default => 'Upcoming',
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextColumn::make('attempts')
|
||||||
|
->label('Tries')
|
||||||
|
->badge()
|
||||||
|
->color(fn ($state) => match (true) {
|
||||||
|
$state === 0 || $state === null => 'gray',
|
||||||
|
$state === 1 => 'success',
|
||||||
|
default => 'warning',
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextColumn::make('stripe_payment_intent_id')
|
||||||
|
->label('Stripe')
|
||||||
|
->url(fn ($state) => $state && $state !== 'SKIPPED'
|
||||||
|
? "https://dashboard.stripe.com/payments/{$state}"
|
||||||
|
: null)
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->placeholder('—')
|
||||||
|
->limit(18)
|
||||||
|
->fontFamily('mono')
|
||||||
|
->copyable(),
|
||||||
|
])
|
||||||
|
->defaultSort('expected_at', 'asc')
|
||||||
|
|
||||||
|
// ── Row Actions ─────────────────────────────────────
|
||||||
|
->actions([
|
||||||
|
|
||||||
|
// Charge now
|
||||||
|
Action::make('take_payment')
|
||||||
|
->label('Charge')
|
||||||
|
->icon('heroicon-o-banknotes')
|
||||||
|
->color('success')
|
||||||
|
->size('sm')
|
||||||
|
->visible(fn ($record) => ! $record->is_paid && ! $record->stripe_payment_intent_id)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Charge Payment')
|
||||||
|
->modalDescription(fn ($record) => 'Charge £' . number_format($record->amount / 100, 2)
|
||||||
|
. ' (expected ' . $record->expected_at?->format('d M Y') . ')')
|
||||||
|
->action(function (ScheduledGivingPayment $record) {
|
||||||
|
try {
|
||||||
|
(new ProcessScheduleGivingDonationPayment($record))->handle();
|
||||||
|
(new AppealScheduledDonationService())->syncProcessedPayments(collect([$record]));
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Payment charged')
|
||||||
|
->body('£' . number_format($record->amount / 100, 2) . ' collected')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Payment failed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Refund
|
||||||
|
Action::make('refund')
|
||||||
|
->label('Refund')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('danger')
|
||||||
|
->size('sm')
|
||||||
|
->visible(fn ($record) => $record->is_paid
|
||||||
|
&& $record->stripe_payment_intent_id
|
||||||
|
&& $record->stripe_payment_intent_id !== 'SKIPPED')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Refund Payment')
|
||||||
|
->modalDescription(fn ($record) => 'Refund £' . number_format($record->amount / 100, 2)
|
||||||
|
. ' for ' . $record->expected_at?->format('d M Y')
|
||||||
|
. ' back to donor\'s card via Stripe.')
|
||||||
|
->form([
|
||||||
|
TextInput::make('refund_amount')
|
||||||
|
->label('Refund amount (£)')
|
||||||
|
->numeric()
|
||||||
|
->default(fn ($record) => number_format($record->amount / 100, 2, '.', ''))
|
||||||
|
->required()
|
||||||
|
->minValue(0.01)
|
||||||
|
->maxValue(fn ($record) => $record->amount / 100)
|
||||||
|
->step(0.01)
|
||||||
|
->helperText('Full amount for complete refund, or reduce for partial.'),
|
||||||
|
])
|
||||||
|
->action(function (ScheduledGivingPayment $record, array $data) {
|
||||||
|
$amountPence = (int) round($data['refund_amount'] * 100);
|
||||||
|
$isPartial = $amountPence < $record->amount;
|
||||||
|
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$result = $service->refundPaymentIntent(
|
||||||
|
$record->stripe_payment_intent_id,
|
||||||
|
$isPartial ? $amountPence : null,
|
||||||
|
'Admin refund: scheduled payment #' . $record->id
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
if (! $isPartial) {
|
||||||
|
$record->update(['is_paid' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Payment refunded')
|
||||||
|
->body('£' . number_format($result['amount'] / 100, 2) . ' refunded. ID: ' . $result['refund_id'])
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Refund failed')
|
||||||
|
->body($result['error'])
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Skip
|
||||||
|
Action::make('skip')
|
||||||
|
->label('Skip')
|
||||||
|
->icon('heroicon-o-forward')
|
||||||
|
->color('warning')
|
||||||
|
->size('sm')
|
||||||
|
->visible(fn ($record) => ! $record->is_paid && ! $record->stripe_payment_intent_id)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Skip Payment')
|
||||||
|
->modalDescription(fn ($record) => 'Mark the £' . number_format($record->amount / 100, 2)
|
||||||
|
. ' payment for ' . $record->expected_at?->format('d M Y')
|
||||||
|
. ' as skipped. No charge will be made.')
|
||||||
|
->action(function (ScheduledGivingPayment $record) {
|
||||||
|
$record->update([
|
||||||
|
'is_paid' => true,
|
||||||
|
'stripe_payment_intent_id' => 'SKIPPED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::make()->title('Payment skipped')->warning()->send();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Header Actions ──────────────────────────────────
|
||||||
|
->headerActions([
|
||||||
|
Action::make('generate_payments')
|
||||||
|
->label('Generate Payments')
|
||||||
|
->icon('heroicon-o-calculator')
|
||||||
|
->visible(function () {
|
||||||
|
return $this->ownerRecord->payments()->count() == 0
|
||||||
|
&& $this->ownerRecord->isConfirmed();
|
||||||
|
})
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn () => $this->ownerRecord->generatePayments()),
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Bulk Actions ────────────────────────────────────
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
BulkAction::make('refund_selected')
|
||||||
|
->label('Refund Selected')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Refund Selected Payments')
|
||||||
|
->modalDescription('Refund all selected paid payments via Stripe. This cannot be undone.')
|
||||||
|
->action(function (Collection $records) {
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$refunded = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record->is_paid
|
||||||
|
|| ! $record->stripe_payment_intent_id
|
||||||
|
|| $record->stripe_payment_intent_id === 'SKIPPED') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $service->refundPaymentIntent(
|
||||||
|
$record->stripe_payment_intent_id,
|
||||||
|
null,
|
||||||
|
'Bulk refund: payment #' . $record->id
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$record->update(['is_paid' => false]);
|
||||||
|
$refunded++;
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($refunded . ' payments refunded' . ($failed ? ", {$failed} failed" : ''))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
|
||||||
|
BulkAction::make('charge_selected')
|
||||||
|
->label('Charge Selected')
|
||||||
|
->icon('heroicon-o-banknotes')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Charge Selected Payments')
|
||||||
|
->modalDescription('Process all selected unpaid payments now.')
|
||||||
|
->action(function (Collection $records) {
|
||||||
|
$charged = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if ($record->is_paid || $record->stripe_payment_intent_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new ProcessScheduleGivingDonationPayment($record))->handle();
|
||||||
|
(new AppealScheduledDonationService())->syncProcessedPayments(collect([$record]));
|
||||||
|
$charged++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($charged . ' payments charged' . ($failed ? ", {$failed} failed" : ''))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
temp_files/care/StripeRefundService.php
Normal file
157
temp_files/care/StripeRefundService.php
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Stripe\Exception\ApiErrorException;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralised Stripe refund & payment-method management.
|
||||||
|
*
|
||||||
|
* Every action is logged. Every response is a simple array so callers
|
||||||
|
* can show user-friendly notifications without catching exceptions.
|
||||||
|
*/
|
||||||
|
class StripeRefundService
|
||||||
|
{
|
||||||
|
private StripeClient $stripe;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->stripe = new StripeClient(config('paisa.gateways.stripe.secret_key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refund a PaymentIntent (full or partial) ────────────────
|
||||||
|
|
||||||
|
public function refundPaymentIntent(string $paymentIntentId, ?int $amountInPence = null, string $reason = ''): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$params = [
|
||||||
|
'payment_intent' => $paymentIntentId,
|
||||||
|
'reason' => 'requested_by_customer',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($amountInPence !== null && $amountInPence > 0) {
|
||||||
|
$params['amount'] = $amountInPence;
|
||||||
|
}
|
||||||
|
|
||||||
|
$refund = $this->stripe->refunds->create($params);
|
||||||
|
|
||||||
|
Log::info('Stripe refund created', [
|
||||||
|
'refund_id' => $refund->id,
|
||||||
|
'payment_intent' => $paymentIntentId,
|
||||||
|
'amount' => $refund->amount,
|
||||||
|
'status' => $refund->status,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'refund_id' => $refund->id,
|
||||||
|
'amount' => $refund->amount,
|
||||||
|
'status' => $refund->status,
|
||||||
|
];
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
Log::error('Stripe refund failed', [
|
||||||
|
'payment_intent' => $paymentIntentId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detach payment method from a SetupIntent ────────────────
|
||||||
|
// This is how we "cancel" recurring: remove the card so no
|
||||||
|
// future charges can be made.
|
||||||
|
|
||||||
|
public function detachPaymentMethod(string $setupIntentId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$si = $this->stripe->setupIntents->retrieve($setupIntentId);
|
||||||
|
|
||||||
|
if (! $si->payment_method) {
|
||||||
|
return ['success' => true, 'message' => 'No payment method attached'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pmId = is_string($si->payment_method)
|
||||||
|
? $si->payment_method
|
||||||
|
: $si->payment_method->id;
|
||||||
|
|
||||||
|
$this->stripe->paymentMethods->detach($pmId);
|
||||||
|
|
||||||
|
Log::info('Stripe payment method detached', [
|
||||||
|
'setup_intent' => $setupIntentId,
|
||||||
|
'payment_method' => $pmId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "Payment method {$pmId} detached",
|
||||||
|
'payment_method' => $pmId,
|
||||||
|
];
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
Log::error('Failed to detach payment method', [
|
||||||
|
'setup_intent' => $setupIntentId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Retrieve PaymentIntent details for display ──────────────
|
||||||
|
|
||||||
|
public function getPaymentDetails(string $paymentIntentId): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pi = $this->stripe->paymentIntents->retrieve($paymentIntentId, [
|
||||||
|
'expand' => ['latest_charge'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$charge = $pi->latest_charge;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $pi->id,
|
||||||
|
'status' => $pi->status,
|
||||||
|
'amount' => $pi->amount,
|
||||||
|
'currency' => strtoupper($pi->currency),
|
||||||
|
'created' => date('d M Y H:i', $pi->created),
|
||||||
|
'refunded' => $charge?->refunded ?? false,
|
||||||
|
'amount_refunded' => $charge?->amount_refunded ?? 0,
|
||||||
|
'card_brand' => $charge?->payment_method_details?->card?->brand ?? null,
|
||||||
|
'card_last4' => $charge?->payment_method_details?->card?->last4 ?? null,
|
||||||
|
];
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Retrieve SetupIntent details for display ────────────────
|
||||||
|
|
||||||
|
public function getSetupIntentDetails(string $setupIntentId): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$si = $this->stripe->setupIntents->retrieve($setupIntentId, [
|
||||||
|
'expand' => ['payment_method'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pm = $si->payment_method;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $si->id,
|
||||||
|
'status' => $si->status,
|
||||||
|
'created' => date('d M Y H:i', $si->created),
|
||||||
|
'payment_method_id' => is_string($pm) ? $pm : ($pm?->id ?? null),
|
||||||
|
'card_brand' => is_object($pm) ? ($pm->card?->brand ?? null) : null,
|
||||||
|
'card_last4' => is_object($pm) ? ($pm->card?->last4 ?? null) : null,
|
||||||
|
'card_exp_month' => is_object($pm) ? ($pm->card?->exp_month ?? null) : null,
|
||||||
|
'card_exp_year' => is_object($pm) ? ($pm->card?->exp_year ?? null) : null,
|
||||||
|
'customer_id' => $si->customer,
|
||||||
|
];
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
temp_files/care/deploy.py
Normal file
134
temp_files/care/deploy.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Deploy supporter care changes:
|
||||||
|
1. Add HasInternalNotes to ScheduledGivingDonation model
|
||||||
|
2. Add InternalNotesRelationManager to ScheduledGivingDonationResource
|
||||||
|
3. Add refund action to DonationResource table row actions
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE = '/home/forge/app.charityright.org.uk'
|
||||||
|
|
||||||
|
# ── 1. Add HasInternalNotes to ScheduledGivingDonation model ─────
|
||||||
|
|
||||||
|
path = os.path.join(BASE, 'app/Models/ScheduledGivingDonation.php')
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
c = f.read()
|
||||||
|
|
||||||
|
if 'HasInternalNotes' not in c:
|
||||||
|
# Add use import
|
||||||
|
c = c.replace(
|
||||||
|
"use App\\Traits\\Models\\HasBasicAttributions;",
|
||||||
|
"use App\\Traits\\HasInternalNotes;\nuse App\\Traits\\Models\\HasBasicAttributions;"
|
||||||
|
)
|
||||||
|
# Add trait usage
|
||||||
|
c = c.replace(
|
||||||
|
" use HasBasicAttributions,",
|
||||||
|
" use HasInternalNotes,\n HasBasicAttributions,"
|
||||||
|
)
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(c)
|
||||||
|
print('Added HasInternalNotes to ScheduledGivingDonation model')
|
||||||
|
else:
|
||||||
|
print('ScheduledGivingDonation already has HasInternalNotes')
|
||||||
|
|
||||||
|
# ── 2. Add InternalNotesRelationManager to ScheduledGivingDonationResource ──
|
||||||
|
|
||||||
|
path = os.path.join(BASE, 'app/Filament/Resources/ScheduledGivingDonationResource.php')
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
c = f.read()
|
||||||
|
|
||||||
|
if 'InternalNotesRelationManager' not in c:
|
||||||
|
# Add import
|
||||||
|
c = c.replace(
|
||||||
|
"use App\\Filament\\Resources\\ScheduledGivingDonationResource\\RelationManagers\\ScheduledGivingDonationPayments;",
|
||||||
|
"use App\\Filament\\Resources\\ScheduledGivingDonationResource\\RelationManagers\\ScheduledGivingDonationPayments;\nuse App\\Filament\\RelationManagers\\InternalNotesRelationManager;"
|
||||||
|
)
|
||||||
|
# Add to getRelations
|
||||||
|
c = c.replace(
|
||||||
|
"ScheduledGivingDonationPayments::class,\n ];",
|
||||||
|
"ScheduledGivingDonationPayments::class,\n InternalNotesRelationManager::class,\n ];"
|
||||||
|
)
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(c)
|
||||||
|
print('Added InternalNotesRelationManager to ScheduledGivingDonationResource')
|
||||||
|
else:
|
||||||
|
print('ScheduledGivingDonationResource already has InternalNotesRelationManager')
|
||||||
|
|
||||||
|
# ── 3. Add refund action to DonationResource table row actions ───
|
||||||
|
|
||||||
|
path = os.path.join(BASE, 'app/Filament/Resources/DonationResource.php')
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
c = f.read()
|
||||||
|
|
||||||
|
# Add StripeRefundService import if missing
|
||||||
|
if 'StripeRefundService' not in c:
|
||||||
|
c = c.replace(
|
||||||
|
"use App\\Models\\Donation;",
|
||||||
|
"use App\\Models\\Donation;\nuse App\\Services\\StripeRefundService;"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add TextInput import if missing for refund form
|
||||||
|
if 'use Filament\\Forms\\Components\\TextInput;' not in c:
|
||||||
|
c = c.replace(
|
||||||
|
"use Filament\\Forms\\Components\\Select;",
|
||||||
|
"use Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Components\\TextInput;"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add refund action inside the ActionGroup, after ViewAction
|
||||||
|
old_view = " ViewAction::make(),"
|
||||||
|
new_view = """ ViewAction::make(),
|
||||||
|
|
||||||
|
Action::make('refund')
|
||||||
|
->label('Refund')
|
||||||
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (Donation $d) => $d->isConfirmed()
|
||||||
|
&& $d->provider_type === \\App\\Definitions\\PaymentProviders::STRIPE
|
||||||
|
&& str_starts_with($d->provider_reference ?? '', 'pi_'))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Refund Donation')
|
||||||
|
->modalDescription(fn (Donation $d) => 'Refund £' . number_format($d->amount / 100, 2) . ' to ' . ($d->customer?->name ?? 'donor') . '\\'s card via Stripe.')
|
||||||
|
->form([
|
||||||
|
TextInput::make('refund_amount')
|
||||||
|
->label('Refund amount (£)')
|
||||||
|
->numeric()
|
||||||
|
->default(fn (Donation $d) => number_format($d->amount / 100, 2, '.', ''))
|
||||||
|
->required()
|
||||||
|
->minValue(0.01)
|
||||||
|
->maxValue(fn (Donation $d) => $d->amount / 100)
|
||||||
|
->step(0.01)
|
||||||
|
->helperText('Full amount for complete refund, or reduce for partial.'),
|
||||||
|
])
|
||||||
|
->action(function (Donation $donation, array $data) {
|
||||||
|
$amountPence = (int) round($data['refund_amount'] * 100);
|
||||||
|
$isPartial = $amountPence < $donation->amount;
|
||||||
|
$service = app(StripeRefundService::class);
|
||||||
|
$result = $service->refundPaymentIntent(
|
||||||
|
$donation->provider_reference,
|
||||||
|
$isPartial ? $amountPence : null,
|
||||||
|
'Table refund by ' . auth()->user()?->name
|
||||||
|
);
|
||||||
|
if ($result['success']) {
|
||||||
|
if (!$isPartial) {
|
||||||
|
$donation->donationConfirmation?->update(['confirmed_at' => null]);
|
||||||
|
}
|
||||||
|
$donation->internalNotes()->create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'body' => ($isPartial ? 'Partial' : 'Full') . ' refund of £' . number_format($amountPence / 100, 2) . '. Stripe ID: ' . $result['refund_id'],
|
||||||
|
]);
|
||||||
|
Notification::make()->title('£' . number_format($result['amount'] / 100, 2) . ' refunded')->success()->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()->title('Refund failed')->body($result['error'])->danger()->send();
|
||||||
|
}
|
||||||
|
}),"""
|
||||||
|
|
||||||
|
c = c.replace(old_view, new_view)
|
||||||
|
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(c)
|
||||||
|
print('Added refund action to DonationResource table')
|
||||||
|
else:
|
||||||
|
print('DonationResource already has StripeRefundService')
|
||||||
|
|
||||||
|
print('Done!')
|
||||||
27
temp_files/care/revert_vendor.py
Normal file
27
temp_files/care/revert_vendor.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
path = '/home/forge/app.charityright.org.uk/vendor/filament/tables/src/Table/Concerns/HasRecords.php'
|
||||||
|
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
new_lines = []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
if 'public function getModel(): string' in line and 'getModelLabel' not in line:
|
||||||
|
new_lines.append(' public function getModel(): string\n')
|
||||||
|
new_lines.append(' {\n')
|
||||||
|
new_lines.append(' return $this->getQuery()->getModel()::class;\n')
|
||||||
|
new_lines.append(' }\n')
|
||||||
|
# Skip the old method body
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and lines[i].strip() != '}':
|
||||||
|
i += 1
|
||||||
|
i += 1 # skip closing brace
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.writelines(new_lines)
|
||||||
|
print('Vendor reverted to original')
|
||||||
Reference in New Issue
Block a user