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:
31
pledge-now-pay-later/src/app/api/messaging/status/route.ts
Normal file
31
pledge-now-pay-later/src/app/api/messaging/status/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getOrgChannels, getChannelStats, getMessageHistory, getPendingReminders } from "@/lib/messaging"
|
||||
import { getOrgId } from "@/lib/session"
|
||||
|
||||
/**
|
||||
* GET /api/messaging/status
|
||||
*
|
||||
* Returns everything the Automations dashboard needs:
|
||||
* - Channel availability (which channels are live)
|
||||
* - Stats (messages sent/failed per channel, last 7 days)
|
||||
* - Message history (recent 50)
|
||||
* - Pending reminders (next 20 scheduled)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const orgId = await getOrgId(request.headers.get("x-org-id"))
|
||||
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
||||
|
||||
const [channels, stats, history, pending] = await Promise.all([
|
||||
getOrgChannels(orgId),
|
||||
getChannelStats(orgId, 7),
|
||||
getMessageHistory(orgId, 50),
|
||||
getPendingReminders(orgId, 20),
|
||||
])
|
||||
|
||||
return NextResponse.json({ channels, stats, history, pending })
|
||||
} catch (error) {
|
||||
console.error("Messaging status error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
55
pledge-now-pay-later/src/app/api/messaging/test/route.ts
Normal file
55
pledge-now-pay-later/src/app/api/messaging/test/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { sendToDonor, getOrgChannels } from "@/lib/messaging"
|
||||
import { getUser } from "@/lib/session"
|
||||
|
||||
/**
|
||||
* POST /api/messaging/test
|
||||
*
|
||||
* Send a test message to the admin's own phone/email
|
||||
* to verify channel configuration.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getUser()
|
||||
if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const { channel } = body // "whatsapp", "email", "sms", or "auto"
|
||||
|
||||
const channels = await getOrgChannels(user.orgId)
|
||||
|
||||
// Determine test destination
|
||||
const testPhone = body.phone || ""
|
||||
const testEmail = body.email || user.email
|
||||
|
||||
const testMsg = `🧪 Test from ${user.orgName || "PNPL"}\n\nThis is a test message to confirm your ${channel || "messaging"} integration is working.\n\nIf you received this, you're all set! 🎉`
|
||||
|
||||
const result = await sendToDonor({
|
||||
donorPhone: channel === "email" ? null : testPhone,
|
||||
donorEmail: channel === "sms" || channel === "whatsapp" ? null : testEmail,
|
||||
donorName: user.name || "Test",
|
||||
whatsappOptIn: true,
|
||||
emailOptIn: true,
|
||||
messageType: "test",
|
||||
subject: `Test message from ${user.orgName || "PNPL"}`,
|
||||
whatsappText: testMsg,
|
||||
emailText: testMsg,
|
||||
smsText: `Test from ${user.orgName || "PNPL"}: Your messaging integration is working! 🎉`,
|
||||
orgId: user.orgId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: result.success,
|
||||
channel: result.channel,
|
||||
error: result.error,
|
||||
channelsAvailable: {
|
||||
whatsapp: channels.whatsapp,
|
||||
email: !!channels.email,
|
||||
sms: !!channels.sms,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Test message error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ export async function GET(request: NextRequest) {
|
||||
gcEnvironment: org.gcEnvironment,
|
||||
stripeSecretKey: org.stripeSecretKey ? "••••••••" : "",
|
||||
stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "",
|
||||
emailProvider: org.emailProvider || "",
|
||||
emailApiKey: org.emailApiKey ? "••••••••" : "",
|
||||
emailFromAddress: org.emailFromAddress || "",
|
||||
emailFromName: org.emailFromName || "",
|
||||
smsProvider: org.smsProvider || "",
|
||||
smsAccountSid: org.smsAccountSid ? "••••••••" : "",
|
||||
smsAuthToken: org.smsAuthToken ? "••••••••" : "",
|
||||
smsFromNumber: org.smsFromNumber || "",
|
||||
orgType: org.orgType || "charity",
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -87,7 +95,7 @@ export async function PATCH(request: NextRequest) {
|
||||
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
||||
|
||||
const body = await request.json()
|
||||
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret"]
|
||||
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret", "emailProvider", "emailApiKey", "emailFromAddress", "emailFromName", "smsProvider", "smsAccountSid", "smsAuthToken", "smsFromNumber"]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: Record<string, any> = {}
|
||||
for (const key of stringKeys) {
|
||||
|
||||
426
pledge-now-pay-later/src/app/dashboard/automations/page.tsx
Normal file
426
pledge-now-pay-later/src/app/dashboard/automations/page.tsx
Normal 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" })
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useSession, signOut } from "next-auth/react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle, Users } from "lucide-react"
|
||||
import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle, Users, Zap } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
@@ -17,6 +17,7 @@ const adminNavItems = [
|
||||
{ href: "/dashboard", label: "Home", icon: Home },
|
||||
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
|
||||
{ href: "/dashboard/money", label: "Money", icon: Banknote },
|
||||
{ href: "/dashboard/automations", label: "Automations", icon: Zap },
|
||||
{ href: "/dashboard/reports", label: "Reports", icon: FileText },
|
||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||
]
|
||||
@@ -44,6 +45,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
if (href === "/dashboard/community") return pathname === "/dashboard/community" || pathname === "/dashboard"
|
||||
if (href === "/dashboard/collect") return pathname.startsWith("/dashboard/collect") || pathname.startsWith("/dashboard/events")
|
||||
if (href === "/dashboard/money") return pathname.startsWith("/dashboard/money") || pathname.startsWith("/dashboard/pledges") || pathname.startsWith("/dashboard/reconcile")
|
||||
if (href === "/dashboard/automations") return pathname.startsWith("/dashboard/automations")
|
||||
if (href === "/dashboard/reports") return pathname.startsWith("/dashboard/reports") || pathname.startsWith("/dashboard/exports")
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
|
||||
Smartphone, Wifi, WifiOff, UserPlus, Trash2, Copy,
|
||||
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight,
|
||||
Zap, Pencil
|
||||
Zap, Pencil, Mail
|
||||
} from "lucide-react"
|
||||
|
||||
/**
|
||||
@@ -48,6 +48,8 @@ interface OrgSettings {
|
||||
bankAccountName: string; refPrefix: string; primaryColor: string
|
||||
gcAccessToken: string; gcEnvironment: string; orgType: string
|
||||
stripeSecretKey: string; stripeWebhookSecret: string
|
||||
emailProvider: string; emailApiKey: string; emailFromAddress: string; emailFromName: string
|
||||
smsProvider: string; smsAccountSid: string; smsAuthToken: string; smsFromNumber: string
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
@@ -364,6 +366,85 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{/* ▸ Email ─────────────────────────────── */}
|
||||
<SettingRow
|
||||
configured={!!settings.emailApiKey}
|
||||
icon={<Mail className={`h-4 w-4 ${settings.emailApiKey ? "text-[#1E40AF]" : "text-gray-400"}`} />}
|
||||
title="Email"
|
||||
summary={settings.emailApiKey ? `${settings.emailProvider || "Resend"} · ${settings.emailFromAddress}` : "Send receipts and reminders by email"}
|
||||
isOpen={open === "email"}
|
||||
onToggle={() => toggle("email")}
|
||||
optional
|
||||
saving={saving === "email"}
|
||||
saved={saved === "email"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="border-l-2 border-[#1E40AF] pl-3 text-xs text-gray-500">
|
||||
Send receipts and reminders to donors who don't have WhatsApp. Connect your <strong className="text-gray-700">own email provider</strong> — messages come from your domain.
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-2">Provider</label>
|
||||
<div className="flex gap-2">
|
||||
{["resend", "sendgrid"].map(p => (
|
||||
<button key={p} onClick={() => update("emailProvider", p)} className={`px-4 py-2 text-xs font-bold border-2 transition-colors ${(settings.emailProvider || "resend") === p ? "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}>
|
||||
{p === "resend" ? "Resend" : "SendGrid"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(settings.emailProvider || "resend") === "resend" && !settings.emailApiKey && (
|
||||
<p className="text-[10px] text-gray-400 mt-2">Free: 3,000 emails/month at <a href="https://resend.com" target="_blank" rel="noopener noreferrer" className="text-[#1E40AF] font-bold hover:underline">resend.com</a></p>
|
||||
)}
|
||||
</div>
|
||||
<Field label="API key" value={settings.emailApiKey || ""} onChange={v => update("emailApiKey", v)} placeholder={settings.emailProvider === "sendgrid" ? "SG.xxxxx" : "re_xxxxx"} type="password" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="From address" value={settings.emailFromAddress || ""} onChange={v => update("emailFromAddress", v)} placeholder="donations@mymosque.org" />
|
||||
<Field label="From name" value={settings.emailFromName || ""} onChange={v => update("emailFromName", v)} placeholder="Al Furqan Mosque" />
|
||||
</div>
|
||||
<SaveRow
|
||||
section="email" saving={saving} saved={saved}
|
||||
onSave={() => save("email", { emailProvider: settings.emailProvider || "resend", emailApiKey: settings.emailApiKey || "", emailFromAddress: settings.emailFromAddress || "", emailFromName: settings.emailFromName || "" })}
|
||||
onCancel={() => setOpen(null)}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{/* ▸ SMS ──────────────────────────────── */}
|
||||
<SettingRow
|
||||
configured={!!settings.smsAccountSid}
|
||||
icon={<Smartphone className={`h-4 w-4 ${settings.smsAccountSid ? "text-[#F59E0B]" : "text-gray-400"}`} />}
|
||||
title="SMS"
|
||||
summary={settings.smsAccountSid ? `Twilio · ${settings.smsFromNumber}` : "Text reminders for donors without WhatsApp"}
|
||||
isOpen={open === "sms"}
|
||||
onToggle={() => toggle("sms")}
|
||||
optional
|
||||
saving={saving === "sms"}
|
||||
saved={saved === "sms"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="border-l-2 border-[#F59E0B] pl-3 text-xs text-gray-500">
|
||||
Send SMS reminders via <strong className="text-gray-700">Twilio</strong>. Reaches donors who don't have WhatsApp and haven't provided an email. Pay-as-you-go (~3p per SMS).
|
||||
</div>
|
||||
{!settings.smsAccountSid && (
|
||||
<div className="bg-[#F9FAFB] border border-gray-100 p-3">
|
||||
<p className="text-[10px] font-bold text-[#111827] mb-1">Get your Twilio credentials</p>
|
||||
<ol className="text-[10px] text-gray-500 space-y-1 list-decimal list-inside">
|
||||
<li>Sign up at <a href="https://www.twilio.com" target="_blank" rel="noopener noreferrer" className="text-[#F59E0B] font-bold hover:underline">twilio.com</a></li>
|
||||
<li>Copy your <strong className="text-gray-700">Account SID</strong> and <strong className="text-gray-700">Auth Token</strong> from the dashboard</li>
|
||||
<li>Buy a phone number (or use the trial number)</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
<Field label="Account SID" value={settings.smsAccountSid || ""} onChange={v => update("smsAccountSid", v)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||
<Field label="Auth token" value={settings.smsAuthToken || ""} onChange={v => update("smsAuthToken", v)} placeholder="Your auth token" type="password" />
|
||||
<Field label="From number" value={settings.smsFromNumber || ""} onChange={v => update("smsFromNumber", v)} placeholder="+447123456789" />
|
||||
<SaveRow
|
||||
section="sms" saving={saving} saved={saved}
|
||||
onSave={() => save("sms", { smsProvider: "twilio", smsAccountSid: settings.smsAccountSid || "", smsAuthToken: settings.smsAuthToken || "", smsFromNumber: settings.smsFromNumber || "" })}
|
||||
onCancel={() => setOpen(null)}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{/* ▸ Team ─────────────────────────────── */}
|
||||
{isAdmin && (
|
||||
<TeamRow
|
||||
|
||||
Reference in New Issue
Block a user