diff --git a/pledge-now-pay-later/src/app/api/automations/ai/route.ts b/pledge-now-pay-later/src/app/api/automations/ai/route.ts index 9e37094..312c4cb 100644 --- a/pledge-now-pay-later/src/app/api/automations/ai/route.ts +++ b/pledge-now-pay-later/src/app/api/automations/ai/route.ts @@ -95,11 +95,11 @@ export async function POST(request: NextRequest) { }) const stepLabels: Record = { - 0: "pledge receipt (instant confirmation — MUST include full bank transfer details)", - 4: "due date reminder (sent on the day the donor said they'd pay — MUST include due date and bank details)", - 1: "gentle reminder (day 2, donor hasn't paid yet — friendly check-in)", - 2: "impact nudge (day 7, building urgency with purpose — why their money matters)", - 3: "final reminder (day 14, last message before marking overdue — respect + options)", + 0: "pledge receipt (instant confirmation — MUST include {{payment_block}} for payment details)", + 4: "due date reminder (sent on the day the donor said they'd pay — MUST include {{due_date}} and {{payment_block}})", + 1: "gentle reminder (day 2, donor hasn't paid yet — friendly check-in with {{pay_link}})", + 2: "impact nudge (day 7, building urgency with purpose — why their money matters, include {{pay_link}})", + 3: "final reminder (day 14, last message before marking overdue — respect + options, include {{pay_link}} and {{cancel_url}})", } const channelRules: Record = { @@ -159,10 +159,28 @@ ${requiredStr} These are template placeholders that get filled in with real data at send time. If you leave any out, the message is BROKEN and the donor won't know how to pay. -${step === 0 ? `STEP 0 IS THE RECEIPT. The donor just pledged. They NEED the bank details to actually transfer the money. You MUST include a bank details section with {{sort_code}}, {{account_no}}, {{bank_name}}, and {{reference}}. Without this, the donor CANNOT PAY.` : ""} -${step === 4 ? `STEP 4 IS THE DUE DATE REMINDER. Sent on the exact day the donor said they'd pay. You MUST include {{due_date}} so they know today is the day. Include bank details ({{sort_code}}, {{account_no}}, {{bank_name}}) so they can pay right now without looking anything up.` : ""} +UNIVERSAL CTA SYSTEM — READ THIS: +We DON'T always process payments ourselves. Donors might pay via bank transfer, card (Stripe), Direct Debit, or EXTERNAL platforms (LaunchGood, JustGiving, GoFundMe). The template must work for ALL of these. That's why we have two smart CTA variables: -All available variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{bank_name}}, {{sort_code}}, {{account_no}}, {{org_name}}, {{days}}, {{due_date}}, {{cancel_url}}, {{pledge_url}} +{{payment_block}} — Renders the FULL payment instruction. Adapts automatically at send time: + • Bank transfer → sort code, account number, reference + • External platform → link to LaunchGood/JustGiving + • Card → link to Stripe checkout + • Direct Debit → "DD is set up, payment collected automatically" + USE THIS in receipts (step 0) and due date messages (step 4). Just write {{payment_block}} on its own line. + +{{pay_link}} — Single clickable link to complete payment. Adapts at send time: + • External → links to the platform directly + • Everything else → links to /p/pay which shows the right thing + USE THIS in reminders (steps 1, 2, 3) as the primary CTA. + +NEVER hardcode bank details like sort codes or account numbers. Use {{payment_block}} or {{pay_link}} instead. They handle everything. + +${step === 0 ? `STEP 0 IS THE RECEIPT. The donor just pledged. Include {{payment_block}} — it gives them everything they need to pay, regardless of payment method.` : ""} +${step === 4 ? `STEP 4 IS THE DUE DATE REMINDER. Today is the day. Include {{due_date}} so they know, and {{payment_block}} so they can act immediately.` : ""} +${[1, 2, 3].includes(step) ? `THIS IS A REMINDER. The CTA is {{pay_link}} — ONE link that takes them to the right place. Don't include {{payment_block}} (too heavy for a reminder). Keep the CTA clear: "Complete your pledge: {{pay_link}}"` : ""} + +All available variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{payment_block}}, {{pay_link}}, {{org_name}}, {{days}}, {{due_date}}, {{cancel_url}}, {{pledge_url}} Rules: - ${channelRules[channel] || "Keep appropriate for the channel."} @@ -297,6 +315,12 @@ Generate a challenger variant B using a DIFFERENT psychological approach. Rememb CRITICAL: You MUST keep ALL these template variables: ${requiredStr} These are placeholders filled with real data. If you remove any, the message breaks. +CTA RULES: +- {{payment_block}} renders full payment details (bank transfer, external platform link, card link, or DD confirmation). Keep it as-is. +- {{pay_link}} is a single link to complete payment (adapts to any payment method). Keep it as-is. +- NEVER replace {{payment_block}} with hardcoded bank details. +- EVERY message must have a clear CTA to complete the pledge. + ${channelHints[channel] || ""} Return ONLY the rewritten message text — nothing else. No JSON, no explanation, no markdown blocks.` }, diff --git a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts index 48ff50a..ef8a3e1 100644 --- a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts +++ b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" import { sendWhatsAppMessage, isWhatsAppReady } from "@/lib/whatsapp" import { generateReminderContent } from "@/lib/reminders" -import { resolveTemplate } from "@/lib/templates" +import { resolveTemplate, buildPaymentBlock, computePayLink } from "@/lib/templates" /** * Process and send pending reminders + due date messages. @@ -48,7 +48,12 @@ export async function GET(request: NextRequest) { status: { notIn: ["paid", "cancelled"] }, }, include: { - event: { select: { name: true } }, + event: { + select: { + name: true, paymentMode: true, + externalUrl: true, externalPlatform: true, + }, + }, organization: { select: { id: true, name: true, bankSortCode: true, @@ -84,15 +89,39 @@ export async function GET(request: NextRequest) { ? pledge.dueDate.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }) : "today" + const sortCode = bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A" + const accountNo = bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A" + const bankName = bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A" + + const payLink = computePayLink({ + paymentMode: pledge.event.paymentMode, + externalUrl: pledge.event.externalUrl || undefined, + reference: pledge.reference, + baseUrl, + }) + + const paymentBlock = buildPaymentBlock({ + rail: pledge.rail, + paymentMode: pledge.event.paymentMode, + reference: pledge.reference, + payLink, + sortCode, accountNo, bankName, + externalUrl: pledge.event.externalUrl || undefined, + externalPlatform: pledge.event.externalPlatform || undefined, + channel: "whatsapp", + }) + const vars: Record = { name: pledge.donorName?.split(" ")[0] || "there", amount: (pledge.amountPence / 100).toFixed(0), event: pledge.event.name, reference: pledge.reference, due_date: dueFormatted, - sort_code: bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A", - account_no: bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A", - bank_name: bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A", + payment_block: paymentBlock, + pay_link: payLink, + sort_code: sortCode, + account_no: accountNo, + bank_name: bankName, org_name: pledge.organization.name || "Our charity", days: String(Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)), cancel_url: `${baseUrl}/p/cancel?ref=${pledge.reference}`, @@ -103,8 +132,8 @@ export async function GET(request: NextRequest) { if (customTemplate) { messageText = resolveTemplate(customTemplate.body, vars) } else { - // Fallback hardcoded due date message - messageText = `Salaam ${vars.name} 👋\n\nJust a heads up — your *£${vars.amount}* pledge to *${vars.event}* is due today (${vars.due_date}).\n\nYour ref: \`${vars.reference}\`\n\n━━━━━━━━━━━━━━━━━━\nSort Code: \`${vars.sort_code}\`\nAccount: \`${vars.account_no}\`\nName: ${vars.bank_name}\nReference: \`${vars.reference}\`\n━━━━━━━━━━━━━━━━━━\n\nAlready sent it? Reply *PAID* 🙏\nNeed help? Reply *HELP*` + // Fallback hardcoded due date message using payment block + messageText = `Salaam ${vars.name} 👋\n\nJust a heads up — your *£${vars.amount}* pledge to *${vars.event}* is due today (${vars.due_date}).\n\nYour ref: \`${vars.reference}\`\n\n${paymentBlock}\n\nAlready sorted? Reply *PAID* 🙏\nNeed help? Reply *HELP*` } const result = await sendWhatsAppMessage(phone, messageText) @@ -150,7 +179,12 @@ export async function GET(request: NextRequest) { include: { pledge: { include: { - event: { select: { name: true } }, + event: { + select: { + name: true, paymentMode: true, + externalUrl: true, externalPlatform: true, + }, + }, organization: { select: { id: true, name: true, bankSortCode: true, @@ -173,15 +207,39 @@ export async function GET(request: NextRequest) { const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000) const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null - // Build template variables + // Build template variables with universal CTA + const sortCode = bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A" + const accountNo = bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A" + const bankName = bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A" + + const payLink = computePayLink({ + paymentMode: pledge.event.paymentMode, + externalUrl: pledge.event.externalUrl || undefined, + reference: pledge.reference, + baseUrl, + }) + + const paymentBlock = buildPaymentBlock({ + rail: pledge.rail, + paymentMode: pledge.event.paymentMode, + reference: pledge.reference, + payLink, + sortCode, accountNo, bankName, + externalUrl: pledge.event.externalUrl || undefined, + externalPlatform: pledge.event.externalPlatform || undefined, + channel, + }) + const vars: Record = { name: pledge.donorName?.split(" ")[0] || "there", amount: (pledge.amountPence / 100).toFixed(0), event: pledge.event.name, reference: pledge.reference, - sort_code: bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A", - account_no: bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A", - bank_name: bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A", + payment_block: paymentBlock, + pay_link: payLink, + sort_code: sortCode, + account_no: accountNo, + bank_name: bankName, org_name: pledge.organization.name || "Our charity", days: String(daysSince), due_date: pledge.dueDate diff --git a/pledge-now-pay-later/src/app/api/pledges/pay-info/route.ts b/pledge-now-pay-later/src/app/api/pledges/pay-info/route.ts new file mode 100644 index 0000000..44e1f10 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/pledges/pay-info/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +/** + * GET /api/pledges/pay-info?ref=PNPL-XXXX + * + * Public endpoint (no auth) — returns payment info for a pledge. + * Used by /p/pay to show the right payment CTA. + * + * Returns different data depending on rail/paymentMode: + * - Bank: sort code, account, bank name, reference + * - External: external URL, platform name + * - Card: flag to trigger Stripe checkout + * - GoCardless: "set up" flag + */ +export async function GET(request: NextRequest) { + if (!prisma) return NextResponse.json({ error: "Service unavailable" }, { status: 503 }) + + const ref = request.nextUrl.searchParams.get("ref") + if (!ref) return NextResponse.json({ error: "Missing ref parameter" }, { status: 400 }) + + const pledge = await prisma.pledge.findUnique({ + where: { reference: ref }, + include: { + event: { + select: { + name: true, paymentMode: true, + externalUrl: true, externalPlatform: true, + }, + }, + organization: { + select: { + name: true, bankSortCode: true, + bankAccountNo: true, bankAccountName: true, bankName: true, + }, + }, + paymentInstruction: true, + }, + }) + + if (!pledge) { + return NextResponse.json({ error: "Pledge not found. Check your reference." }, { status: 404 }) + } + + const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null + + return NextResponse.json({ + reference: pledge.reference, + amountPence: pledge.amountPence, + status: pledge.status, + rail: pledge.rail, + eventName: pledge.event.name, + orgName: pledge.organization.name, + paymentMode: pledge.event.paymentMode, + externalUrl: pledge.event.externalUrl, + externalPlatform: pledge.event.externalPlatform, + sortCode: bankDetails?.sortCode || pledge.organization.bankSortCode, + accountNo: bankDetails?.accountNo || pledge.organization.bankAccountNo, + bankName: bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.bankName, + dueDate: pledge.dueDate?.toISOString() || null, + }) +} diff --git a/pledge-now-pay-later/src/app/p/pay/page.tsx b/pledge-now-pay-later/src/app/p/pay/page.tsx new file mode 100644 index 0000000..cd61a9b --- /dev/null +++ b/pledge-now-pay-later/src/app/p/pay/page.tsx @@ -0,0 +1,287 @@ +"use client" + +import { useState, useEffect, Suspense } from "react" +import { useSearchParams } from "next/navigation" +import { Building2, ExternalLink, CreditCard, Landmark, Shield, Copy, Check, Loader2 } from "lucide-react" + +/** + * /p/pay?ref=PNPL-XXXX + * + * Universal payment completion page. ONE link that adapts to any rail: + * + * - Bank transfer → shows bank details with copy buttons + * - External (LaunchGood, JustGiving) → shows link + redirects + * - Card (Stripe) → creates checkout session + redirects + * - GoCardless → shows "DD is set up, nothing to do" + * + * This is the CTA target in every WhatsApp/SMS/Email message. + * The donor taps ONE link and gets exactly what they need. + */ + +interface PledgeInfo { + reference: string + amountPence: number + status: string + rail: string + eventName: string + orgName: string + paymentMode: string + externalUrl?: string + externalPlatform?: string + sortCode?: string + accountNo?: string + bankName?: string + dueDate?: string + checkoutUrl?: string +} + +function PayPage() { + const params = useSearchParams() + const ref = params.get("ref") + const [pledge, setPledge] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [copied, setCopied] = useState("") + const [creatingCheckout, setCreatingCheckout] = useState(false) + + useEffect(() => { + if (!ref) { setError("No pledge reference provided."); setLoading(false); return } + fetch(`/api/pledges/pay-info?ref=${encodeURIComponent(ref)}`) + .then(r => r.json()) + .then(data => { + if (data.error) setError(data.error) + else setPledge(data) + }) + .catch(() => setError("Something went wrong. Please try again.")) + .finally(() => setLoading(false)) + }, [ref]) + + const copy = (text: string, label: string) => { + navigator.clipboard.writeText(text) + setCopied(label) + setTimeout(() => setCopied(""), 2000) + } + + const handleCardPayment = async () => { + if (!pledge) return + setCreatingCheckout(true) + try { + const res = await fetch("/api/stripe/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pledgeReference: pledge.reference }), + }) + const data = await res.json() + if (data.checkoutUrl) { + window.location.href = data.checkoutUrl + } else { + setError("Could not create payment session. Please try again.") + } + } catch { + setError("Payment service unavailable. Please try again.") + } + setCreatingCheckout(false) + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (error || !pledge) { + return ( +
+
+
+ P +
+

{error || "Pledge not found"}

+

Check the link in your message, or contact the charity.

+
+
+ ) + } + + // Already paid or cancelled + if (pledge.status === "paid") { + return ( +
+
+
+ +
+

Already paid ✓

+

+ Your £{(pledge.amountPence / 100).toFixed(0)} pledge to {pledge.eventName} has been received. Thank you! +

+
+
+ ) + } + if (pledge.status === "cancelled") { + return ( +
+
+

Pledge cancelled

+

This pledge has been withdrawn. No further action needed.

+
+
+ ) + } + + const pounds = (pledge.amountPence / 100).toFixed(0) + + return ( +
+
+ + {/* Header */} +
+
+ P +
+

+ Complete your pledge +

+

+ £{pounds} to {pledge.eventName} +

+ {pledge.dueDate && ( +

+ Due: {new Date(pledge.dueDate).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })} +

+ )} +
+ + {/* ── EXTERNAL PLATFORM ── */} + {pledge.paymentMode === "external" && pledge.externalUrl && ( +
+
+ +

+ {pledge.orgName} collects donations via + {pledge.externalPlatform === "launchgood" ? "LaunchGood" : + pledge.externalPlatform === "justgiving" ? "JustGiving" : + pledge.externalPlatform === "gofundme" ? "GoFundMe" : + pledge.externalPlatform === "enthuse" ? "Enthuse" : + "their donation page"} + +

+ + Donate £{pounds} → + +

+ Use reference {pledge.reference} if asked +

+
+

+ Once you've donated, reply PAID to our WhatsApp message so we can mark it. +

+
+ )} + + {/* ── BANK TRANSFER ── */} + {pledge.rail === "bank" && pledge.paymentMode !== "external" && ( +
+
+
+
+ + Bank Transfer + Zero fees +
+
+ {[ + { label: "Sort Code", value: pledge.sortCode || "N/A" }, + { label: "Account Number", value: pledge.accountNo || "N/A" }, + { label: "Account Name", value: pledge.bankName || "N/A" }, + { label: "Reference", value: pledge.reference, highlight: true }, + ].map(row => ( +
+
+

{row.label}

+

{row.value}

+
+ +
+ ))} +
+

+ ⚠️ Use the exact reference above so {pledge.orgName} can match your payment. +

+
+ )} + + {/* ── CARD / STRIPE ── */} + {pledge.rail === "card" && pledge.paymentMode !== "external" && ( +
+
+ +

+ Secure card payment via Stripe +

+ +

+ Visa, Mastercard, Amex, Apple Pay, Google Pay +

+
+
+ )} + + {/* ── GOCARDLESS / DIRECT DEBIT ── */} + {pledge.rail === "gocardless" && ( +
+
+ +

Direct Debit is set up

+

+ Payment of £{pounds} will be collected automatically within 3-5 working days. +

+

+ Protected by the Direct Debit Guarantee. You can cancel anytime. +

+
+
+ )} + + {/* Footer */} +
+ {pledge.status !== "paid" && pledge.status !== "cancelled" && ( + + Cancel this pledge + + )} +
+ + {pledge.orgName} · Powered by Pledge Now, Pay Later +
+
+
+
+ ) +} + +export default function PayPageWrapper() { + return ( + }> + + + ) +} diff --git a/pledge-now-pay-later/src/lib/templates.ts b/pledge-now-pay-later/src/lib/templates.ts index 2b33733..3ebef79 100644 --- a/pledge-now-pay-later/src/lib/templates.ts +++ b/pledge-now-pay-later/src/lib/templates.ts @@ -1,22 +1,37 @@ /** * Default message templates — seeded when an org first visits Automations. * - * These are the STARTING POINT. Charities customise them. * Templates use {{variable}} syntax, resolved at send time. * - * Available variables: + * UNIVERSAL CTA SYSTEM: + * Every message MUST have a call-to-action. The CTA adapts to how the donor + * chose to pay — bank transfer, external platform, card, or Direct Debit. + * + * Two smart CTA variables: + * + * {{payment_block}} — Full payment instruction block. Used in receipts + * and due date messages. Renders as: + * - Bank: sort code, account, reference with dividers + * - External: link to LaunchGood/JustGiving/etc + * - Card: link to Stripe checkout page + * - GoCardless: "DD is set up, auto-collected" + * + * {{pay_link}} — Single URL to complete payment. Used in reminder CTAs. + * Resolves to /p/pay?ref=XXX which adapts to any rail. + * + * Other variables: * {{name}} — donor first name (or "there") * {{amount}} — pledge amount e.g. "50" * {{event}} — appeal/event name * {{reference}} — payment reference e.g. "PNPL-A2F4-50" - * {{bank_name}} — org bank account name - * {{sort_code}} — sort code - * {{account_no}} — account number * {{org_name}} — charity name * {{days}} — days since pledge * {{due_date}} — formatted due date e.g. "Friday 14 March" * {{cancel_url}} — link to cancel pledge * {{pledge_url}} — link to view pledges + * + * Legacy (still resolved if present, but templates should use {{payment_block}}): + * {{bank_name}}, {{sort_code}}, {{account_no}} */ export interface TemplateDefaults { @@ -31,25 +46,24 @@ export interface TemplateDefaults { // AI MUST include these. If missing, the message is broken. export const REQUIRED_VARIABLES: Record = { - 0: ["name", "amount", "event", "reference", "sort_code", "account_no", "bank_name"], - 4: ["name", "amount", "event", "reference", "due_date"], - 1: ["name", "amount", "reference"], - 2: ["name", "amount", "reference"], - 3: ["name", "amount", "reference"], + 0: ["name", "amount", "event", "reference", "payment_block"], + 4: ["name", "amount", "event", "reference", "due_date", "payment_block"], + 1: ["name", "amount", "reference", "pay_link"], + 2: ["name", "amount", "reference", "pay_link"], + 3: ["name", "amount", "reference", "pay_link"], } // Variables that SHOULD be present (warn if missing, but don't reject) export const RECOMMENDED_VARIABLES: Record = { 0: ["org_name"], - 4: ["sort_code", "account_no", "bank_name"], + 4: [], 1: ["event"], 2: ["event", "days"], - 3: ["event"], + 3: ["event", "cancel_url"], } /** * Validate that a template body contains all required variables for its step. - * Returns { valid, missing, warnings } */ export function validateTemplate(step: number, body: string): { valid: boolean @@ -67,8 +81,7 @@ export function validateTemplate(step: number, body: string): { /** * Patch missing required variables back into a message body. - * Appends them at the end in a natural way. Last resort — better than - * sending a message that's missing the payment reference. + * Last resort — better than sending a message missing the CTA. */ export function patchMissingVariables(step: number, body: string): string { const { missing } = validateTemplate(step, body) @@ -76,45 +89,136 @@ export function patchMissingVariables(step: number, body: string): string { let patched = body.trimEnd() - // Step 0 (receipt) — must have bank details block - if (step === 0) { - const needsBank = missing.some(v => ["sort_code", "account_no", "bank_name"].includes(v)) - if (needsBank) { - patched += `\n\n━━━━━━━━━━━━━━━━━━\n*Transfer to:*\nSort Code: \`{{sort_code}}\`\nAccount: \`{{account_no}}\`\nName: {{bank_name}}\nReference: \`{{reference}}\`\n━━━━━━━━━━━━━━━━━━` - // Remove patched vars from missing - const stillMissing = missing.filter(v => !["sort_code", "account_no", "bank_name", "reference"].includes(v)) - if (stillMissing.includes("name")) patched = patched.replace(/^/, "Hi {{name}},\n\n") - if (stillMissing.includes("amount")) patched += `\n\n💷 Amount: *£{{amount}}*` - if (stillMissing.includes("event")) patched += ` to *{{event}}*` - return patched - } + // Steps 0, 4 — need {{payment_block}} + if ((step === 0 || step === 4) && missing.includes("payment_block")) { + patched += "\n\n{{payment_block}}" } - // Step 4 (due date) — must have due date + // Steps 1-3 — need {{pay_link}} + if ([1, 2, 3].includes(step) && missing.includes("pay_link")) { + patched += "\n\nComplete your pledge: {{pay_link}}" + } + + // Step 4 — needs {{due_date}} if (step === 4 && missing.includes("due_date")) { - patched += `\n\n📅 Due: *{{due_date}}*` + patched += "\n\n📅 Due: *{{due_date}}*" } - // Generic: append any still-missing required vars + // Generic: patch any still-missing basic vars const { missing: stillMissing } = validateTemplate(step, patched) if (stillMissing.length > 0) { - const varLabels: Record = { - name: "{{name}}", - amount: "£{{amount}}", - reference: "Ref: `{{reference}}`", - event: "{{event}}", - due_date: "Due: {{due_date}}", - sort_code: "Sort: `{{sort_code}}`", - account_no: "Acc: `{{account_no}}`", - bank_name: "{{bank_name}}", + const patches: Record = { + name: "Hi {{name}},\n\n", + amount: "💷 *£{{amount}}*", + reference: "\nRef: `{{reference}}`", + event: " to *{{event}}*", + } + for (const v of stillMissing) { + if (v === "name" && !patched.includes("{{name}}")) { + patched = patches.name + patched + } else if (patches[v]) { + patched += patches[v] + } } - patched += "\n\n" + stillMissing.map(v => varLabels[v] || `{{${v}}}`).join("\n") } return patched } +/** + * Build the payment block based on pledge context. + * This is resolved at SEND TIME in the cron, not at template design time. + * + * The same template works for all payment rails because {{payment_block}} + * adapts to the pledge's rail and event's payment mode. + */ +export function buildPaymentBlock(context: { + rail: string + paymentMode: string + reference: string + payLink: string + sortCode?: string + accountNo?: string + bankName?: string + externalUrl?: string + externalPlatform?: string + channel?: string // whatsapp, email, sms +}): string { + const ch = context.channel || "whatsapp" + + // ── External platform (LaunchGood, JustGiving, etc.) ── + if (context.paymentMode === "external" && context.externalUrl) { + const platformNames: Record = { + launchgood: "LaunchGood", + justgiving: "JustGiving", + gofundme: "GoFundMe", + enthuse: "Enthuse", + } + const platform = platformNames[context.externalPlatform || ""] || "the donation page" + + if (ch === "whatsapp") { + return `Complete your donation on *${platform}*:\n🔗 ${context.externalUrl}\n\n_Use reference \`${context.reference}\` if asked_` + } + if (ch === "sms") { + return `Donate at ${context.externalUrl} ref ${context.reference}` + } + return `Complete your donation on ${platform}:\n${context.externalUrl}\n\nUse reference: ${context.reference}` + } + + // ── GoCardless (Direct Debit) ── + if (context.rail === "gocardless") { + if (ch === "whatsapp") { + return `✅ *Direct Debit is set up*\n_Payment will be collected automatically in 3-5 working days_\n_Protected by the Direct Debit Guarantee_` + } + if (ch === "sms") { + return `DD set up - payment collected automatically in 3-5 days.` + } + return `✅ Direct Debit is set up\nPayment will be collected automatically in 3-5 working days.\nProtected by the Direct Debit Guarantee.` + } + + // ── Card (Stripe) ── + if (context.rail === "card") { + if (ch === "whatsapp") { + return `Pay by card:\n🔗 ${context.payLink}\n\n_Secure payment via Stripe · Visa, Mastercard, Apple Pay_` + } + if (ch === "sms") { + return `Pay by card: ${context.payLink}` + } + return `Pay by card:\n${context.payLink}\n\nSecure payment via Stripe — Visa, Mastercard, Amex, Apple Pay, Google Pay.` + } + + // ── Bank transfer (default) ── + if (ch === "whatsapp") { + return `━━━━━━━━━━━━━━━━━━\n*Transfer to:*\nSort Code: \`${context.sortCode || "N/A"}\`\nAccount: \`${context.accountNo || "N/A"}\`\nName: ${context.bankName || "N/A"}\nReference: \`${context.reference}\`\n━━━━━━━━━━━━━━━━━━\n\n⚠️ _Use the exact reference above_` + } + if (ch === "sms") { + return `SC ${context.sortCode || "N/A"} Acc ${context.accountNo || "N/A"} Name ${context.bankName || "N/A"} Ref ${context.reference}` + } + return `Bank: ${context.bankName || "N/A"}\nSort Code: ${context.sortCode || "N/A"}\nAccount: ${context.accountNo || "N/A"}\nReference: ${context.reference}\n\n⚠️ Use the exact reference above so we can match your payment.` +} + +/** + * Compute the pay link for a pledge. + * For external platforms, links directly to their URL. + * For everything else, links to /p/pay which adapts. + */ +export function computePayLink(context: { + paymentMode: string + externalUrl?: string + reference: string + baseUrl?: string +}): string { + if (context.paymentMode === "external" && context.externalUrl) { + return context.externalUrl + } + const base = context.baseUrl || "https://pledge.quikcue.com" + return `${base}/p/pay?ref=${context.reference}` +} + + // ─── WhatsApp templates ────────────────────────────────────── +// Note: {{payment_block}} renders the right CTA for any rail. +// No hardcoded bank details — adapts to external, card, DD, or bank. const WA_RECEIPT = `🤲 *Pledge Confirmed!* @@ -123,15 +227,7 @@ Thank you, {{name}}! 💷 *£{{amount}}* pledged to *{{event}}* 🔖 Ref: \`{{reference}}\` -━━━━━━━━━━━━━━━━━━ -*Transfer to:* -Sort Code: \`{{sort_code}}\` -Account: \`{{account_no}}\` -Name: {{bank_name}} -Reference: \`{{reference}}\` -━━━━━━━━━━━━━━━━━━ - -⚠️ _Use the exact reference above_ +{{payment_block}} Reply *HELP* anytime 💚` @@ -141,14 +237,9 @@ Just a heads up — your *£{{amount}}* pledge to *{{event}}* is due today ({{du Your ref: \`{{reference}}\` -━━━━━━━━━━━━━━━━━━ -Sort Code: \`{{sort_code}}\` -Account: \`{{account_no}}\` -Name: {{bank_name}} -Reference: \`{{reference}}\` -━━━━━━━━━━━━━━━━━━ +{{payment_block}} -Already sent it? Reply *PAID* 🙏 +Already sorted? Reply *PAID* 🙏 Need help? Reply *HELP*` const WA_GENTLE = `Hi {{name}} 👋 @@ -156,9 +247,11 @@ const WA_GENTLE = `Hi {{name}} 👋 Just a quick reminder about your *£{{amount}}* pledge to {{event}}. If you've already paid — thank you! 🙏 -If not, your ref is: \`{{reference}}\` +If not, complete your pledge here: {{pay_link}} -Reply *PAID* if you've sent it, or *HELP* if you need the bank details again.` +Ref: \`{{reference}}\` + +Reply *PAID* if you've sent it, or *HELP* if you need anything.` const WA_IMPACT = `Hi {{name}}, @@ -166,19 +259,23 @@ Your *£{{amount}}* pledge to {{event}} is still pending ({{days}} days). Every pound makes a real difference. 🤲 +Complete your pledge: {{pay_link}} Ref: \`{{reference}}\` -Reply *PAID* once transferred, or *CANCEL* to withdraw.` +Reply *PAID* once done, or *CANCEL* to withdraw.` const WA_FINAL = `Hi {{name}}, This is our final message about your *£{{amount}}* pledge to {{event}}. -We completely understand if circumstances have changed. Reply: +We completely understand if circumstances have changed. -*PAID* — if you've sent it +Complete your pledge: {{pay_link}} + +Or reply: +*PAID* — if you've already sent it *CANCEL* — to withdraw the pledge -*HELP* — to get bank details +*HELP* — if you need assistance Ref: \`{{reference}}\`` @@ -188,14 +285,9 @@ const EMAIL_RECEIPT = `Hi {{name}}, Thank you for pledging £{{amount}} at {{event}}! -To complete your donation, please transfer to: +To complete your donation: -Bank: {{bank_name}} -Sort Code: {{sort_code}} -Account: {{account_no}} -Reference: {{reference}} - -⚠️ Please use the exact reference above so we can match your payment. +{{payment_block}} View your pledge: {{pledge_url}} @@ -207,12 +299,7 @@ const EMAIL_DUE_DATE = `Hi {{name}}, Your £{{amount}} pledge to {{event}} is due today ({{due_date}}). -To complete your donation: - -Bank: {{bank_name}} -Sort Code: {{sort_code}} -Account: {{account_no}} -Reference: {{reference}} +{{payment_block}} View your pledge: {{pledge_url}} @@ -226,7 +313,9 @@ Just a friendly reminder about your £{{amount}} pledge at {{event}}. If you've already sent the payment, thank you! It can take a few days to appear. -If not, here's your reference: {{reference}} +Complete your pledge: {{pay_link}} + +Reference: {{reference}} View details: {{pledge_url}} @@ -238,6 +327,8 @@ Your £{{amount}} pledge from {{event}} is still outstanding. Every donation makes a real impact. Your contribution helps us continue our vital work. +Complete your pledge: {{pay_link}} + Payment reference: {{reference}} View details: {{pledge_url}} @@ -249,9 +340,10 @@ const EMAIL_FINAL = `Hi {{name}}, This is our final reminder about your £{{amount}} pledge from {{event}}. We understand circumstances change. If you'd like to: -✅ Pay now — use reference: {{reference}} +✅ Pay now — {{pay_link}} ❌ Cancel — {{cancel_url}} +Reference: {{reference}} View details: {{pledge_url}} Thank you for considering us. @@ -259,15 +351,15 @@ Thank you for considering us. // ─── SMS templates ─────────────────────────────────────────── -const SMS_RECEIPT = `Thank you, {{name}}! £{{amount}} pledged to {{event}}. Ref: {{reference}}. Transfer to SC {{sort_code}} Acc {{account_no}} Name {{bank_name}}. Use exact ref!` +const SMS_RECEIPT = `Thank you, {{name}}! £{{amount}} pledged to {{event}}. Ref: {{reference}}. {{payment_block}}` -const SMS_DUE_DATE = `Hi {{name}}, your £{{amount}} pledge to {{event}} is due today. Ref: {{reference}}. SC {{sort_code}} Acc {{account_no}}. Reply PAID once sent.` +const SMS_DUE_DATE = `Hi {{name}}, your £{{amount}} pledge to {{event}} is due today ({{due_date}}). Ref: {{reference}}. {{payment_block}}` -const SMS_GENTLE = `Hi {{name}}, reminder: your £{{amount}} pledge to {{event}} ref {{reference}} is pending. Already paid? Ignore this. Need help? Reply HELP.` +const SMS_GENTLE = `Hi {{name}}, reminder: your £{{amount}} pledge to {{event}} ref {{reference}} is pending. Pay: {{pay_link}}` -const SMS_IMPACT = `{{name}}, your £{{amount}} to {{event}} (ref: {{reference}}) is {{days}} days old. Every pound counts. Reply PAID or CANCEL.` +const SMS_IMPACT = `{{name}}, your £{{amount}} to {{event}} (ref: {{reference}}) is {{days}} days old. Complete: {{pay_link}} or reply CANCEL.` -const SMS_FINAL = `Final reminder: £{{amount}} pledge to {{event}}, ref {{reference}}. Reply PAID if sent, or CANCEL to withdraw. Thank you. - {{org_name}}` +const SMS_FINAL = `Final reminder: £{{amount}} pledge to {{event}}, ref {{reference}}. Pay: {{pay_link}} or reply CANCEL. - {{org_name}}` // ─── All defaults ──────────────────────────────────────────── @@ -280,15 +372,15 @@ export const DEFAULT_TEMPLATES: TemplateDefaults[] = [ { step: 4, channel: "whatsapp", name: "Due date reminder", body: WA_DUE_DATE }, { step: 4, channel: "email", name: "Due date reminder", subject: "Your £{{amount}} pledge is due today", body: EMAIL_DUE_DATE }, { step: 4, channel: "sms", name: "Due date reminder", body: SMS_DUE_DATE }, - // Step 1: Day 2 gentle reminder + // Step 1: Gentle reminder { step: 1, channel: "whatsapp", name: "Gentle reminder", body: WA_GENTLE }, { step: 1, channel: "email", name: "Gentle reminder", subject: "Quick reminder: your £{{amount}} pledge", body: EMAIL_GENTLE }, { step: 1, channel: "sms", name: "Gentle reminder", body: SMS_GENTLE }, - // Step 2: Day 7 impact nudge + // Step 2: Impact nudge { step: 2, channel: "whatsapp", name: "Impact nudge", body: WA_IMPACT }, { step: 2, channel: "email", name: "Impact nudge", subject: "Your £{{amount}} pledge is making a difference", body: EMAIL_IMPACT }, { step: 2, channel: "sms", name: "Impact nudge", body: SMS_IMPACT }, - // Step 3: Day 14 final reminder + // Step 3: Final reminder { step: 3, channel: "whatsapp", name: "Final reminder", body: WA_FINAL }, { step: 3, channel: "email", name: "Final reminder", subject: "Final reminder: £{{amount}} pledge", body: EMAIL_FINAL }, { step: 3, channel: "sms", name: "Final reminder", body: SMS_FINAL }, @@ -301,14 +393,17 @@ export const TEMPLATE_VARIABLES = [ { key: "amount", label: "Amount (£)", example: "50" }, { key: "event", label: "Appeal name", example: "Masjid Building Fund" }, { key: "reference", label: "Payment reference", example: "PNPL-A2F4-50" }, - { key: "bank_name", label: "Bank account name", example: "Al Furqan Mosque" }, - { key: "sort_code", label: "Sort code", example: "20-30-80" }, - { key: "account_no", label: "Account number", example: "12345678" }, + { key: "payment_block", label: "Payment details (adapts to method)", example: "━━━━━━━━━━━━━━━━━━\n*Transfer to:*\nSort Code: `20-30-80`\nAccount: `12345678`\nName: Al Furqan Mosque\nReference: `PNPL-A2F4-50`\n━━━━━━━━━━━━━━━━━━\n\n⚠️ _Use the exact reference above_" }, + { key: "pay_link", label: "Payment link (adapts to method)", example: "pledge.quikcue.com/p/pay?ref=PNPL-A2F4-50" }, { key: "org_name", label: "Charity name", example: "Al Furqan Mosque" }, { key: "days", label: "Days since pledge", example: "7" }, { key: "due_date", label: "Due date", example: "Friday 14 March" }, { key: "cancel_url", label: "Cancel link", example: "pledge.quikcue.com/p/cancel?ref=..." }, { key: "pledge_url", label: "Pledge link", example: "pledge.quikcue.com/p/my-pledges" }, + // Legacy — still resolved if present + { key: "bank_name", label: "Bank account name (legacy)", example: "Al Furqan Mosque" }, + { key: "sort_code", label: "Sort code (legacy)", example: "20-30-80" }, + { key: "account_no", label: "Account number (legacy)", example: "12345678" }, ] /** @@ -334,7 +429,7 @@ export function resolvePreview(body: string): string { // Step 4 (due date) sits between receipt and first reminder. export const STEP_META = [ - { step: 0, trigger: "Instantly", label: "Receipt", desc: "Pledge confirmation with bank details", icon: "✉️" }, + { step: 0, trigger: "Instantly", label: "Receipt", desc: "Pledge confirmation with payment details", icon: "✉️" }, { step: 4, trigger: "On the due date", label: "Due date", desc: "The day they said they'd pay", icon: "📅", conditional: true }, { step: 1, trigger: "Day 2", label: "Gentle reminder", desc: "Friendly nudge if not yet paid", icon: "👋" }, { step: 2, trigger: "Day 7", label: "Impact nudge", desc: "Why their donation matters", icon: "💚" }, @@ -347,7 +442,7 @@ export interface StrategyPreset { id: string name: string desc: string - matrix: Record // step → channels + matrix: Record } export const STRATEGY_PRESETS: StrategyPreset[] = [