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:
@@ -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