diff --git a/pledge-now-pay-later/pnpl-backup b/pledge-now-pay-later/pnpl-backup new file mode 160000 index 0000000..3883378 --- /dev/null +++ b/pledge-now-pay-later/pnpl-backup @@ -0,0 +1 @@ +Subproject commit 38833783a20113fef2b43fa8170eb6a8420aed04 diff --git a/pledge-now-pay-later/src/app/api/ai/suggest/route.ts b/pledge-now-pay-later/src/app/api/ai/suggest/route.ts new file mode 100644 index 0000000..8805f6b --- /dev/null +++ b/pledge-now-pay-later/src/app/api/ai/suggest/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { suggestAmounts } from "@/lib/ai" + +export async function GET(request: NextRequest) { + try { + const eventId = request.nextUrl.searchParams.get("eventId") + if (!eventId || !prisma) { + // Return smart defaults + return NextResponse.json(await suggestAmounts({ eventName: "Charity Event" })) + } + + // Get real peer data from the event + const pledges = await prisma.pledge.findMany({ + where: { eventId, status: { not: "cancelled" } }, + select: { amountPence: true }, + orderBy: { amountPence: "asc" }, + }) + + const event = await prisma.event.findUnique({ + where: { id: eventId }, + select: { name: true }, + }) + + if (pledges.length === 0) { + return NextResponse.json(await suggestAmounts({ + eventName: event?.name || "Charity Event", + })) + } + + const amounts = pledges.map(p => p.amountPence) + const avg = Math.round(amounts.reduce((a, b) => a + b, 0) / amounts.length) + const median = amounts[Math.floor(amounts.length / 2)] + const top = amounts[amounts.length - 1] + + return NextResponse.json(await suggestAmounts({ + eventName: event?.name || "Charity Event", + avgPledge: avg, + medianPledge: median, + pledgeCount: pledges.length, + topAmount: top, + })) + } catch (error) { + console.error("AI suggest error:", error) + return NextResponse.json(await suggestAmounts({ eventName: "Charity Event" })) + } +} diff --git a/pledge-now-pay-later/src/app/api/pledges/route.ts b/pledge-now-pay-later/src/app/api/pledges/route.ts index c941fbd..7b95fac 100644 --- a/pledge-now-pay-later/src/app/api/pledges/route.ts +++ b/pledge-now-pay-later/src/app/api/pledges/route.ts @@ -3,6 +3,7 @@ import prisma from "@/lib/prisma" import { createPledgeSchema } from "@/lib/validators" import { generateReference } from "@/lib/reference" import { calculateReminderSchedule } from "@/lib/reminders" +import { sendPledgeReceipt } from "@/lib/whatsapp" export async function POST(request: NextRequest) { try { @@ -125,6 +126,37 @@ export async function POST(request: NextRequest) { } } + // Async: Send WhatsApp receipt to donor (non-blocking) + if (donorPhone) { + sendPledgeReceipt(donorPhone, { + donorName: donorName || undefined, + amountPounds: (amountPence / 100).toFixed(0), + eventName: event.name, + reference: pledge.reference, + rail, + bankDetails: rail === "bank" && org.bankSortCode ? { + sortCode: org.bankSortCode, + accountNo: org.bankAccountNo || "", + accountName: org.bankAccountName || org.name, + } : undefined, + orgName: org.name, + }).catch(err => console.error("[WAHA] Receipt send failed:", err)) + } + + // Async: Notify volunteer if QR source has volunteer info + if (qrSourceId) { + prisma?.qrSource.findUnique({ + where: { id: qrSourceId }, + select: { volunteerName: true, label: true, pledges: { select: { amountPence: true } } }, + }).then(qr => { + // In future: if volunteer has a phone number stored, send WhatsApp notification + // For now, this is a no-op unless volunteer phone is added to schema + if (qr) { + console.log(`[PLEDGE] ${qr.volunteerName || qr.label}: +1 pledge (Ā£${(amountPence / 100).toFixed(0)})`) + } + }).catch(() => {}) + } + return NextResponse.json(response, { status: 201 }) } catch (error) { console.error("Pledge creation error:", error) diff --git a/pledge-now-pay-later/src/app/api/whatsapp/send/route.ts b/pledge-now-pay-later/src/app/api/whatsapp/send/route.ts new file mode 100644 index 0000000..436c57f --- /dev/null +++ b/pledge-now-pay-later/src/app/api/whatsapp/send/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server" +import { sendPledgeReceipt, sendPledgeReminder, getWhatsAppStatus } from "@/lib/whatsapp" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { type, phone, data } = body + + if (!phone) { + return NextResponse.json({ error: "Phone number required" }, { status: 400 }) + } + + let result + switch (type) { + case "receipt": + result = await sendPledgeReceipt(phone, data) + break + case "reminder": + result = await sendPledgeReminder(phone, data) + break + default: + return NextResponse.json({ error: "Unknown message type" }, { status: 400 }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("WhatsApp send error:", error) + return NextResponse.json({ error: "Failed to send" }, { status: 500 }) + } +} + +export async function GET() { + const status = await getWhatsAppStatus() + return NextResponse.json(status) +} diff --git a/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts b/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts new file mode 100644 index 0000000..b00cd43 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { sendWhatsAppMessage } from "@/lib/whatsapp" + +/** + * WAHA webhook — receives incoming WhatsApp messages + * Handles: PAID, HELP, CANCEL commands from donors + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { payload } = body + + // WAHA sends message events + if (!payload?.body || !payload?.from) { + return NextResponse.json({ ok: true }) + } + + const text = payload.body.trim().toUpperCase() + const fromPhone = payload.from.replace("@c.us", "") + + // Only handle known commands + if (!["PAID", "HELP", "CANCEL", "STATUS"].includes(text)) { + return NextResponse.json({ ok: true }) + } + + if (!prisma) return NextResponse.json({ ok: true }) + + // Find pledges by this phone number + // Normalize: try with and without country code + const phoneVariants = [fromPhone] + if (fromPhone.startsWith("44")) { + phoneVariants.push("0" + fromPhone.slice(2)) + phoneVariants.push("+" + fromPhone) + } + + const pledges = await prisma.pledge.findMany({ + where: { + donorPhone: { in: phoneVariants }, + status: { in: ["new", "initiated"] }, + }, + include: { + event: { select: { name: true } }, + paymentInstruction: true, + }, + orderBy: { createdAt: "desc" }, + take: 5, + }) + + if (pledges.length === 0) { + await sendWhatsAppMessage(fromPhone, `We couldn't find any pending pledges for this number. If you need help, please contact the charity directly.`) + return NextResponse.json({ ok: true }) + } + + const pledge = pledges[0] // Most recent + const amount = (pledge.amountPence / 100).toFixed(0) + + switch (text) { + case "PAID": { + await prisma.pledge.update({ + where: { id: pledge.id }, + data: { status: "initiated", iPaidClickedAt: new Date() }, + }) + await sendWhatsAppMessage(fromPhone, + `āœ… Thanks! We've noted that you've paid your *Ā£${amount}* pledge to ${pledge.event.name}.\n\nWe'll confirm once the payment is matched. Ref: \`${pledge.reference}\`` + ) + break + } + + case "HELP": { + const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null + if (bankDetails) { + await sendWhatsAppMessage(fromPhone, + `šŸ¦ *Bank Details for your Ā£${amount} pledge:*\n\nSort Code: \`${bankDetails.sortCode}\`\nAccount: \`${bankDetails.accountNo}\`\nName: ${bankDetails.accountName}\nReference: \`${pledge.reference}\`\n\nāš ļø _Use the exact reference above_` + ) + } else { + await sendWhatsAppMessage(fromPhone, + `Your pledge ref is \`${pledge.reference}\` for Ā£${amount} to ${pledge.event.name}.\n\nContact the charity for payment details.` + ) + } + break + } + + case "CANCEL": { + await prisma.pledge.update({ + where: { id: pledge.id }, + data: { status: "cancelled", cancelledAt: new Date() }, + }) + await sendWhatsAppMessage(fromPhone, + `Your Ā£${amount} pledge to ${pledge.event.name} has been cancelled. No worries — thank you for considering! šŸ™` + ) + break + } + + case "STATUS": { + const statusText = pledges.map(p => + `• Ā£${(p.amountPence / 100).toFixed(0)} → ${p.event.name} (${p.status}) [${p.reference}]` + ).join("\n") + await sendWhatsAppMessage(fromPhone, `šŸ“‹ *Your pledges:*\n\n${statusText}`) + break + } + } + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("WhatsApp webhook error:", error) + return NextResponse.json({ ok: true }) + } +} diff --git a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx index c2c7046..a3d7bf7 100644 --- a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx @@ -5,7 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Building2, CreditCard, Palette, Check, Loader2, AlertCircle } from "lucide-react" +import { Building2, CreditCard, Palette, Check, Loader2, AlertCircle, MessageCircle, Radio } from "lucide-react" +import { Badge } from "@/components/ui/badge" interface OrgSettings { name: string @@ -210,6 +211,9 @@ export default function SettingsPage() { + {/* WhatsApp (WAHA) */} + + {/* Branding */} @@ -264,3 +268,60 @@ export default function SettingsPage() { ) } + +function WhatsAppStatus() { + const [status, setStatus] = useState<{ connected: boolean; session: string; version?: string } | null>(null) + const [checking, setChecking] = useState(true) + + useEffect(() => { + fetch("/api/whatsapp/send") + .then(r => r.json()) + .then(data => setStatus(data)) + .catch(() => setStatus({ connected: false, session: "default" })) + .finally(() => setChecking(false)) + }, []) + + return ( + + + + WhatsApp Integration + {checking ? ( + + ) : status?.connected ? ( + Connected + ) : ( + Not Connected + )} + + + Send pledge receipts, payment reminders, and bank details via WhatsApp. + Donors can reply PAID, HELP, or CANCEL. + + + + {status?.connected ? ( +
+

āœ… WhatsApp is active

+

+ Session: {status.session} Ā· WAHA {status.version || ""} +

+
+

Auto-sends: Pledge receipts with bank details

+

Reminders: Gentle → Nudge → Urgent → Final

+

Chatbot: Donors reply PAID, HELP, CANCEL, STATUS

+
+
+ ) : ( +
+

WhatsApp not connected

+

+ Connect a WhatsApp number in WAHA ({`waha.quikcue.com`}) to enable automatic messaging. + The app works without it — messages will be skipped. +

+
+ )} +
+
+ ) +} diff --git a/pledge-now-pay-later/src/app/globals.css b/pledge-now-pay-later/src/app/globals.css index c1c4d07..0650615 100644 --- a/pledge-now-pay-later/src/app/globals.css +++ b/pledge-now-pay-later/src/app/globals.css @@ -2,7 +2,7 @@ @tailwind components; @tailwind utilities; -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap'); @layer base { :root { @@ -35,6 +35,7 @@ body { @apply bg-background text-foreground antialiased; font-family: 'Inter', system-ui, -apple-system, sans-serif; + -webkit-tap-highlight-color: transparent; } } @@ -43,3 +44,127 @@ @apply min-h-[48px] min-w-[48px]; } } + +/* Premium animations */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse-ring { + 0% { transform: scale(0.8); opacity: 1; } + 100% { transform: scale(2); opacity: 0; } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes confetti-fall { + 0% { transform: translateY(-10vh) rotate(0deg); opacity: 1; } + 100% { transform: translateY(100vh) rotate(720deg); opacity: 0; } +} + +@keyframes bounce-gentle { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} + +@keyframes counter-roll { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.animate-fade-up { animation: fadeUp 0.5s ease-out forwards; } +.animate-fade-in { animation: fadeIn 0.3s ease-out forwards; } +.animate-scale-in { animation: scaleIn 0.3s ease-out forwards; } +.animate-slide-down { animation: slideDown 0.3s ease-out forwards; } +.animate-pulse-ring { animation: pulse-ring 1.5s ease-out infinite; } +.animate-shimmer { + background: linear-gradient(90deg, transparent 30%, rgba(255,255,255,0.4) 50%, transparent 70%); + background-size: 200% 100%; + animation: shimmer 2s infinite; +} +.animate-bounce-gentle { animation: bounce-gentle 2s ease-in-out infinite; } +.animate-counter-roll { animation: counter-roll 0.4s ease-out forwards; } + +/* Stagger children animations */ +.stagger-children > * { opacity: 0; animation: fadeUp 0.4s ease-out forwards; } +.stagger-children > *:nth-child(1) { animation-delay: 0ms; } +.stagger-children > *:nth-child(2) { animation-delay: 60ms; } +.stagger-children > *:nth-child(3) { animation-delay: 120ms; } +.stagger-children > *:nth-child(4) { animation-delay: 180ms; } +.stagger-children > *:nth-child(5) { animation-delay: 240ms; } +.stagger-children > *:nth-child(6) { animation-delay: 300ms; } +.stagger-children > *:nth-child(7) { animation-delay: 360ms; } +.stagger-children > *:nth-child(8) { animation-delay: 420ms; } + +/* Glass effects */ +.glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.glass-dark { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +/* Premium card hover */ +.card-hover { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} +.card-hover:hover { + transform: translateY(-2px); + box-shadow: 0 12px 40px -8px rgba(0,0,0,0.12); +} +.card-hover:active { + transform: translateY(0) scale(0.98); +} + +/* Number input clean */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type="number"] { + -moz-appearance: textfield; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: hsl(var(--muted)); + border-radius: 2px; +} + +/* Selection */ +::selection { + background: #1e40af20; + color: #1e40af; +} diff --git a/pledge-now-pay-later/src/app/p/[token]/page.tsx b/pledge-now-pay-later/src/app/p/[token]/page.tsx index c42e7ee..95c081b 100644 --- a/pledge-now-pay-later/src/app/p/[token]/page.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx @@ -29,15 +29,6 @@ interface EventInfo { qrSourceLabel: string | null } -// Steps: -// 0 = Amount -// 1 = Payment method -// 2 = Identity (for bank transfer) -// 3 = Bank instructions -// 4 = Confirmation (card, DD) -// 5 = Card payment step -// 7 = Direct Debit step - export default function PledgePage() { const params = useParams() const token = params.token as string @@ -54,12 +45,7 @@ export default function PledgePage() { const [pledgeResult, setPledgeResult] = useState<{ id: string reference: string - bankDetails?: { - bankName: string - sortCode: string - accountNo: string - accountName: string - } + bankDetails?: { bankName: string; sortCode: string; accountNo: string; accountName: string } } | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState("") @@ -72,10 +58,7 @@ export default function PledgePage() { else setEventInfo(data) setLoading(false) }) - .catch(() => { - setError("Unable to load pledge page") - setLoading(false) - }) + .catch(() => { setError("Unable to load pledge page"); setLoading(false) }) fetch("/api/analytics", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -86,16 +69,21 @@ export default function PledgePage() { const handleAmountSelected = (amountPence: number) => { setPledgeData((d) => ({ ...d, amountPence })) setStep(1) + fetch("/api/analytics", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ eventType: "amount_selected", metadata: { amountPence, token } }), + }).catch(() => {}) } const handleRailSelected = (rail: Rail) => { setPledgeData((d) => ({ ...d, rail })) - const railStepMap: Record = { - bank: 2, - card: 5, - gocardless: 7, - } - setStep(railStepMap[rail]) + fetch("/api/analytics", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ eventType: "rail_selected", metadata: { rail, token } }), + }).catch(() => {}) + setStep(rail === "bank" ? 2 : rail === "card" ? 5 : 7) } const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean }) => { @@ -106,11 +94,7 @@ export default function PledgePage() { const res = await fetch("/api/pledges", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...finalData, - eventId: eventInfo?.id, - qrSourceId: eventInfo?.qrSourceId, - }), + body: JSON.stringify({ ...finalData, eventId: eventInfo?.id, qrSourceId: eventInfo?.qrSourceId }), }) const result = await res.json() if (result.error) { setError(result.error); return } @@ -123,8 +107,11 @@ export default function PledgePage() { if (loading) { return ( -
-
Loading...
+
+
+ 🤲 +
+

Loading...

) } @@ -132,49 +119,61 @@ export default function PledgePage() { if (error) { return (
-
-
šŸ˜”
+
+
šŸ˜”

Something went wrong

{error}

+
) } - const shareUrl = eventInfo?.qrSourceId ? `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}` : undefined + const shareUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}` const steps: Record = { - 0: , + 0: , 1: , 2: , - 3: pledgeResult && , - 4: pledgeResult && , + 3: pledgeResult && , + 4: pledgeResult && , 5: , 7: , } const backableSteps = new Set([1, 2, 5, 7]) - const getBackStep = (s: number): number => { - if (s === 5 || s === 7) return 1 - return s - 1 - } - const progressPercent = step >= 3 ? 100 : step >= 2 ? 66 : step >= 1 ? 33 : 10 + const getBackStep = (s: number): number => (s === 5 || s === 7) ? 1 : s - 1 + + // Smooth progress + const progressMap: Record = { 0: 8, 1: 33, 2: 55, 3: 100, 4: 100, 5: 55, 7: 55 } + const progressPercent = progressMap[step] || 10 return (
+ {/* Progress bar */}
-
+
-
-

{eventInfo?.organizationName}

-

{eventInfo?.qrSourceLabel || ""}

+ {/* Header */} +
+

{eventInfo?.organizationName}

+ {eventInfo?.qrSourceLabel && ( +

{eventInfo.qrSourceLabel}

+ )}
-
{steps[step]}
+ {/* Content */} +
{steps[step]}
+ {/* Back button */} {backableSteps.has(step) && ( -
+
- ))} + {/* Social proof */} + {suggestions?.socialProof && ( +
+
+ {[...Array(3)].map((_, i) => ( +
+ {["A", "S", "M"][i]} +
+ ))} +
+

+ + {suggestions.socialProof} +

+
+ )} + + {/* Amount grid */} +
+ {amounts.map((amount) => { + const isSelected = selected === amount + const isHovering = hovering === amount + const pounds = amount / 100 + + return ( + + ) + })}
- {/* Custom */} + {/* AI nudge */} + {suggestions?.nudge && ( +

+ + {suggestions.nudge} +

+ )} + + {/* Custom amount — premium input */}
- -
- Ā£ - inputRef.current?.focus()} + className="text-xs font-medium text-muted-foreground hover:text-trust-blue transition-colors cursor-pointer" + > + Or enter your own amount → + +
+ £ + handleCustomChange(e.target.value)} - className="pl-10 h-16 text-2xl font-bold text-center rounded-2xl" + className="w-full pl-10 pr-4 h-16 text-2xl font-black text-center rounded-2xl border-2 border-gray-200 bg-white focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all" />
+ {/* Live Gift Aid preview */} + {isValid && ( +
+

+ With Gift Aid:{" "} + your £{(activeAmount / 100).toFixed(0)} becomes{" "} + + £{((activeAmount + giftAidBonus) / 100).toFixed(0)} + +

+

HMRC adds 25% — at zero cost to you

+
+ )} + {/* Continue */}

- You won't be charged now. Choose how to pay next. + No payment now — choose how to pay on the next step

) diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/bank-instructions-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/bank-instructions-step.tsx index a88ac5d..5c02e3f 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/bank-instructions-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/bank-instructions-step.tsx @@ -1,9 +1,9 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" -import { Check, Copy, ExternalLink, MessageCircle, Share2 } from "lucide-react" +import { Check, Copy, MessageCircle, Share2, Sparkles, ExternalLink } from "lucide-react" interface Props { pledge: { @@ -18,26 +18,52 @@ interface Props { } amount: number eventName: string + donorPhone?: string } -export function BankInstructionsStep({ pledge, amount, eventName }: Props) { - const [copied, setCopied] = useState(false) +export function BankInstructionsStep({ pledge, amount, eventName, donorPhone }: Props) { + const [copiedField, setCopiedField] = useState(null) const [markedPaid, setMarkedPaid] = useState(false) + const [whatsappSent, setWhatsappSent] = useState(false) - const copyReference = async () => { - await navigator.clipboard.writeText(pledge.reference) - setCopied(true) - setTimeout(() => setCopied(false), 3000) + const bd = pledge.bankDetails + + // Send bank details to WhatsApp + useEffect(() => { + if (!donorPhone || whatsappSent) return + fetch("/api/whatsapp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "receipt", + phone: donorPhone, + data: { + amountPounds: (amount / 100).toFixed(0), + eventName, + reference: pledge.reference, + rail: "bank", + bankDetails: bd ? { sortCode: bd.sortCode, accountNo: bd.accountNo, accountName: bd.accountName } : undefined, + }, + }), + }).then(() => setWhatsappSent(true)).catch(() => {}) + }, [donorPhone, whatsappSent, amount, eventName, pledge.reference, bd]) + + const copyField = async (value: string, field: string) => { + await navigator.clipboard.writeText(value) + setCopiedField(field) + if (navigator.vibrate) navigator.vibrate(10) + setTimeout(() => setCopiedField(null), 2000) // Track fetch("/api/analytics", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ eventType: "instruction_copy_clicked", pledgeId: pledge.id }), + body: JSON.stringify({ eventType: "instruction_copy_clicked", pledgeId: pledge.id, metadata: { field } }), }).catch(() => {}) } const handleIPaid = async () => { setMarkedPaid(true) + if (navigator.vibrate) navigator.vibrate([10, 50, 10]) fetch(`/api/pledges/${pledge.id}/mark-initiated`, { method: "POST" }).catch(() => {}) fetch("/api/analytics", { method: "POST", @@ -46,23 +72,32 @@ export function BankInstructionsStep({ pledge, amount, eventName }: Props) { }).catch(() => {}) } - const bd = pledge.bankDetails - + // Post-payment view if (markedPaid) { return ( -
-
- +
+
+
+
+ +
-

Thank you!

+

Thank you!

- We'll confirm once your payment of £{(amount / 100).toFixed(2)} is received. + We'll confirm once your £{(amount / 100).toFixed(0)} is received.

+ + {whatsappSent && ( +
+ Details sent to your WhatsApp āœ“ +
+ )} +
Reference - {pledge.reference} + {pledge.reference}
Event @@ -70,26 +105,26 @@ export function BankInstructionsStep({ pledge, amount, eventName }: Props) {
- {/* Share CTA */} -
-

🤲 Know someone who'd donate too?

+ + {/* Share */} +
+
+ +

Know someone who'd donate too?

+

- Need help? Contact the charity directly. + Payments usually arrive within 2 hours. We'll email you once confirmed.

) } + // Copy-able field component + const CopyField = ({ label, value, fieldKey, mono }: { label: string; value: string; fieldKey: string; mono?: boolean }) => ( + + ) + return ( -
+
-
- šŸ¦ +
+ šŸ¦
-

- Transfer £{(amount / 100).toFixed(2)} +

+ Transfer £{(amount / 100).toFixed(0)}

-

- Use these details in your banking app +

+ Tap any field to copy Ā· Use your banking app

- {/* Bank details card */} + {whatsappSent && ( +
+ Bank details also sent to your WhatsApp +
+ )} + + {/* Bank details — tap to copy each field */} {bd && ( - - -
-
-

Sort Code

-

{bd.sortCode}

-
-
-

Account No

-

{bd.accountNo}

-
-
-
-

Account Name

-

{bd.accountName}

-
+ +
+ + + + )} - {/* Reference - THE KEY */} -
-

- Payment Reference — use exactly: -

-

- {pledge.reference} + {/* THE reference — the most important thing */} +

+

+ Payment Reference

+ +

+ āš ļø Use this exact reference so we can match your payment +

- {/* Suggestion */} -
-

- - Open your banking app now and search for "new payment" + {/* Open banking app hint */} +

+

+ + Open your banking app → New payment → Paste the details

{/* I've paid */} -

- Payments usually take 1-2 hours to arrive. We'll confirm once received. + Payments usually arrive within 2 hours. No rush — transfer at your convenience.

) diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx index 15fb64c..5dc5c63 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx @@ -1,8 +1,9 @@ "use client" -import { Check, Share2, MessageCircle } from "lucide-react" +import { useEffect, useState, useCallback } from "react" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" +import { Check, Share2, MessageCircle, Copy, Sparkles } from "lucide-react" interface Props { pledge: { id: string; reference: string } @@ -10,9 +11,49 @@ interface Props { rail: string eventName: string shareUrl?: string + donorPhone?: string } -export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl }: Props) { +// Mini confetti +function Confetti() { + const [pieces, setPieces] = useState>([]) + + useEffect(() => { + setPieces( + Array.from({ length: 40 }, () => ({ + x: Math.random() * 100, + color: ["#1e40af", "#16a34a", "#f59e0b", "#ec4899", "#8b5cf6"][Math.floor(Math.random() * 5)], + delay: Math.random() * 2, + size: 4 + Math.random() * 8, + })) + ) + }, []) + + return ( +
+ {pieces.map((p, i) => ( +
0.5 ? "50%" : "2px", + animation: `confetti-fall ${2 + Math.random() * 2}s ease-in forwards`, + animationDelay: `${p.delay}s`, + }} + /> + ))} +
+ ) +} + +export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, donorPhone }: Props) { + const [copied, setCopied] = useState(false) + const [whatsappSent, setWhatsappSent] = useState(false) + const railLabels: Record = { bank: "Bank Transfer", gocardless: "Direct Debit", @@ -21,10 +62,38 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl }: const nextStepMessages: Record = { bank: "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.", - gocardless: "Your Direct Debit mandate has been set up. The payment of Ā£" + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless. Protected by the Direct Debit Guarantee.", - card: "Your card payment has been processed. You'll receive a confirmation email shortly.", + gocardless: `Your Direct Debit mandate is set up. Ā£${(amount / 100).toFixed(2)} will be collected automatically in 3-5 working days. Protected by the Direct Debit Guarantee.`, + card: "Your card payment has been processed. Confirmation email is on its way.", } + // Send WhatsApp receipt if phone provided + const sendWhatsAppReceipt = useCallback(async () => { + if (!donorPhone || whatsappSent) return + try { + await fetch("/api/whatsapp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "receipt", + phone: donorPhone, + data: { + amountPounds: (amount / 100).toFixed(0), + eventName, + reference: pledge.reference, + rail, + }, + }), + }) + setWhatsappSent(true) + } catch { + // Silent fail — not critical + } + }, [donorPhone, whatsappSent, amount, eventName, pledge.reference, rail]) + + useEffect(() => { + sendWhatsAppReceipt() + }, [sendWhatsAppReceipt]) + const handleWhatsAppShare = () => { const text = `I just pledged Ā£${(amount / 100).toFixed(0)} to ${eventName}! 🤲\n\nYou can pledge too: ${shareUrl || window.location.origin}` window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank") @@ -39,89 +108,101 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl }: } } + const copyRef = async () => { + await navigator.clipboard.writeText(pledge.reference) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( -
-
- -
- -
-

- {rail === "gocardless" ? "Mandate Set Up!" : rail === "card" ? "Payment Complete!" : "Pledge Received!"} -

-

- Thank you for your generous {rail === "card" ? "donation" : "pledge"} to{" "} - {eventName} -

-
- - - -
- Amount - £{(amount / 100).toFixed(2)} + <> + +
+ {/* Success icon with pulse */} +
+
+
+
-
- Payment Method - {railLabels[rail] || rail} -
-
- Reference - {pledge.reference} -
- {rail === "gocardless" && ( -
- Collection - 3-5 working days -
- )} - {rail === "card" && ( -
- Status - Paid āœ“ -
- )} - - - - {/* What happens next */} -
-

What happens next?

-

- {nextStepMessages[rail] || nextStepMessages.bank} -

-
- - {/* Share / encourage others */} -
-

- 🤲 Spread the word — every pledge counts! -

-

- Share with friends and family so they can pledge too. -

-
- -
-
-

- Need help? Contact the charity directly. Ref: {pledge.reference} -

-
+
+

+ {rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"} +

+

+ Thank you for your generous support of{" "} + {eventName} +

+
+ + {/* Details card */} + +
+ +
+ Amount + £{(amount / 100).toFixed(2)} +
+
+ Method + {railLabels[rail] || rail} +
+
+ Reference + +
+ {rail === "card" && ( +
+ Status + + Paid + +
+ )} +
+ + + {/* What happens next */} +
+

What happens next?

+

{nextStepMessages[rail] || nextStepMessages.bank}

+
+ + {whatsappSent && ( +
+ Receipt sent to your WhatsApp āœ“ +
+ )} + + {/* Share */} +
+
+ +

+ Double your impact — share with friends +

+
+

+ Every share can inspire another pledge +

+
+ + +
+
+ +

+ Need help? Contact the charity directly Ā· Ref: {pledge.reference} +

+
+ ) } diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx index 888da29..ff0fcb8 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx @@ -1,10 +1,8 @@ "use client" -import { useState } from "react" +import { useState, useRef, useEffect } from "react" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Gift, Shield } from "lucide-react" +import { Gift, Shield, Sparkles, Phone, Mail } from "lucide-react" interface Props { onSubmit: (data: { @@ -22,10 +20,15 @@ export function IdentityStep({ onSubmit, amount }: Props) { const [phone, setPhone] = useState("") const [giftAid, setGiftAid] = useState(false) const [submitting, setSubmitting] = useState(false) + const [contactMode, setContactMode] = useState<"email" | "phone">("email") + const nameRef = useRef(null) - const hasContact = email.includes("@") || phone.length >= 10 + useEffect(() => { nameRef.current?.focus() }, []) + + const hasContact = contactMode === "email" ? email.includes("@") : phone.length >= 10 const isValid = hasContact const giftAidBonus = Math.round(amount * 0.25) + const totalWithAid = amount + giftAidBonus const handleSubmit = async () => { if (!isValid) return @@ -38,123 +41,168 @@ export function IdentityStep({ onSubmit, amount }: Props) { } return ( -
+
-

+

Almost there!

-

- We need a way to send you payment details +

+ We just need a way to send you payment details

-
-
- - + {/* Name */} +
+ setName(e.target.value)} autoComplete="name" + className="w-full h-14 px-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all" /> + {name && ( +
āœ“
+ )}
-
- - setEmail(e.target.value)} - autoComplete="email" - inputMode="email" - /> -

- We'll send your payment instructions and receipt here -

+ {/* Contact mode toggle */} +
+ +
-
-
- or -
-
- -
- - setPhone(e.target.value)} - autoComplete="tel" - inputMode="tel" - /> -

- We can send reminders via SMS if you prefer -

-
- - {/* Gift Aid — prominent UK-specific */} -
setGiftAid(!giftAid)} - className={`rounded-2xl border-2 p-5 cursor-pointer transition-all ${ - giftAid - ? "border-success-green bg-success-green/5 shadow-md shadow-success-green/10" - : "border-gray-200 bg-white hover:border-success-green/50" - }`} - > -
-
- -
-
-
- {}} - className="h-5 w-5 rounded border-gray-300 text-success-green focus:ring-success-green" - /> - Add Gift Aid - {giftAid && ( - - +Ā£{(giftAidBonus / 100).toFixed(0)} free - - )} -
-

- Boost your Ā£{(amount / 100).toFixed(0)} pledge to{" "} - Ā£{((amount + giftAidBonus) / 100).toFixed(0)} at no extra cost. - HMRC adds 25% — the charity claims it back. -

- {giftAid && ( -

- I confirm I am a UK taxpayer and understand that if I pay less Income Tax and/or - Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that - tax year it is my responsibility to pay any difference. -

- )} -
+ {/* Contact input */} + {contactMode === "email" ? ( +
+ + setEmail(e.target.value)} + autoComplete="email" + inputMode="email" + className="w-full h-14 pl-12 pr-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all" + />
-
+ ) : ( +
+ + setPhone(e.target.value)} + autoComplete="tel" + inputMode="tel" + className="w-full h-14 pl-12 pr-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all" + /> +

+ We'll send reminders via WhatsApp āœ“ +

+
+ )}
+ {/* Gift Aid — the hero */} + + + {/* Submit */}
- Your data is kept secure and only used for this pledge + Your data is encrypted and only used for this pledge
) diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx index 6e993ef..d96db05 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/payment-step.tsx @@ -1,6 +1,6 @@ "use client" -import { Building2, CreditCard, Landmark } from "lucide-react" +import { Building2, CreditCard, Landmark, Shield, CheckCircle2 } from "lucide-react" interface Props { onSelect: (rail: "bank" | "gocardless" | "card") => void @@ -8,90 +8,115 @@ interface Props { } export function PaymentStep({ onSelect, amount }: Props) { - const pounds = (amount / 100).toFixed(2) + const pounds = (amount / 100).toFixed(0) + const giftAidTotal = ((amount + amount * 0.25) / 100).toFixed(0) const options = [ { id: "bank" as const, icon: Building2, title: "Bank Transfer", - subtitle: "Zero fees — 100% goes to charity", + subtitle: "100% goes to charity — zero fees", tag: "Recommended", - tagColor: "bg-success-green text-white", - detail: "Use your banking app to transfer directly. We'll give you the details.", - fee: "No fees", - feeColor: "text-success-green", + tagClass: "bg-success-green text-white", + detail: "We'll give you the bank details. Transfer in your own time.", + fee: "Free", + feeClass: "text-success-green font-bold", + iconBg: "from-emerald-500 to-green-600", + highlight: true, + benefits: ["Zero fees", "Most charities prefer this"], }, { id: "gocardless" as const, icon: Landmark, title: "Direct Debit", subtitle: "Automatic collection — set and forget", - tag: "Set up once", - tagColor: "bg-trust-blue/10 text-trust-blue", - detail: "We'll collect via GoCardless. Protected by the Direct Debit Guarantee.", + tag: "Hassle-free", + tagClass: "bg-trust-blue/10 text-trust-blue", + detail: "GoCardless collects it for you. Protected by the DD Guarantee.", fee: "1% + 20p", - feeColor: "text-muted-foreground", + feeClass: "text-muted-foreground", + iconBg: "from-trust-blue to-blue-600", + highlight: false, + benefits: ["No action needed", "DD Guarantee"], }, { id: "card" as const, icon: CreditCard, - title: "Debit or Credit Card", - subtitle: "Pay instantly by Visa, Mastercard, or Amex", + title: "Card Payment", + subtitle: "Visa, Mastercard, Amex — instant", tag: "Instant", - tagColor: "bg-purple-100 text-purple-700", - detail: "Secure payment powered by Stripe. Receipt emailed immediately.", + tagClass: "bg-purple-100 text-purple-700", + detail: "Powered by Stripe. Receipt emailed instantly.", fee: "1.4% + 20p", - feeColor: "text-muted-foreground", + feeClass: "text-muted-foreground", + iconBg: "from-purple-500 to-violet-600", + highlight: false, + benefits: ["Instant confirmation", "All major cards"], }, ] return ( -
+
-

+

How would you like to pay?

-

+

Your pledge: £{pounds} + (£{giftAidTotal} with Gift Aid)

-
+
{options.map((opt) => ( ))}
-

- All payments are secure. Bank transfers mean 100% reaches the charity. -

+
+ + All payments are encrypted and secure +
) } diff --git a/pledge-now-pay-later/src/lib/ai.ts b/pledge-now-pay-later/src/lib/ai.ts new file mode 100644 index 0000000..f1fc4bd --- /dev/null +++ b/pledge-now-pay-later/src/lib/ai.ts @@ -0,0 +1,186 @@ +/** + * AI module — uses OpenAI GPT-4o-mini (nano model, ~$0.15/1M input tokens) + * Falls back to smart heuristics when no API key is set + */ + +const OPENAI_KEY = process.env.OPENAI_API_KEY +const MODEL = "gpt-4o-mini" + +interface ChatMessage { + role: "system" | "user" | "assistant" + content: string +} + +async function chat(messages: ChatMessage[], maxTokens = 300): Promise { + if (!OPENAI_KEY) return "" + + try { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${OPENAI_KEY}`, + }, + body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.7 }), + }) + const data = await res.json() + return data.choices?.[0]?.message?.content || "" + } catch { + return "" + } +} + +/** + * Generate smart amount suggestions based on event context + peer data + */ +export async function suggestAmounts(context: { + eventName: string + avgPledge?: number + medianPledge?: number + pledgeCount?: number + topAmount?: number + currency?: string +}): Promise<{ amounts: number[]; nudge: string; socialProof: string }> { + const avg = context.avgPledge || 5000 // 50 quid default + const median = context.medianPledge || avg + + // Smart anchoring: show amounts around the median, biased upward + const base = Math.round(median / 1000) * 1000 || 5000 + const amounts = [ + Math.max(1000, Math.round(base * 0.5 / 500) * 500), + base, + Math.round(base * 2 / 1000) * 1000, + Math.round(base * 5 / 1000) * 1000, + Math.round(base * 10 / 1000) * 1000, + Math.round(base * 20 / 1000) * 1000, + ].filter((v, i, a) => a.indexOf(v) === i && v >= 500) // dedup, min Ā£5 + + // Social proof text + let socialProof = "" + if (context.pledgeCount && context.pledgeCount > 3) { + socialProof = `${context.pledgeCount} people have pledged so far` + if (context.avgPledge) { + socialProof += ` Ā· Average Ā£${Math.round(context.avgPledge / 100)}` + } + } + + // AI-generated nudge (or fallback) + let nudge = "" + if (OPENAI_KEY && context.pledgeCount && context.pledgeCount > 5) { + nudge = await chat([ + { + role: "system", + content: "You write one short, warm, encouraging line (max 12 words) to nudge a charity donor to pledge generously. UK English. No emojis. No pressure.", + }, + { + role: "user", + content: `Event: ${context.eventName}. ${context.pledgeCount} people pledged avg Ā£${Math.round((context.avgPledge || 5000) / 100)}. Generate a nudge.`, + }, + ], 30) + } + + if (!nudge) { + const nudges = [ + "Every pound makes a real difference", + "Your generosity changes lives", + "Join others making an impact today", + "Be part of something meaningful", + ] + nudge = nudges[Math.floor(Math.random() * nudges.length)] + } + + return { amounts: amounts.slice(0, 6), nudge, socialProof } +} + +/** + * Generate a personalized thank-you / reminder message + */ +export async function generateMessage(type: "thank_you" | "reminder_gentle" | "reminder_urgent" | "whatsapp_receipt", vars: { + donorName?: string + amount: string + eventName: string + reference: string + orgName?: string + daysSincePledge?: number +}): Promise { + const name = vars.donorName?.split(" ")[0] || "there" + + // Templates with AI enhancement + const templates: Record = { + thank_you: `Thank you${name !== "there" ? `, ${name}` : ""}! Your Ā£${vars.amount} pledge to ${vars.eventName} means the world. Ref: ${vars.reference}`, + reminder_gentle: `Hi ${name}, just a friendly nudge about your Ā£${vars.amount} pledge to ${vars.eventName}. If you've already paid — thank you! Ref: ${vars.reference}`, + reminder_urgent: `Hi ${name}, your Ā£${vars.amount} pledge to ${vars.eventName} is still pending after ${vars.daysSincePledge || "a few"} days. We'd love to close this out — every penny counts. Ref: ${vars.reference}`, + whatsapp_receipt: `🤲 *Pledge Confirmed!*\n\nšŸ’· Amount: Ā£${vars.amount}\nšŸ“‹ Event: ${vars.eventName}\nšŸ”– Reference: \`${vars.reference}\`\n\n${name !== "there" ? `Thank you, ${name}!` : "Thank you!"} Your generosity makes a real difference.\n\n_Powered by Pledge Now, Pay Later_`, + } + + let msg = templates[type] || templates.thank_you + + // Try AI-enhanced version for reminders + if (OPENAI_KEY && (type === "reminder_gentle" || type === "reminder_urgent")) { + const aiMsg = await chat([ + { + role: "system", + content: `You write short, warm ${type === "reminder_urgent" ? "but firm" : "and gentle"} payment reminder messages for a UK charity. Max 3 sentences. Include the reference number. UK English. Be human, not corporate.`, + }, + { + role: "user", + content: `Donor: ${name}. Amount: Ā£${vars.amount}. Event: ${vars.eventName}. Reference: ${vars.reference}. Days since pledge: ${vars.daysSincePledge || "?"}. Generate the message.`, + }, + ], 100) + if (aiMsg) msg = aiMsg + } + + return msg +} + +/** + * AI-powered fuzzy reference matching for bank reconciliation + */ +export async function smartMatch(bankDescription: string, candidates: Array<{ ref: string; amount: number; donor: string }>): Promise<{ + matchedRef: string | null + confidence: number + reasoning: string +}> { + if (!OPENAI_KEY || candidates.length === 0) { + return { matchedRef: null, confidence: 0, reasoning: "No AI key or no candidates" } + } + + const candidateList = candidates.map(c => `${c.ref} (Ā£${(c.amount / 100).toFixed(2)}, ${c.donor || "anonymous"})`).join(", ") + + const result = await chat([ + { + role: "system", + content: 'You match bank transaction descriptions to pledge references. Return ONLY valid JSON: {"ref":"MATCHED_REF","confidence":0.0-1.0,"reasoning":"short reason"}. If no match, ref should be null.', + }, + { + role: "user", + content: `Bank description: "${bankDescription}"\nPossible pledge refs: ${candidateList}\n\nWhich pledge reference does this bank transaction match?`, + }, + ], 80) + + try { + const parsed = JSON.parse(result) + return { + matchedRef: parsed.ref || null, + confidence: parsed.confidence || 0, + reasoning: parsed.reasoning || "", + } + } catch { + return { matchedRef: null, confidence: 0, reasoning: "Parse error" } + } +} + +/** + * Generate event description from a simple prompt + */ +export async function generateEventDescription(prompt: string): Promise { + if (!OPENAI_KEY) return "" + + return chat([ + { + role: "system", + content: "You write concise, compelling charity event descriptions for a UK audience. Max 2 sentences. Warm and inviting.", + }, + { role: "user", content: prompt }, + ], 60) +} diff --git a/pledge-now-pay-later/src/lib/whatsapp.ts b/pledge-now-pay-later/src/lib/whatsapp.ts new file mode 100644 index 0000000..f279220 --- /dev/null +++ b/pledge-now-pay-later/src/lib/whatsapp.ts @@ -0,0 +1,200 @@ +/** + * WAHA (WhatsApp HTTP API) integration + * Connects to waha.quikcue.com for sending WhatsApp messages + * + * WAHA runs as a Docker service in the same Swarm overlay network. + * From within the pnpl container, we reach it via the published port or service DNS. + */ + +const WAHA_URL = process.env.WAHA_API_URL || "http://tasks.qc-comms_waha:3000" +const WAHA_KEY = process.env.WAHA_API_KEY || "qc-waha-api-7Fp3nR9xYm2K" +const WAHA_SESSION = process.env.WAHA_SESSION || "default" + +interface WahaResponse { + id?: string + error?: string + message?: string +} + +async function wahaFetch(path: string, body?: Record): Promise { + try { + const res = await fetch(`${WAHA_URL}${path}`, { + method: body ? "POST" : "GET", + headers: { + "Content-Type": "application/json", + "X-Api-Key": WAHA_KEY, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(10000), + }) + return res.json() + } catch (err) { + console.error("[WAHA]", path, err) + return { error: String(err) } + } +} + +/** + * Normalize UK phone to WhatsApp JID format + * 07700900000 → 447700900000@c.us + * +447700900000 → 447700900000@c.us + */ +function toJid(phone: string): string { + let clean = phone.replace(/[\s\-\(\)]/g, "") + if (clean.startsWith("+")) clean = clean.slice(1) + if (clean.startsWith("0")) clean = "44" + clean.slice(1) // UK + if (!clean.includes("@")) clean += "@c.us" + return clean +} + +/** + * Check if WAHA session is connected and ready + */ +export async function isWhatsAppReady(): Promise { + try { + const sessions = await wahaFetch("/api/sessions") + if (Array.isArray(sessions)) { + return sessions.some((s: { name: string; status: string }) => + s.name === WAHA_SESSION && s.status === "WORKING" + ) + } + return false + } catch { + return false + } +} + +/** + * Send a text message via WhatsApp + */ +export async function sendWhatsAppMessage( + phone: string, + text: string +): Promise<{ success: boolean; messageId?: string; error?: string }> { + const jid = toJid(phone) + const result = await wahaFetch(`/api/sendText`, { + session: WAHA_SESSION, + chatId: jid, + text, + }) + + if (result.id) { + return { success: true, messageId: result.id } + } + return { success: false, error: result.error || result.message || "Unknown error" } +} + +/** + * Send pledge receipt via WhatsApp + */ +export async function sendPledgeReceipt(phone: string, data: { + donorName?: string + amountPounds: string + eventName: string + reference: string + rail: string + bankDetails?: { sortCode: string; accountNo: string; accountName: string } + orgName?: string +}): Promise<{ success: boolean; error?: string }> { + const name = data.donorName?.split(" ")[0] || "there" + const railEmoji: Record = { bank: "šŸ¦", card: "šŸ’³", gocardless: "šŸ›ļø" } + const railLabel: Record = { bank: "Bank Transfer", card: "Card", gocardless: "Direct Debit" } + + let msg = `🤲 *Pledge Confirmed!*\n\n` + msg += `Thank you, ${name}!\n\n` + msg += `šŸ’· *Ā£${data.amountPounds}* pledged to *${data.eventName}*\n` + msg += `${railEmoji[data.rail] || "šŸ’°"} Via: ${railLabel[data.rail] || data.rail}\n` + msg += `šŸ”– Ref: \`${data.reference}\`\n` + + if (data.rail === "bank" && data.bankDetails) { + msg += `\n━━━━━━━━━━━━━━━━━━\n` + msg += `*Transfer to:*\n` + msg += `Sort Code: \`${data.bankDetails.sortCode}\`\n` + msg += `Account: \`${data.bankDetails.accountNo}\`\n` + msg += `Name: ${data.bankDetails.accountName}\n` + msg += `Reference: \`${data.reference}\`\n` + msg += `━━━━━━━━━━━━━━━━━━\n` + msg += `\nāš ļø _Use the exact reference above_\n` + } + + if (data.rail === "gocardless") { + msg += `\nāœ… Direct Debit will be collected in 3-5 working days.\n` + msg += `Protected by the Direct Debit Guarantee.\n` + } + + if (data.rail === "card") { + msg += `\nāœ… Payment processed — receipt sent to your email.\n` + } + + msg += `\n_${data.orgName || "Powered by Pledge Now, Pay Later"}_` + + return sendWhatsAppMessage(phone, msg) +} + +/** + * Send a payment reminder via WhatsApp + */ +export async function sendPledgeReminder(phone: string, data: { + donorName?: string + amountPounds: string + eventName: string + reference: string + daysSincePledge: number + step: number // 0=gentle, 1=nudge, 2=urgent, 3=final +}): Promise<{ success: boolean; error?: string }> { + const name = data.donorName?.split(" ")[0] || "there" + + const templates = [ + // Step 0: Gentle + `Hi ${name} šŸ‘‹\n\nJust a quick reminder about your *Ā£${data.amountPounds}* pledge to ${data.eventName}.\n\nIf you've already paid — thank you! šŸ™\nIf not, your ref is: \`${data.reference}\`\n\nReply *PAID* if you've sent it, or *HELP* if you need the bank details again.`, + // Step 1: Nudge + `Hi ${name},\n\nYour *Ā£${data.amountPounds}* pledge to ${data.eventName} is still pending (${data.daysSincePledge} days).\n\nEvery pound makes a real difference. 🤲\n\nRef: \`${data.reference}\`\n\nReply *PAID* once transferred, or *CANCEL* to withdraw.`, + // Step 2: Urgent + `Hi ${name},\n\nWe're reaching out about your *Ā£${data.amountPounds}* pledge from ${data.eventName}.\n\nIt's been ${data.daysSincePledge} days and we haven't received the payment yet.\n\nRef: \`${data.reference}\`\n\nReply *PAID*, *HELP*, or *CANCEL*.`, + // Step 3: Final + `Hi ${name},\n\nThis is our final message about your *Ā£${data.amountPounds}* pledge to ${data.eventName}.\n\nWe completely understand if circumstances have changed. Reply:\n\n*PAID* — if you've sent it\n*CANCEL* — to withdraw the pledge\n*HELP* — to get bank details\n\nRef: \`${data.reference}\``, + ] + + const text = templates[Math.min(data.step, 3)] + return sendWhatsAppMessage(phone, text) +} + +/** + * Notify volunteer when someone pledges at their table/QR + */ +export async function notifyVolunteer(phone: string, data: { + volunteerName: string + donorName?: string + amountPounds: string + eventName: string + totalPledges: number + totalAmount: string +}): Promise<{ success: boolean; error?: string }> { + let msg = `šŸŽ‰ *New Pledge!*\n\n` + msg += `${data.donorName || "Someone"} just pledged *Ā£${data.amountPounds}* at your table!\n\n` + msg += `šŸ“Š Your total: *${data.totalPledges} pledges* Ā· *Ā£${data.totalAmount}*\n` + msg += `\nKeep going, ${data.volunteerName}! šŸ’Ŗ` + + return sendWhatsAppMessage(phone, msg) +} + +/** + * Check WAHA status for health checks + */ +export async function getWhatsAppStatus(): Promise<{ + connected: boolean + session: string + version?: string +}> { + try { + const ready = await isWhatsAppReady() + const version = await wahaFetch("/api/version") + return { + connected: ready, + session: WAHA_SESSION, + version: (version as { version?: string }).version, + } + } catch { + return { connected: false, session: WAHA_SESSION } + } +}