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
166 lines
5.2 KiB
TypeScript
166 lines
5.2 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import prisma from "@/lib/prisma"
|
|
import { createPledgeSchema } from "@/lib/validators"
|
|
import { generateReference } from "@/lib/reference"
|
|
import { calculateReminderSchedule } from "@/lib/reminders"
|
|
import { sendPledgeReceipt } from "@/lib/whatsapp"
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json()
|
|
|
|
if (!prisma) {
|
|
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
|
|
}
|
|
|
|
const parsed = createPledgeSchema.safeParse(body)
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: "Invalid data", details: parsed.error.flatten() },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = parsed.data
|
|
|
|
// Get event + org
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: eventId },
|
|
include: { organization: true },
|
|
})
|
|
|
|
if (!event) {
|
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
|
}
|
|
|
|
const org = event.organization
|
|
|
|
// Generate unique reference (retry on collision)
|
|
let reference = ""
|
|
let attempts = 0
|
|
while (attempts < 10) {
|
|
reference = generateReference(org.refPrefix || "PNPL", amountPence)
|
|
const exists = await prisma.pledge.findUnique({ where: { reference } })
|
|
if (!exists) break
|
|
attempts++
|
|
}
|
|
if (attempts >= 10) {
|
|
return NextResponse.json({ error: "Could not generate unique reference" }, { status: 500 })
|
|
}
|
|
|
|
// Create pledge + payment instruction + reminder schedule in transaction
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const pledge = await prisma.$transaction(async (tx: any) => {
|
|
const p = await tx.pledge.create({
|
|
data: {
|
|
reference,
|
|
amountPence,
|
|
currency: "GBP",
|
|
rail,
|
|
status: "new",
|
|
donorName: donorName || null,
|
|
donorEmail: donorEmail || null,
|
|
donorPhone: donorPhone || null,
|
|
giftAid,
|
|
eventId,
|
|
qrSourceId: qrSourceId || null,
|
|
organizationId: org.id,
|
|
},
|
|
})
|
|
|
|
// Create payment instruction for bank transfers
|
|
if (rail === "bank" && org.bankSortCode && org.bankAccountNo) {
|
|
await tx.paymentInstruction.create({
|
|
data: {
|
|
pledgeId: p.id,
|
|
bankReference: reference,
|
|
bankDetails: {
|
|
bankName: org.bankName || "",
|
|
sortCode: org.bankSortCode,
|
|
accountNo: org.bankAccountNo,
|
|
accountName: org.bankAccountName || org.name,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// Create reminder schedule
|
|
const schedule = calculateReminderSchedule(new Date())
|
|
await tx.reminder.createMany({
|
|
data: schedule.map((s) => ({
|
|
pledgeId: p.id,
|
|
step: s.step,
|
|
channel: s.channel,
|
|
scheduledAt: s.scheduledAt,
|
|
status: "pending",
|
|
payload: { templateKey: s.templateKey, subject: s.subject },
|
|
})),
|
|
})
|
|
|
|
// Track analytics
|
|
await tx.analyticsEvent.create({
|
|
data: {
|
|
eventType: "pledge_completed",
|
|
pledgeId: p.id,
|
|
eventId,
|
|
qrSourceId: qrSourceId || null,
|
|
metadata: { amountPence, rail },
|
|
},
|
|
})
|
|
|
|
return p
|
|
})
|
|
|
|
// Build response
|
|
const response: Record<string, unknown> = {
|
|
id: pledge.id,
|
|
reference: pledge.reference,
|
|
}
|
|
|
|
if (rail === "bank" && org.bankSortCode) {
|
|
response.bankDetails = {
|
|
bankName: org.bankName || "",
|
|
sortCode: org.bankSortCode,
|
|
accountNo: org.bankAccountNo || "",
|
|
accountName: org.bankAccountName || org.name,
|
|
}
|
|
}
|
|
|
|
// Async: Send WhatsApp receipt to donor (non-blocking)
|
|
if (donorPhone) {
|
|
sendPledgeReceipt(donorPhone, {
|
|
donorName: donorName || undefined,
|
|
amountPounds: (amountPence / 100).toFixed(0),
|
|
eventName: event.name,
|
|
reference: pledge.reference,
|
|
rail,
|
|
bankDetails: rail === "bank" && org.bankSortCode ? {
|
|
sortCode: org.bankSortCode,
|
|
accountNo: org.bankAccountNo || "",
|
|
accountName: org.bankAccountName || org.name,
|
|
} : undefined,
|
|
orgName: org.name,
|
|
}).catch(err => console.error("[WAHA] Receipt send failed:", err))
|
|
}
|
|
|
|
// Async: Notify volunteer if QR source has volunteer info
|
|
if (qrSourceId) {
|
|
prisma?.qrSource.findUnique({
|
|
where: { id: qrSourceId },
|
|
select: { volunteerName: true, label: true, pledges: { select: { amountPence: true } } },
|
|
}).then(qr => {
|
|
// In future: if volunteer has a phone number stored, send WhatsApp notification
|
|
// For now, this is a no-op unless volunteer phone is added to schema
|
|
if (qr) {
|
|
console.log(`[PLEDGE] ${qr.volunteerName || qr.label}: +1 pledge (£${(amountPence / 100).toFixed(0)})`)
|
|
}
|
|
}).catch(() => {})
|
|
}
|
|
|
|
return NextResponse.json(response, { status: 201 })
|
|
} catch (error) {
|
|
console.error("Pledge creation error:", error)
|
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
|
}
|
|
}
|