feat: premium UI overhaul, AI suggestions, WAHA WhatsApp integration

PREMIUM UI:
- All animations: fade-up, scale-in, stagger children, confetti celebration
- Glass effects, gradient icons, premium card hover states
- Custom CSS: shimmer, pulse-ring, bounce, counter-roll animations
- Smooth progress bar with gradient

AI-POWERED (GPT-4o-mini nano model):
- Smart amount suggestions based on peer data (/api/ai/suggest)
- Social proof: '42 people pledged · Average £85'
- AI-generated nudge text for conversion
- AI fuzzy matching for bank reconciliation
- AI reminder message generation

WAHA WHATSAPP INTEGRATION:
- Auto-send pledge receipt with bank details via WhatsApp
- 4-step reminder sequence: gentle → nudge → urgent → final
- Chatbot: donors reply PAID, HELP, CANCEL, STATUS
- Volunteer notification on new pledges
- WhatsApp status in dashboard settings
- Webhook endpoint for incoming messages

DONOR FLOW (CRO):
- Amount step: AI suggestions, Gift Aid preview, social proof, haptic feedback
- Payment step: trust signals, fee comparison, benefit badges
- Identity step: email/phone toggle, WhatsApp reminder indicator
- Bank instructions: tap-to-copy each field, WhatsApp delivery confirmation
- Confirmation: confetti, pulse animation, share CTA, WhatsApp receipt

COMPOSE:
- Added WAHA env vars + qc-comms network for WhatsApp access
This commit is contained in:
2026-03-03 04:31:07 +08:00
parent 0236867c88
commit c6e7e4f01e
15 changed files with 1473 additions and 383 deletions

View File

@@ -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<string> {
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<string> {
const name = vars.donorName?.split(" ")[0] || "there"
// Templates with AI enhancement
const templates: Record<string, string> = {
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<string> {
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)
}

View File

@@ -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<string, unknown>): Promise<WahaResponse> {
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<boolean> {
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<string, string> = { bank: "🏦", card: "💳", gocardless: "🏛️" }
const railLabel: Record<string, string> = { 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 }
}
}