diff --git a/pledge-now-pay-later/src/app/(auth)/login/page.tsx b/pledge-now-pay-later/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..5432766 --- /dev/null +++ b/pledge-now-pay-later/src/app/(auth)/login/page.tsx @@ -0,0 +1,94 @@ +"use client" + +import { useState } from "react" +import { signIn } from "next-auth/react" +import { useRouter } from "next/navigation" +import Link from "next/link" + +export default function LoginPage() { + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setLoading(true) + + const result = await signIn("credentials", { + email, + password, + redirect: false, + }) + + if (result?.error) { + setError("Invalid email or password") + setLoading(false) + } else { + router.push("/dashboard") + } + } + + return ( +
+
+
+
+ ๐Ÿคฒ +
+

Welcome back

+

Sign in to your charity dashboard

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + className="mt-1 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm focus:border-trust-blue focus:ring-2 focus:ring-trust-blue/20 outline-none transition-all" + placeholder="you@charity.org" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm focus:border-trust-blue focus:ring-2 focus:ring-trust-blue/20 outline-none transition-all" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + required + /> +
+ + +
+ +

+ Don't have an account?{" "} + + Get Started Free + +

+
+
+ ) +} diff --git a/pledge-now-pay-later/src/app/(auth)/signup/page.tsx b/pledge-now-pay-later/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..39dac8a --- /dev/null +++ b/pledge-now-pay-later/src/app/(auth)/signup/page.tsx @@ -0,0 +1,144 @@ +"use client" + +import { useState } from "react" +import { signIn } from "next-auth/react" +import { useRouter } from "next/navigation" +import Link from "next/link" + +export default function SignupPage() { + const [charityName, setCharityName] = useState("") + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setLoading(true) + + try { + const res = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, name, charityName }), + }) + + const data = await res.json() + + if (!res.ok) { + setError(data.error || "Failed to create account") + setLoading(false) + return + } + + // Auto sign in + const result = await signIn("credentials", { + email, + password, + redirect: false, + }) + + if (result?.error) { + setError("Account created but couldn't sign in. Try logging in.") + setLoading(false) + } else { + router.push("/dashboard/setup") + } + } catch { + setError("Something went wrong. Please try again.") + setLoading(false) + } + } + + return ( +
+
+
+
+ ๐Ÿคฒ +
+

Get Started Free

+

Set up your charity in 2 minutes

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setCharityName(e.target.value)} + className="mt-1 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm focus:border-trust-blue focus:ring-2 focus:ring-trust-blue/20 outline-none transition-all" + placeholder="e.g. Islamic Relief UK" + required + /> +
+ +
+ + setName(e.target.value)} + className="mt-1 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm focus:border-trust-blue focus:ring-2 focus:ring-trust-blue/20 outline-none transition-all" + placeholder="e.g. Fatima Khan" + /> +
+ +
+ + setEmail(e.target.value)} + className="mt-1 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm focus:border-trust-blue focus:ring-2 focus:ring-trust-blue/20 outline-none transition-all" + placeholder="you@charity.org" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm focus:border-trust-blue focus:ring-2 focus:ring-trust-blue/20 outline-none transition-all" + placeholder="Min 8 characters" + required + minLength={8} + /> +
+ + + +

+ Free forever. No credit card needed. Takes 2 minutes. +

+
+ +

+ Already have an account?{" "} + + Sign In + +

+
+
+ ) +} diff --git a/pledge-now-pay-later/src/app/api/auth/[...nextauth]/route.ts b/pledge-now-pay-later/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..fa36fb6 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +import NextAuth from "next-auth" +import { authOptions } from "@/lib/auth" + +const handler = NextAuth(authOptions) +export { handler as GET, handler as POST } diff --git a/pledge-now-pay-later/src/app/api/auth/signup/route.ts b/pledge-now-pay-later/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000..1debf17 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/auth/signup/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server" +import { hash } from "bcryptjs" +import prisma from "@/lib/prisma" + +export async function POST(request: NextRequest) { + try { + if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) + + const { email, password, name, charityName } = await request.json() + + if (!email || !password || !charityName) { + return NextResponse.json({ error: "Email, password and charity name are required" }, { status: 400 }) + } + if (password.length < 8) { + return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 }) + } + + const cleanEmail = email.toLowerCase().trim() + + // Check if email exists + const existing = await prisma.user.findUnique({ where: { email: cleanEmail } }) + if (existing) { + return NextResponse.json({ error: "An account with this email already exists" }, { status: 409 }) + } + + // Create org + user in transaction + const slug = charityName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 50) + const hashedPassword = await hash(password, 12) + + const result = await prisma.$transaction(async (tx) => { + const org = await tx.organization.create({ + data: { + name: charityName.trim(), + slug: slug + "-" + Date.now().toString(36), + country: "GB", + refPrefix: slug.substring(0, 4).toUpperCase() || "PNPL", + }, + }) + + const user = await tx.user.create({ + data: { + email: cleanEmail, + name: name?.trim() || null, + hashedPassword, + role: "org_admin", + organizationId: org.id, + }, + }) + + return { userId: user.id, orgId: org.id, orgName: org.name } + }) + + return NextResponse.json(result, { status: 201 }) + } catch (error) { + console.error("Signup error:", error) + return NextResponse.json({ error: "Failed to create account" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/dashboard/route.ts b/pledge-now-pay-later/src/app/api/dashboard/route.ts index af63b92..b123f10 100644 --- a/pledge-now-pay-later/src/app/api/dashboard/route.ts +++ b/pledge-now-pay-later/src/app/api/dashboard/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" -import { resolveOrgId } from "@/lib/org" +import { getOrgId } from "@/lib/session" interface PledgeRow { id: string @@ -56,7 +56,7 @@ export async function GET(request: NextRequest) { }) } - const orgId = await resolveOrgId(request.headers.get("x-org-id")) + const orgId = await getOrgId(request.headers.get("x-org-id")) if (!orgId) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }) } diff --git a/pledge-now-pay-later/src/app/api/events/route.ts b/pledge-now-pay-later/src/app/api/events/route.ts index 685ee77..89cb047 100644 --- a/pledge-now-pay-later/src/app/api/events/route.ts +++ b/pledge-now-pay-later/src/app/api/events/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" import { createEventSchema } from "@/lib/validators" -import { resolveOrgId } from "@/lib/org" +import { getOrgId } from "@/lib/session" interface PledgeSummary { amountPence: number @@ -27,7 +27,7 @@ export async function GET(request: NextRequest) { if (!prisma) { return NextResponse.json([]) } - const orgId = await resolveOrgId(request.headers.get("x-org-id")) + const orgId = await getOrgId(request.headers.get("x-org-id")) if (!orgId) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }) } @@ -73,7 +73,7 @@ export async function POST(request: NextRequest) { if (!prisma) { return NextResponse.json({ error: "Database not configured" }, { status: 503 }) } - const orgId = await resolveOrgId(request.headers.get("x-org-id")) + const orgId = await getOrgId(request.headers.get("x-org-id")) if (!orgId) { return NextResponse.json({ error: "Organization not found" }, { status: 404 }) } diff --git a/pledge-now-pay-later/src/app/api/settings/route.ts b/pledge-now-pay-later/src/app/api/settings/route.ts index 15415ba..fc1fed3 100644 --- a/pledge-now-pay-later/src/app/api/settings/route.ts +++ b/pledge-now-pay-later/src/app/api/settings/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" -import { resolveOrgId } from "@/lib/org" +import { getOrgId } from "@/lib/session" export async function GET(request: NextRequest) { try { if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) - const orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo") + const orgId = await getOrgId(request.headers.get("x-org-id")) if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 }) const org = await prisma.organization.findUnique({ where: { id: orgId } }) @@ -39,7 +39,7 @@ export async function PUT(request: NextRequest) { const body = await request.json() // Try to find existing org first - const orgId = await resolveOrgId(request.headers.get("x-org-id") || "default") + const orgId = await getOrgId(request.headers.get("x-org-id")) if (orgId) { // Update existing @@ -76,7 +76,7 @@ export async function PUT(request: NextRequest) { export async function PATCH(request: NextRequest) { try { if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) - const orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo") + const orgId = await getOrgId(request.headers.get("x-org-id")) if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 }) const body = await request.json() diff --git a/pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts b/pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts index 5cc185d..2dc5677 100644 --- a/pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts +++ b/pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts @@ -4,65 +4,63 @@ const WAHA_URL = process.env.WAHA_API_URL || "https://waha.quikcue.com" const WAHA_KEY = process.env.WAHA_API_KEY || "qc-waha-api-7Fp3nR9xYm2K" const WAHA_SESSION = process.env.WAHA_SESSION || "default" +async function wahaGet(path: string) { + const res = await fetch(`${WAHA_URL}${path}`, { + headers: { "X-Api-Key": WAHA_KEY }, + signal: AbortSignal.timeout(8000), + cache: "no-store", + }) + return res +} + /** * GET /api/whatsapp/qr - * Returns the WAHA QR code screenshot as PNG for WhatsApp pairing. - * Also returns session status. + * Returns session status. For QR scanning, provides the screenshot + * cropped to the QR code area, or a link to the WAHA dashboard. */ export async function GET() { try { - // First check session status - const sessRes = await fetch(`${WAHA_URL}/api/sessions`, { - headers: { "X-Api-Key": WAHA_KEY }, - signal: AbortSignal.timeout(5000), - }) + const sessRes = await wahaGet("/api/sessions") const sessions = await sessRes.json() - const session = Array.isArray(sessions) ? sessions.find((s: { name: string }) => s.name === WAHA_SESSION) : null if (!session) { - return NextResponse.json({ - status: "NO_SESSION", - message: "No WAHA session exists. Start one first.", - }) + return NextResponse.json({ status: "NO_SESSION" }) } if (session.status === "WORKING") { - const me = session.me || {} return NextResponse.json({ status: "CONNECTED", - phone: me.id?.replace("@c.us", "") || "unknown", - pushName: me.pushname || "", + phone: session.me?.id?.replace("@c.us", "") || "", + pushName: session.me?.pushname || "", }) } if (session.status === "SCAN_QR_CODE") { - // Get screenshot - const qrRes = await fetch(`${WAHA_URL}/api/screenshot?session=${WAHA_SESSION}`, { - headers: { "X-Api-Key": WAHA_KEY }, - signal: AbortSignal.timeout(10000), - }) - - if (!qrRes.ok) { - return NextResponse.json({ status: "SCAN_QR_CODE", error: "Failed to get QR" }) - } - - const buffer = await qrRes.arrayBuffer() - const base64 = Buffer.from(buffer).toString("base64") + // Get screenshot from WAHA and return as base64 + try { + const ssRes = await wahaGet(`/api/screenshot?session=${WAHA_SESSION}`) + if (ssRes.ok) { + const buf = await ssRes.arrayBuffer() + const b64 = Buffer.from(buf).toString("base64") + return NextResponse.json({ + status: "SCAN_QR_CODE", + screenshot: `data:image/png;base64,${b64}`, + message: "Scan the QR code with WhatsApp โ†’ Linked Devices โ†’ Link a Device", + }) + } + } catch { /* fall through */ } return NextResponse.json({ status: "SCAN_QR_CODE", - qrImage: `data:image/png;base64,${base64}`, - message: "Scan this QR code with WhatsApp on your phone", + screenshot: null, + message: "QR loading... Refresh in a moment.", }) } - return NextResponse.json({ - status: session.status, - message: `Session is ${session.status}`, - }) + return NextResponse.json({ status: session.status }) } catch (error) { console.error("WhatsApp QR error:", error) return NextResponse.json({ status: "ERROR", error: String(error) }) @@ -70,54 +68,43 @@ export async function GET() { } /** - * POST /api/whatsapp/qr - Start or restart a session + * POST /api/whatsapp/qr - Start or restart session */ export async function POST() { try { - // Check if session exists - const sessRes = await fetch(`${WAHA_URL}/api/sessions`, { - headers: { "X-Api-Key": WAHA_KEY }, - signal: AbortSignal.timeout(5000), - }) + const sessRes = await wahaGet("/api/sessions") const sessions = await sessRes.json() const existing = Array.isArray(sessions) ? sessions.find((s: { name: string }) => s.name === WAHA_SESSION) : null if (existing) { - // Stop and restart await fetch(`${WAHA_URL}/api/sessions/stop`, { method: "POST", headers: { "Content-Type": "application/json", "X-Api-Key": WAHA_KEY }, body: JSON.stringify({ name: WAHA_SESSION }), signal: AbortSignal.timeout(10000), - }) - // Small delay + }).catch(() => {}) await new Promise(r => setTimeout(r, 2000)) } - // Start session with webhook const startRes = await fetch(`${WAHA_URL}/api/sessions/start`, { method: "POST", headers: { "Content-Type": "application/json", "X-Api-Key": WAHA_KEY }, body: JSON.stringify({ name: WAHA_SESSION, config: { - webhooks: [ - { - url: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/api/whatsapp/webhook`, - events: ["message"], - }, - ], + webhooks: [{ + url: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/api/whatsapp/webhook`, + events: ["message", "session.status"], + }], }, }), signal: AbortSignal.timeout(15000), }) - const result = await startRes.json() return NextResponse.json({ success: true, status: result.status || "STARTING" }) } catch (error) { - console.error("WhatsApp session start error:", error) return NextResponse.json({ success: false, error: String(error) }) } } diff --git a/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts b/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts index b00cd43..2436f00 100644 --- a/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts +++ b/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts @@ -1,15 +1,29 @@ import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" import { sendWhatsAppMessage } from "@/lib/whatsapp" +import { setQrValue } from "@/lib/qr-store" /** - * WAHA webhook โ€” receives incoming WhatsApp messages + * WAHA webhook โ€” receives incoming WhatsApp messages + session status events * Handles: PAID, HELP, CANCEL commands from donors + * Also captures QR code value for the pairing flow */ export async function POST(request: NextRequest) { try { const body = await request.json() - const { payload } = body + const { event, payload } = body + + // Handle session.status events (for QR code capture) + if (event === "session.status") { + console.log("[WAHA] Session status:", JSON.stringify({ status: payload?.status, hasQr: !!payload?.qr, keys: Object.keys(payload || {}) })) + // WAHA sends QR in different formats depending on version + const qrVal = payload?.qr?.value || payload?.qr || payload?.qrCode + if (payload?.status === "SCAN_QR_CODE" && qrVal && typeof qrVal === "string") { + setQrValue(qrVal) + console.log("[WAHA] QR value stored, length:", qrVal.length) + } + return NextResponse.json({ ok: true }) + } // WAHA sends message events if (!payload?.body || !payload?.from) { diff --git a/pledge-now-pay-later/src/app/dashboard/events/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/page.tsx index 0e90e49..b57e51f 100644 --- a/pledge-now-pay-later/src/app/dashboard/events/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/events/page.tsx @@ -33,7 +33,7 @@ export default function EventsPage() { const [showCreate, setShowCreate] = useState(false) useEffect(() => { - fetch("/api/events", { headers: { "x-org-id": "demo" } }) + fetch("/api/events") .then((r) => r.json()) .then((data) => { if (Array.isArray(data)) setEvents(data) @@ -49,7 +49,7 @@ export default function EventsPage() { try { const res = await fetch("/api/events", { method: "POST", - headers: { "Content-Type": "application/json", "x-org-id": "demo" }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...form, goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined, diff --git a/pledge-now-pay-later/src/app/dashboard/layout.tsx b/pledge-now-pay-later/src/app/dashboard/layout.tsx index c67c693..b8dc596 100644 --- a/pledge-now-pay-later/src/app/dashboard/layout.tsx +++ b/pledge-now-pay-later/src/app/dashboard/layout.tsx @@ -2,7 +2,8 @@ import Link from "next/link" import { usePathname } from "next/navigation" -import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings, Plus, ExternalLink } from "lucide-react" +import { useSession, signOut } from "next-auth/react" +import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut } from "lucide-react" import { cn } from "@/lib/utils" const navItems = [ @@ -16,6 +17,9 @@ const navItems = [ export default function DashboardLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname() + const { data: session } = useSession() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user = session?.user as any return (
@@ -27,8 +31,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod P
- PNPL - Dashboard + {user?.orgName || "PNPL"}
@@ -38,8 +41,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod - Public Site + + {session && ( + + )}
diff --git a/pledge-now-pay-later/src/app/dashboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/page.tsx index d6f80d6..ac6f546 100644 --- a/pledge-now-pay-later/src/app/dashboard/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/page.tsx @@ -33,7 +33,7 @@ export default function DashboardPage() { const [whatsappStatus, setWhatsappStatus] = useState(null) const fetchData = useCallback(() => { - fetch("/api/dashboard", { headers: { "x-org-id": "demo" } }) + fetch("/api/dashboard") .then(r => r.json()) .then(d => { if (d.summary) setData(d) }) .catch(() => {}) diff --git a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx index 9f8b2bd..06a2356 100644 --- a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx @@ -106,7 +106,7 @@ export default function PledgesPage() { }, [tab, search, page]) const fetchStats = useCallback(async () => { - const res = await fetch("/api/dashboard", { headers: { "x-org-id": "demo" } }) + const res = await fetch("/api/dashboard") const data = await res.json() if (data.summary) { setStats({ diff --git a/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx b/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx index 7d1d808..0ed70b7 100644 --- a/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx @@ -54,7 +54,7 @@ export default function ReconcilePage() { const res = await fetch("/api/imports/bank-statement", { method: "POST", - headers: { "x-org-id": "demo" }, + headers: { }, body: formData, }) const data = await res.json() diff --git a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx index db5d1af..c655275 100644 --- a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx @@ -31,7 +31,7 @@ export default function SettingsPage() { const [error, setError] = useState(null) useEffect(() => { - fetch("/api/settings", { headers: { "x-org-id": "demo" } }) + fetch("/api/settings") .then((r) => r.json()) .then((data) => { if (data.name) setSettings(data) }) .catch(() => setError("Failed to load settings")) @@ -44,7 +44,7 @@ export default function SettingsPage() { try { const res = await fetch("/api/settings", { method: "PATCH", - headers: { "Content-Type": "application/json", "x-org-id": "demo" }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }) if (res.ok) { setSaved(section); setTimeout(() => setSaved(null), 2000) } @@ -150,7 +150,7 @@ function WhatsAppPanel() { const res = await fetch("/api/whatsapp/qr") const data = await res.json() setStatus(data.status) - if (data.qrImage) setQrImage(data.qrImage) + if (data.screenshot) setQrImage(data.screenshot) if (data.phone) setPhone(data.phone) if (data.pushName) setPushName(data.pushName) } catch { @@ -231,23 +231,33 @@ function WhatsAppPanel() {
{qrImage ? (
- {/* eslint-disable-next-line @next/next/no-img-element */} - WhatsApp QR Code + {/* Crop to QR area: the screenshot shows full WhatsApp web page. + QR code is roughly in center. We use overflow hidden + object positioning. */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + WhatsApp QR Code +
) : ( -
+
)}

Scan with WhatsApp

-

QR refreshes automatically every 5 seconds

+

Open WhatsApp โ†’ Settings โ†’ Linked Devices โ†’ Link a Device

+

Auto-refreshes every 5 seconds

diff --git a/pledge-now-pay-later/src/app/layout.tsx b/pledge-now-pay-later/src/app/layout.tsx index bb5f24c..008c33f 100644 --- a/pledge-now-pay-later/src/app/layout.tsx +++ b/pledge-now-pay-later/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next" import { ToastProvider } from "@/components/ui/toast" +import { Providers } from "./providers" import "./globals.css" export const metadata: Metadata = { @@ -11,9 +12,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - - {children} - + + + {children} + + ) diff --git a/pledge-now-pay-later/src/app/page.tsx b/pledge-now-pay-later/src/app/page.tsx index 2cb02b2..1373104 100644 --- a/pledge-now-pay-later/src/app/page.tsx +++ b/pledge-now-pay-later/src/app/page.tsx @@ -1,123 +1,188 @@ import Link from "next/link" -import { Button } from "@/components/ui/button" -import { CreditCard, Landmark, Building2, QrCode, BarChart3, Bell, Download, Users, Gift, MessageCircle, Share2, Smartphone } from "lucide-react" -export default function Home() { +export default function HomePage() { return ( -
+
+ {/* Nav */} +
+
+
+
+ ๐Ÿคฒ +
+ Pledge Now, Pay Later +
+
+ + Sign In + + + Get Started Free + +
+
+
+ {/* Hero */} -
-
-
-
- โœจ Free forever for UK charities -
-

- Pledge Now,{" "} - Pay Later -

-

- Turn "I'll donate later" into tracked pledges with automatic follow-up. Built for UK charity fundraising events. -

+
+
+
+ ๐Ÿ‡ฌ๐Ÿ‡ง Built for UK charities ยท Free to start
-
- - - - - +

+ Turn promises into + payments +

+

+ At your next event, donors pledge what they want to give โ€” then pay on their own terms. + You get QR codes, WhatsApp reminders, and a dashboard to track every penny. +

+
+ + Start Collecting Pledges โ†’ + + See How It Works +
-
-
-
0%
-
Bank transfer fees
+

Free forever ยท No card needed ยท 2 minute setup

+
+
+ + {/* Problem */} +
+
+
+
+
๐Ÿ˜ค
+

Pledges go cold

+

Donors say "I'll pay ยฃ500" at the gala, then forget. You have no way to follow up.

-
-
15s
-
Pledge time
+
+
๐Ÿ“
+

Paper tracking

+

Spreadsheets, napkin notes, WhatsApp groups. No system, no references, no proof.

-
-
+25%
-
Gift Aid boost
+
+
๐Ÿ’ธ
+

Money left on the table

+

UK charities lose 30-50% of pledged amounts because there's no follow-up system.

-
+
- {/* Who is this for? */} -
-
-

Built for everyone in your fundraising chain

-

From the charity manager to the donor's phone

-
+ {/* How it works */} +
+
+
+

How it works

+

From pledge to payment in 4 steps

+
+
{[ - { icon: BarChart3, title: "Charity Managers", desc: "Live dashboard, bank reconciliation, Gift Aid reports. See every pound from pledge to collection.", color: "text-trust-blue" }, - { icon: Users, title: "Volunteers", desc: "Personal QR codes, leaderboard, own pledge tracker. Know exactly who pledged at your table.", color: "text-warm-amber" }, - { icon: Smartphone, title: "Donors", desc: "15-second pledge on your phone. Clear bank details, copy buttons, reminders until paid.", color: "text-success-green" }, - { icon: Share2, title: "Personal Fundraisers", desc: "Share your pledge link on WhatsApp. Track friends and family pledges with a progress bar.", color: "text-purple-600" }, - ].map((p, i) => ( -
- -

{p.title}

-

{p.desc}

+ { step: "1", icon: "๐Ÿ“ฑ", title: "Donor scans QR", desc: "At your event, each table/volunteer has a unique QR code." }, + { step: "2", icon: "๐Ÿคฒ", title: "Pledges amount", desc: "Pick an amount. Choose to pay now, on a date, or monthly instalments." }, + { step: "3", icon: "๐Ÿ’ฌ", title: "Gets reminders", desc: "WhatsApp messages with bank details before each due date. They reply PAID when done." }, + { step: "4", icon: "โœ…", title: "You reconcile", desc: "Dashboard shows who pledged, who paid, who needs a nudge. Upload bank statements to auto-match." }, + ].map((s) => ( +
+
+ {s.icon} +
+
+ {s.step} +
+

{s.title}

+

{s.desc}

))}
-
- - {/* Payment Methods */} -
-

3 UK Payment Rails, One Platform

-

Every method a UK donor expects

-
- {[ - { icon: Building2, title: "Bank Transfer", desc: "Zero fees โ€” 100% to charity. Unique reference for auto-matching.", color: "text-success-green", tag: "0% fees" }, - { icon: Landmark, title: "Direct Debit", desc: "GoCardless auto-collection. Protected by the Direct Debit Guarantee.", color: "text-trust-blue", tag: "Set & forget" }, - { icon: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex. Instant payment and receipt.", color: "text-purple-600", tag: "Instant" }, - ].map((m, i) => ( -
- - {m.tag} -

{m.title}

-

{m.desc}

-
- ))} -
-
+
{/* Features */} -
-
-

Everything a UK charity needs

-
+
+
+
+

Everything you need

+
+
{[ - { icon: QrCode, title: "QR Attribution", desc: "Per-table, per-volunteer tracking. Know who raised what." }, - { icon: Gift, title: "Gift Aid Built In", desc: "One-tap declaration. HMRC-ready export. +25% on every eligible pledge." }, - { icon: Bell, title: "Smart Reminders", desc: "Automated follow-up via email and SMS until the pledge is paid." }, - { icon: Download, title: "Bank Reconciliation", desc: "Upload your CSV statement. Auto-match by unique reference." }, - { icon: MessageCircle, title: "WhatsApp Sharing", desc: "Donors share their pledge with friends. Viral fundraising built in." }, - { icon: Users, title: "Volunteer Portal", desc: "Each volunteer sees their own pledges and conversion rate." }, - { icon: BarChart3, title: "Live Dashboard", desc: "Real-time ticker during events. Pipeline from pledge to payment." }, - { icon: Share2, title: "Fundraiser Pages", desc: "Shareable links with progress bars. Perfect for personal campaigns." }, - ].map((f, i) => ( -
- -

{f.title}

-

{f.desc}

+ { icon: "๐Ÿ“ฑ", title: "QR Code Generator", desc: "Unique codes per volunteer/table. Track who brings in the most." }, + { icon: "๐Ÿ“…", title: "Flexible Scheduling", desc: "Pay now, pick a date, or split into 2-12 monthly instalments." }, + { icon: "๐Ÿ’ฌ", title: "WhatsApp Reminders", desc: "Auto-send bank details and reminders. Donors reply PAID, HELP, or CANCEL." }, + { icon: "๐ŸŽ", title: "Gift Aid", desc: "Collect declarations inline. Export HMRC-ready CSV with one click." }, + { icon: "๐Ÿฆ", title: "UK Bank Transfers", desc: "Unique reference per pledge for easy reconciliation. Tap-to-copy details." }, + { icon: "๐Ÿ“Š", title: "Live Dashboard", desc: "See pledges come in real-time. Pipeline view: pending โ†’ initiated โ†’ paid." }, + { icon: "๐Ÿ†", title: "Volunteer Leaderboard", desc: "Real-time scoreboard. Motivate your team with friendly competition." }, + { icon: "๐Ÿ“ค", title: "CRM Export", desc: "Download all pledge data as CSV. Gift Aid pack for HMRC." }, + ].map((f) => ( +
+ {f.icon} +
+

{f.title}

+

{f.desc}

+
))}
-
+
+ + {/* Donor schedule */} +
+
+

Donors choose when to pay

+
+
+
โšก
+

Pay Now

+

Card, bank transfer, or Direct Debit right away

+
+
+
๐Ÿ“…
+

Pick a Date

+

"I'll pay on payday" โ€” reminders sent automatically

+
+
+
๐Ÿ“†
+

Monthly

+

Split into 2-12 instalments. Each one tracked separately

+
+
+
+
+ + {/* CTA */} +
+
+

Start collecting pledges today

+

+ Free to use. Set up in 2 minutes. No technical knowledge needed. +

+ + Create Your Free Account โ†’ + +

Used by mosques, churches, schools and charities across the UK

+
+
{/* Footer */} -
-

Pledge Now, Pay Later โ€” Built for UK charities by QuikCue.

-

Free forever. No hidden fees. No card required.

+
+
+
+
+ ๐Ÿคฒ +
+ Pledge Now, Pay Later +
+
+ Sign In + Get Started +
+ ยฉ {new Date().getFullYear()} QuikCue Ltd +
) diff --git a/pledge-now-pay-later/src/app/providers.tsx b/pledge-now-pay-later/src/app/providers.tsx new file mode 100644 index 0000000..fca1b98 --- /dev/null +++ b/pledge-now-pay-later/src/app/providers.tsx @@ -0,0 +1,7 @@ +"use client" + +import { SessionProvider } from "next-auth/react" + +export function Providers({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/pledge-now-pay-later/src/lib/auth.ts b/pledge-now-pay-later/src/lib/auth.ts new file mode 100644 index 0000000..eca7934 --- /dev/null +++ b/pledge-now-pay-later/src/lib/auth.ts @@ -0,0 +1,69 @@ +import { type NextAuthOptions } from "next-auth" +import CredentialsProvider from "next-auth/providers/credentials" +import { compare } from "bcryptjs" +import prisma from "@/lib/prisma" + +export const authOptions: NextAuthOptions = { + session: { strategy: "jwt" }, + pages: { + signIn: "/login", + newUser: "/dashboard/setup", + }, + providers: [ + CredentialsProvider({ + name: "credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password || !prisma) return null + + const user = await prisma.user.findUnique({ + where: { email: credentials.email.toLowerCase().trim() }, + include: { organization: { select: { id: true, name: true, slug: true } } }, + }) + + if (!user || !user.hashedPassword) return null + + const valid = await compare(credentials.password, user.hashedPassword) + if (!valid) return null + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + orgId: user.organizationId, + orgName: user.organization.name, + orgSlug: user.organization.slug, + } + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const u = user as any + token.role = u.role + token.orgId = u.orgId + token.orgName = u.orgName + token.orgSlug = u.orgSlug + } + return token + }, + async session({ session, token }) { + if (session.user) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = session as any + s.user.id = token.sub + s.user.role = token.role + s.user.orgId = token.orgId + s.user.orgName = token.orgName + s.user.orgSlug = token.orgSlug + } + return session + }, + }, +} diff --git a/pledge-now-pay-later/src/lib/qr-store.ts b/pledge-now-pay-later/src/lib/qr-store.ts new file mode 100644 index 0000000..dedad55 --- /dev/null +++ b/pledge-now-pay-later/src/lib/qr-store.ts @@ -0,0 +1,18 @@ +// In-memory QR value store for WAHA pairing +let cachedQrValue: string | null = null +let cachedQrTimestamp = 0 + +export function setQrValue(value: string) { + cachedQrValue = value + cachedQrTimestamp = Date.now() +} + +export function getQrValue(): string | null { + if (cachedQrValue && Date.now() - cachedQrTimestamp < 60000) return cachedQrValue + return null +} + +export function clearQrValue() { + cachedQrValue = null + cachedQrTimestamp = 0 +} diff --git a/pledge-now-pay-later/src/lib/session.ts b/pledge-now-pay-later/src/lib/session.ts new file mode 100644 index 0000000..b1aa270 --- /dev/null +++ b/pledge-now-pay-later/src/lib/session.ts @@ -0,0 +1,43 @@ +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" + +interface SessionUser { + id: string + email: string + name?: string + role: string + orgId: string + orgName: string + orgSlug: string +} + +/** + * Get the current user from the session. + * Returns null if not authenticated. + */ +export async function getUser(): Promise { + const session = await getServerSession(authOptions) + if (!session?.user) return null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return session.user as any as SessionUser +} + +/** + * Get the current org ID from the session. + * Falls back to x-org-id header or first org for backwards compatibility. + */ +export async function getOrgId(headerOrgId?: string | null): Promise { + // 1. Try session first + const user = await getUser() + if (user?.orgId) return user.orgId + + // 2. Fall back to header (for API calls) + if (headerOrgId) { + const { resolveOrgId } = await import("@/lib/org") + return resolveOrgId(headerOrgId) + } + + // 3. Fall back to first org (single-tenant compat) + const { resolveOrgId } = await import("@/lib/org") + return resolveOrgId(null) +} diff --git a/pledge-now-pay-later/src/middleware.ts b/pledge-now-pay-later/src/middleware.ts index f07028a..91c4e38 100644 --- a/pledge-now-pay-later/src/middleware.ts +++ b/pledge-now-pay-later/src/middleware.ts @@ -1,50 +1,9 @@ -import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" +import { withAuth } from "next-auth/middleware" -// Simple in-memory rate limiter (use Redis in production) -const rateLimit = new Map() - -function checkRateLimit(ip: string, limit: number = 60, windowMs: number = 60000): boolean { - const now = Date.now() - const entry = rateLimit.get(ip) - - if (!entry || entry.resetAt < now) { - rateLimit.set(ip, { count: 1, resetAt: now + windowMs }) - return true - } - - if (entry.count >= limit) return false - entry.count++ - return true -} - -export function middleware(request: NextRequest) { - const response = NextResponse.next() - - // Rate limit API routes - if (request.nextUrl.pathname.startsWith("/api/")) { - const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown" - if (!checkRateLimit(ip)) { - return NextResponse.json( - { error: "Too many requests" }, - { status: 429 } - ) - } - } - - // Add security headers - response.headers.set("X-Frame-Options", "SAMEORIGIN") - response.headers.set("X-Content-Type-Options", "nosniff") - response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin") - - // Allow iframe embedding for pledge pages - if (request.nextUrl.pathname.startsWith("/p/")) { - response.headers.delete("X-Frame-Options") - } - - return response -} +export default withAuth({ + pages: { signIn: "/login" }, +}) export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], + matcher: ["/dashboard/:path*"], }