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:
186
pledge-now-pay-later/src/lib/ai.ts
Normal file
186
pledge-now-pay-later/src/lib/ai.ts
Normal 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)
|
||||
}
|
||||
200
pledge-now-pay-later/src/lib/whatsapp.ts
Normal file
200
pledge-now-pay-later/src/lib/whatsapp.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user