Universal CTA: payment_block + pay_link adapt to bank/external/card/DD
THE PROBLEM:
Every message assumed bank transfer. But pledges can be on LaunchGood,
JustGiving, Stripe, GoCardless, or bank transfer. The CTA must adapt.
THE SOLUTION: Two smart template variables that resolve at send time:
{{payment_block}} — Full payment instruction block (receipts, due date):
Bank transfer: sort code, account, reference with dividers
External: 'Complete your donation on LaunchGood: [link]'
Card: 'Pay by card: [Stripe checkout link]'
GoCardless: 'DD is set up, payment collected automatically'
{{pay_link}} — Single link to complete payment (reminders):
External: links directly to LaunchGood/JustGiving URL
Everything else: /p/pay?ref=XXX which adapts per rail
NEW: /p/pay?ref=PNPL-XXXX — Universal payment completion page
One link in every message. Shows the right thing for every rail:
- Bank: sort code, account, reference with copy buttons
- External: link + redirect to platform
- Card: Stripe checkout button
- GoCardless: 'DD set up, nothing to do'
- Already paid: green checkmark
- Cancelled: 'no action needed'
NEW: /api/pledges/pay-info — Public endpoint for pay page data
DEFAULT TEMPLATES REWRITTEN:
Step 0 (receipt): {{payment_block}} instead of hardcoded bank details
Step 4 (due date): {{payment_block}} + {{due_date}}
Steps 1-3 (reminders): {{pay_link}} as primary CTA
Every message now has a clear call-to-action regardless of rail
AI PROMPT UPDATED:
- Knows about universal CTA system
- Enforces {{payment_block}} for receipts, {{pay_link}} for reminders
- Told to NEVER hardcode bank details — use smart variables
- Rewrite action also enforces CTA rules
CRON UPDATED:
- buildPaymentBlock() resolves per pledge context
- computePayLink() resolves per payment mode
- Event query now includes paymentMode, externalUrl, externalPlatform
- Both due date and normal reminders use universal vars
This commit is contained in:
@@ -95,11 +95,11 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
const stepLabels: Record<number, string> = {
|
||||
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<string, string> = {
|
||||
@@ -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.`
|
||||
},
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> | 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<string, string> = {
|
||||
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
|
||||
|
||||
62
pledge-now-pay-later/src/app/api/pledges/pay-info/route.ts
Normal file
62
pledge-now-pay-later/src/app/api/pledges/pay-info/route.ts
Normal file
@@ -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<string, string> | 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,
|
||||
})
|
||||
}
|
||||
287
pledge-now-pay-later/src/app/p/pay/page.tsx
Normal file
287
pledge-now-pay-later/src/app/p/pay/page.tsx
Normal file
@@ -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<PledgeInfo | null>(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 (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !pledge) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center p-6">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-[#111827]">
|
||||
<span className="text-white text-sm font-black">P</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-black text-[#111827]">{error || "Pledge not found"}</h1>
|
||||
<p className="text-sm text-gray-500">Check the link in your message, or contact the charity.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Already paid or cancelled
|
||||
if (pledge.status === "paid") {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center p-6">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-[#16A34A] flex items-center justify-center mx-auto" style={{ borderRadius: "50%" }}>
|
||||
<Check className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-[#111827]">Already paid ✓</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Your £{(pledge.amountPence / 100).toFixed(0)} pledge to {pledge.eventName} has been received. Thank you!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (pledge.status === "cancelled") {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center p-6">
|
||||
<div className="max-w-sm w-full text-center space-y-4">
|
||||
<h1 className="text-2xl font-black text-[#111827]">Pledge cancelled</h1>
|
||||
<p className="text-sm text-gray-500">This pledge has been withdrawn. No further action needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const pounds = (pledge.amountPence / 100).toFixed(0)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 bg-[#111827]">
|
||||
<span className="text-white text-xs font-black">P</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-[#111827] tracking-tight">
|
||||
Complete your pledge
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
<span className="font-bold text-[#111827]">£{pounds}</span> to {pledge.eventName}
|
||||
</p>
|
||||
{pledge.dueDate && (
|
||||
<p className="text-xs text-[#F59E0B] font-semibold">
|
||||
Due: {new Date(pledge.dueDate).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── EXTERNAL PLATFORM ── */}
|
||||
{pledge.paymentMode === "external" && pledge.externalUrl && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-[#1E40AF] p-6 text-center space-y-3">
|
||||
<ExternalLink className="h-8 w-8 text-[#1E40AF] mx-auto" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{pledge.orgName} collects donations via <strong className="text-[#111827]">
|
||||
{pledge.externalPlatform === "launchgood" ? "LaunchGood" :
|
||||
pledge.externalPlatform === "justgiving" ? "JustGiving" :
|
||||
pledge.externalPlatform === "gofundme" ? "GoFundMe" :
|
||||
pledge.externalPlatform === "enthuse" ? "Enthuse" :
|
||||
"their donation page"}
|
||||
</strong>
|
||||
</p>
|
||||
<a href={pledge.externalUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="block w-full bg-[#1E40AF] text-white py-3.5 text-sm font-bold hover:bg-blue-900 transition-colors text-center">
|
||||
Donate £{pounds} →
|
||||
</a>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Use reference <code className="bg-gray-100 px-1.5 py-0.5 font-mono text-[#111827]">{pledge.reference}</code> if asked
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Once you've donated, reply <strong>PAID</strong> to our WhatsApp message so we can mark it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── BANK TRANSFER ── */}
|
||||
{pledge.rail === "bank" && pledge.paymentMode !== "external" && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-gray-200 divide-y divide-gray-200">
|
||||
<div className="p-4 bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-[#111827]" />
|
||||
<span className="text-sm font-bold text-[#111827]">Bank Transfer</span>
|
||||
<span className="ml-auto text-[10px] font-bold text-[#16A34A] bg-green-50 px-2 py-0.5">Zero fees</span>
|
||||
</div>
|
||||
</div>
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={row.label} className={`p-4 flex items-center justify-between ${row.highlight ? "bg-[#FEF3C7]" : ""}`}>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wide">{row.label}</p>
|
||||
<p className={`text-sm font-mono font-bold ${row.highlight ? "text-[#111827]" : "text-[#111827]"}`}>{row.value}</p>
|
||||
</div>
|
||||
<button onClick={() => copy(row.value, row.label)}
|
||||
className="p-2 hover:bg-gray-100 transition-colors" title="Copy">
|
||||
{copied === row.label
|
||||
? <Check className="h-4 w-4 text-[#16A34A]" />
|
||||
: <Copy className="h-4 w-4 text-gray-400" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
⚠️ Use the exact reference above so {pledge.orgName} can match your payment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CARD / STRIPE ── */}
|
||||
{pledge.rail === "card" && pledge.paymentMode !== "external" && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-[#1E40AF] p-6 text-center space-y-3">
|
||||
<CreditCard className="h-8 w-8 text-[#1E40AF] mx-auto" />
|
||||
<p className="text-sm text-gray-600">
|
||||
Secure card payment via Stripe
|
||||
</p>
|
||||
<button onClick={handleCardPayment} disabled={creatingCheckout}
|
||||
className="w-full bg-[#1E40AF] text-white py-3.5 text-sm font-bold hover:bg-blue-900 transition-colors disabled:opacity-50">
|
||||
{creatingCheckout
|
||||
? <span className="flex items-center justify-center gap-2"><Loader2 className="h-4 w-4 animate-spin" /> Creating payment…</span>
|
||||
: `Pay £${pounds} by card →`
|
||||
}
|
||||
</button>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Visa, Mastercard, Amex, Apple Pay, Google Pay
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── GOCARDLESS / DIRECT DEBIT ── */}
|
||||
{pledge.rail === "gocardless" && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-[#16A34A] p-6 text-center space-y-3">
|
||||
<Landmark className="h-8 w-8 text-[#16A34A] mx-auto" />
|
||||
<h2 className="text-lg font-bold text-[#111827]">Direct Debit is set up</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Payment of <strong>£{pounds}</strong> will be collected automatically within 3-5 working days.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Protected by the Direct Debit Guarantee. You can cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center space-y-3 pt-2">
|
||||
{pledge.status !== "paid" && pledge.status !== "cancelled" && (
|
||||
<a href={`/p/cancel?ref=${pledge.reference}`}
|
||||
className="text-xs text-gray-400 hover:text-red-600 transition-colors">
|
||||
Cancel this pledge
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-gray-400">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>{pledge.orgName} · Powered by Pledge Now, Pay Later</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PayPageWrapper() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-white flex items-center justify-center"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>}>
|
||||
<PayPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -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<number, string[]> = {
|
||||
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<number, string[]> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string[]> // step → channels
|
||||
matrix: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const STRATEGY_PRESETS: StrategyPreset[] = [
|
||||
|
||||
Reference in New Issue
Block a user