Automations engine: multi-channel messaging + dashboard

THE STAR OF THE SHOW — the automation engine is now visible.

## New: Unified Messaging Layer (src/lib/messaging.ts)
Channel waterfall: WhatsApp → SMS → Email
- sendToDonor() routes to best available channel
- Respects donor consent flags (whatsappOptIn, emailOptIn)
- Falls back automatically if primary channel fails
- Every attempt logged to AnalyticsEvent for dashboard

## New: Email Integration (src/lib/email.ts)
Bring-your-own-key: charity pastes their Resend or SendGrid API key
- Resend: free 3,000 emails/month
- SendGrid: free 100/day
- Messages come from THEIR domain (donations@mymosque.org)
- Plain text auto-converted to clean HTML

## New: SMS Integration (src/lib/sms.ts)
Bring-your-own-key: charity pastes their Twilio credentials
- Pay-as-you-go (~3p per SMS)
- UK number normalization (07xxx → +447xxx)
- Reaches donors without WhatsApp or email

## New: /dashboard/automations — the visible engine
A. Dark hero stats: Messages this week per channel + delivery rate
B. Live channels: WhatsApp/Email/SMS with status, features, stats
C. The Pipeline: visual 4-step automation sequence
   - What triggers, what's sent, which channels, waterfall explanation
D. Scheduled reminders: upcoming messages with timing
E. Message feed: recent messages with channel icon, status, time

## New: /api/messaging/status — dashboard data endpoint
Returns channels, stats (7 day), history (50 recent), pending reminders

## New: /api/messaging/test — send test message to admin

## Schema: 8 new Organization columns
emailProvider, emailApiKey, emailFromAddress, emailFromName
smsProvider, smsAccountSid, smsAuthToken, smsFromNumber

## Settings: 2 new channel rows in the checklist
- Email: provider selector (Resend/SendGrid) + API key + from address
- SMS: Twilio credentials + from number
Both follow the same checklist expand/collapse pattern

## Nav: Automations added between Money and Reports
Home → Collect → Money → Automations → Reports → Settings

## Stats tracking
Messages logged as AnalyticsEvent:
  message.whatsapp.receipt.sent
  message.email.reminder_1.failed
  message.sms.reminder_2.sent
Donor PII masked in logs (last 4 digits of phone, email obfuscated)
This commit is contained in:
2026-03-04 23:20:50 +08:00
parent ce4f2ba52a
commit c52c97df17
13 changed files with 1518 additions and 25 deletions

View File

@@ -0,0 +1,426 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { formatPence } from "@/lib/utils"
import {
Loader2, MessageCircle, Mail, Smartphone, Check, X, Clock,
ChevronDown, Send, Zap, AlertTriangle, Radio
} from "lucide-react"
import Link from "next/link"
/**
* /dashboard/automations — "Is it working? What's being sent?"
*
* THIS IS THE STAR OF THE SHOW.
*
* The entire product is an automation engine. Without it, PNPL is
* just a spreadsheet. This page makes the engine VISIBLE.
*
* Aaisha's questions:
* 1. "Are messages actually being sent?" → Live channel status
* 2. "What did Ahmed receive?" → Message feed with previews
* 3. "What happens after someone pledges?" → Visual pipeline
* 4. "Is everything working?" → Delivery stats
* 5. "What's coming up next?" → Scheduled reminders
*
* The page has 4 sections:
* A. Hero stats bar (dark) — messages this week, delivery rate
* B. Live channels — which pipes are active
* C. The Pipeline — visual "what happens after a pledge"
* D. Message feed — recent messages with status
*/
interface ChannelStatus {
whatsapp: boolean
email: { provider: string; fromAddress: string } | null
sms: { provider: string; fromNumber: string } | null
}
interface ChannelStats {
whatsapp: { sent: number; failed: number }
email: { sent: number; failed: number }
sms: { sent: number; failed: number }
total: number
deliveryRate: number
}
interface MessageEntry {
id: string; channel: string; messageType: string
donorName: string | null; success: boolean
error: string | null; createdAt: string
}
interface PendingReminder {
id: string; donorName: string | null; amountPence: number
step: number; channel: string; scheduledAt: string
}
const CHANNEL_ICONS: Record<string, typeof MessageCircle> = {
whatsapp: MessageCircle, email: Mail, sms: Smartphone,
}
const CHANNEL_COLORS: Record<string, string> = {
whatsapp: "#25D366", email: "#1E40AF", sms: "#F59E0B",
}
const MSG_LABELS: Record<string, string> = {
receipt: "Receipt",
reminder_1: "Reminder 1",
reminder_2: "Reminder 2",
reminder_3: "Reminder 3",
reminder_4: "Final reminder",
overdue_notice: "Overdue notice",
payment_confirmed: "Payment confirmed",
test: "Test message",
}
const PIPELINE_STEPS = [
{
trigger: "Someone pledges",
title: "Receipt",
desc: "Bank details, reference, confirmation",
timing: "Instantly",
messageType: "receipt",
},
{
trigger: "Day 2",
title: "Gentle reminder",
desc: "\"Just a quick reminder about your pledge...\"",
timing: "If not paid",
messageType: "reminder_1",
},
{
trigger: "Day 7",
title: "Impact nudge",
desc: "\"Your £X helps fund...\" — why it matters",
timing: "If not paid",
messageType: "reminder_2",
},
{
trigger: "Day 14",
title: "Final reminder",
desc: "\"This is our final message\" — reply PAID/CANCEL",
timing: "Marks overdue if no response",
messageType: "reminder_3",
},
]
export default function AutomationsPage() {
const [loading, setLoading] = useState(true)
const [channels, setChannels] = useState<ChannelStatus | null>(null)
const [stats, setStats] = useState<ChannelStats | null>(null)
const [history, setHistory] = useState<MessageEntry[]>([])
const [pending, setPending] = useState<PendingReminder[]>([])
const [pipelineOpen, setPipelineOpen] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch("/api/messaging/status")
const data = await res.json()
if (data.channels) setChannels(data.channels)
if (data.stats) setStats(data.stats)
if (data.history) setHistory(data.history)
if (data.pending) setPending(data.pending)
} catch { /* */ }
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
const activeChannels = [
channels?.whatsapp ? "WhatsApp" : null,
channels?.email ? "Email" : null,
channels?.sms ? "SMS" : null,
].filter(Boolean)
const noChannels = activeChannels.length === 0
return (
<div className="space-y-6">
{/* ── Header ── */}
<div>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Pledge follow-up engine</p>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Automations</h1>
</div>
{/* ── A. Hero stats (dark) ── */}
<div className="bg-[#111827] p-5">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">Last 7 days</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-700">
{[
{ value: String(stats?.whatsapp.sent || 0), label: "WhatsApp", color: "text-[#25D366]" },
{ value: String(stats?.email.sent || 0), label: "Email", color: "text-[#60A5FA]" },
{ value: String(stats?.sms.sent || 0), label: "SMS", color: "text-[#FBBF24]" },
{ value: String(stats?.total || 0), label: "Total messages", color: "text-white" },
{ value: `${stats?.deliveryRate || 0}%`, label: "Delivered", color: (stats?.deliveryRate || 0) >= 90 ? "text-[#4ADE80]" : "text-[#FBBF24]" },
].map(s => (
<div key={s.label} className="bg-[#111827] p-4">
<p className={`text-2xl font-black tracking-tight ${s.color}`}>{s.value}</p>
<p className="text-[10px] text-gray-500 mt-1">{s.label}</p>
</div>
))}
</div>
</div>
{/* ── B. Live channels ── */}
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
<h2 className="text-sm font-bold text-[#111827]">Channels</h2>
<Link href="/dashboard/settings" className="text-[10px] font-bold text-[#1E40AF] hover:underline">
Configure in Settings
</Link>
</div>
{noChannels ? (
<div className="p-8 text-center">
<AlertTriangle className="h-6 w-6 text-[#F59E0B] mx-auto mb-2" />
<p className="text-sm font-bold text-[#111827]">No channels connected</p>
<p className="text-xs text-gray-500 mt-1">Connect WhatsApp, Email, or SMS in Settings to start sending automatic receipts and reminders.</p>
<Link href="/dashboard/settings" className="inline-block mt-3 bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
Go to Settings
</Link>
</div>
) : (
<div className="divide-y divide-gray-50">
{/* WhatsApp */}
<ChannelRow
name="WhatsApp"
icon={MessageCircle}
color="#25D366"
active={!!channels?.whatsapp}
detail={channels?.whatsapp ? "Connected · Receipts, reminders, chatbot" : "Not connected"}
stats={stats?.whatsapp}
features={["Instant receipts", "4-step reminders", "2-way chatbot (PAID/HELP/CANCEL)"]}
/>
{/* Email */}
<ChannelRow
name="Email"
icon={Mail}
color="#1E40AF"
active={!!channels?.email}
detail={channels?.email ? `${channels.email.provider} · ${channels.email.fromAddress}` : "Not configured — add your Resend or SendGrid key in Settings"}
stats={stats?.email}
features={["HTML receipts", "Reminder emails", "Works for donors without WhatsApp"]}
/>
{/* SMS */}
<ChannelRow
name="SMS"
icon={Smartphone}
color="#F59E0B"
active={!!channels?.sms}
detail={channels?.sms ? `${channels.sms.provider} · ${channels.sms.fromNumber}` : "Not configured — add your Twilio credentials in Settings"}
stats={stats?.sms}
features={["Text reminders", "Reaches everyone", "Fallback for no-WhatsApp donors"]}
/>
</div>
)}
</div>
{/* ── C. The Pipeline — what happens after a pledge ── */}
<div className="bg-white border border-gray-200">
<button
onClick={() => setPipelineOpen(!pipelineOpen)}
className="w-full px-5 py-4 flex items-center justify-between hover:bg-[#F9FAFB] transition-colors"
>
<div className="flex items-center gap-3">
<Zap className="h-4 w-4 text-[#1E40AF]" />
<div className="text-left">
<h2 className="text-sm font-bold text-[#111827]">What happens after someone pledges</h2>
<p className="text-[10px] text-gray-500">4-step automated sequence · {activeChannels.join(" → ") || "no channels"}</p>
</div>
</div>
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${pipelineOpen ? "rotate-180" : ""}`} />
</button>
{pipelineOpen && (
<div className="px-5 pb-5 space-y-0">
{PIPELINE_STEPS.map((step, i) => (
<div key={i} className="flex gap-4">
{/* Timeline */}
<div className="flex flex-col items-center">
<div className={`w-8 h-8 flex items-center justify-center shrink-0 ${i === 0 ? "bg-[#1E40AF] text-white" : "bg-gray-100 text-gray-500"}`}>
<span className="text-[10px] font-black">{i + 1}</span>
</div>
{i < PIPELINE_STEPS.length - 1 && (
<div className="w-px h-full bg-gray-200 min-h-[40px]" />
)}
</div>
{/* Content */}
<div className="pb-6 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1.5 py-0.5">{step.trigger}</span>
<span className="text-[9px] text-gray-400">{step.timing}</span>
</div>
<p className="text-sm font-bold text-[#111827]">{step.title}</p>
<p className="text-xs text-gray-500 mt-0.5">{step.desc}</p>
<div className="flex items-center gap-1.5 mt-2">
{activeChannels.map((ch, ci) => {
const key = ch!.toLowerCase() as keyof typeof CHANNEL_COLORS
return (
<span key={ci} className="text-[8px] font-bold px-1.5 py-0.5" style={{ backgroundColor: CHANNEL_COLORS[key] + "15", color: CHANNEL_COLORS[key] }}>
{ch}
</span>
)
})}
{activeChannels.length > 1 && (
<span className="text-[8px] text-gray-400">waterfall</span>
)}
</div>
</div>
</div>
))}
<div className="border-l-2 border-gray-200 pl-4 ml-[15px] py-3 text-xs text-gray-400">
Messages try <strong className="text-gray-600">WhatsApp first</strong>, then fall back to SMS, then Email. Donors who reply <code className="bg-gray-100 px-1 text-[10px] font-mono">PAID</code> skip all future reminders.
</div>
</div>
)}
</div>
{/* ── D. Upcoming reminders ── */}
{pending.length > 0 && (
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3">
<h2 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
<Clock className="h-4 w-4 text-gray-400" /> Scheduled ({pending.length})
</h2>
</div>
<div className="divide-y divide-gray-50">
{pending.slice(0, 8).map(r => {
const when = new Date(r.scheduledAt)
const isToday = when.toDateString() === new Date().toDateString()
const isTomorrow = when.toDateString() === new Date(Date.now() + 86400000).toDateString()
const label = isToday ? "Today" : isTomorrow ? "Tomorrow" : when.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
const stepLabels = ["Receipt", "Reminder 1", "Reminder 2", "Final"]
return (
<div key={r.id} className="px-5 py-2.5 flex items-center gap-3">
<div className={`w-1.5 h-1.5 shrink-0 ${isToday ? "bg-[#F59E0B]" : "bg-gray-300"}`} />
<div className="flex-1 min-w-0">
<p className="text-xs text-[#111827] truncate">
<strong>{r.donorName || "Anonymous"}</strong> · {formatPence(r.amountPence)} · {stepLabels[r.step] || `Step ${r.step}`}
</p>
</div>
<span className={`text-[10px] font-bold shrink-0 ${isToday ? "text-[#F59E0B]" : "text-gray-400"}`}>{label}</span>
</div>
)
})}
</div>
</div>
)}
{/* ── E. Recent messages ── */}
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3">
<h2 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
<Send className="h-4 w-4 text-gray-400" /> Recent messages
</h2>
</div>
{history.length === 0 ? (
<div className="p-8 text-center">
<Send className="h-6 w-6 text-gray-200 mx-auto mb-2" />
<p className="text-xs text-gray-400">No messages sent yet. Messages will appear here when donors start pledging.</p>
</div>
) : (
<div className="divide-y divide-gray-50 max-h-[400px] overflow-y-auto">
{history.map(msg => {
const ChannelIcon = CHANNEL_ICONS[msg.channel] || Mail
const color = CHANNEL_COLORS[msg.channel] || "#999"
const ago = getTimeAgo(new Date(msg.createdAt))
return (
<div key={msg.id} className="px-5 py-2.5 flex items-center gap-3">
{/* Channel icon */}
<div className="w-6 h-6 flex items-center justify-center shrink-0" style={{ backgroundColor: color + "15" }}>
<ChannelIcon className="h-3 w-3" style={{ color }} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-xs text-[#111827] truncate">
<strong>{msg.donorName || "Anonymous"}</strong>
<span className="text-gray-400 mx-1">·</span>
{MSG_LABELS[msg.messageType] || msg.messageType}
</p>
</div>
{/* Status */}
{msg.success ? (
<Check className="h-3.5 w-3.5 text-[#16A34A] shrink-0" />
) : (
<span title={msg.error || "Failed"}><X className="h-3.5 w-3.5 text-[#DC2626] shrink-0" /></span>
)}
{/* Time */}
<span className="text-[10px] text-gray-400 shrink-0 w-16 text-right">{ago}</span>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
// ─── Channel row ─────────────────────────────────────────────
function ChannelRow({ name, icon: Icon, color, active, detail, stats, features }: {
name: string; icon: typeof MessageCircle; color: string
active: boolean; detail: string
stats?: { sent: number; failed: number }
features: string[]
}) {
return (
<div className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center shrink-0" style={{ backgroundColor: active ? color + "15" : "#F3F4F6" }}>
<Icon className="h-4 w-4" style={{ color: active ? color : "#9CA3AF" }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-[#111827]">{name}</p>
{active && (
<span className="text-[8px] font-bold px-1 py-0.5 flex items-center gap-0.5" style={{ backgroundColor: color + "15", color }}>
<Radio className="h-2 w-2" /> Live
</span>
)}
</div>
<p className="text-[10px] text-gray-500 truncate">{detail}</p>
</div>
{stats && active && (
<div className="text-right shrink-0">
<p className="text-sm font-black text-[#111827]">{stats.sent}</p>
<p className="text-[9px] text-gray-400">sent this week</p>
</div>
)}
</div>
{active && (
<div className="mt-3 flex flex-wrap gap-1.5 ml-11">
{features.map((f, i) => (
<span key={i} className="text-[9px] text-gray-500 bg-[#F9FAFB] px-2 py-1 flex items-center gap-1">
<Check className="h-2.5 w-2.5 text-[#16A34A]" /> {f}
</span>
))}
</div>
)}
</div>
)
}
// ─── Helpers ─────────────────────────────────────────────────
function getTimeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000)
if (s < 60) return "just now"
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
if (s < 604800) return `${Math.floor(s / 86400)}d ago`
return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
}