production auth: signup, login, protected dashboard, landing page, WAHA QR fix

AUTH:
- NextAuth with credentials provider (bcrypt password hashing)
- /api/auth/signup: creates org + user in transaction
- /login, /signup pages with clean minimal UI
- Middleware protects all /dashboard/* routes → redirects to /login
- Session-based org resolution (no more hardcoded 'demo' headers)
- SessionProvider wraps entire app
- Dashboard header shows org name + sign out button

LANDING PAGE:
- Full marketing page at / with hero, problem, how-it-works, features, CTA
- 'Get Started Free' → /signup → auto-login → /dashboard/setup
- Clean responsive design, no auth required for public pages

WAHA QR FIX:
- WAHA CORE doesn't expose QR value via API or webhook
- Now uses /api/screenshot (full browser capture) with CSS crop to QR area
- Settings panel shows cropped screenshot with overflow:hidden
- Auto-polls every 5s, refresh button

MULTI-TENANT:
- getOrgId() tries session first, then header, then first-org fallback
- All dashboard APIs use session-based org
- Signup creates isolated org per charity
This commit is contained in:
2026-03-03 05:37:04 +08:00
parent 6894f091fd
commit 4f23f28873
22 changed files with 708 additions and 221 deletions

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5 p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<div className="inline-flex h-12 w-12 rounded-2xl bg-gradient-to-br from-trust-blue to-blue-600 items-center justify-center shadow-lg shadow-trust-blue/20 mb-4">
<span className="text-white text-xl">🤲</span>
</div>
<h1 className="text-2xl font-black text-gray-900">Welcome back</h1>
<p className="text-sm text-muted-foreground mt-1">Sign in to your charity dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-xl bg-danger-red/10 border border-danger-red/20 p-3 text-sm text-danger-red text-center">
{error}
</div>
)}
<div>
<label className="text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={email}
onChange={(e) => 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
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Password</label>
<input
type="password"
value={password}
onChange={(e) => 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
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-trust-blue px-4 py-3 text-sm font-semibold text-white hover:bg-trust-blue/90 disabled:opacity-50 transition-all"
>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/signup" className="text-trust-blue font-semibold hover:underline">
Get Started Free
</Link>
</p>
</div>
</div>
)
}

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5 p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<div className="inline-flex h-12 w-12 rounded-2xl bg-gradient-to-br from-trust-blue to-blue-600 items-center justify-center shadow-lg shadow-trust-blue/20 mb-4">
<span className="text-white text-xl">🤲</span>
</div>
<h1 className="text-2xl font-black text-gray-900">Get Started Free</h1>
<p className="text-sm text-muted-foreground mt-1">Set up your charity in 2 minutes</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-xl bg-danger-red/10 border border-danger-red/20 p-3 text-sm text-danger-red text-center">
{error}
</div>
)}
<div>
<label className="text-sm font-medium text-gray-700">Charity / Organisation Name</label>
<input
type="text"
value={charityName}
onChange={(e) => 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
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Your Name</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={email}
onChange={(e) => 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
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Password</label>
<input
type="password"
value={password}
onChange={(e) => 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}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-trust-blue px-4 py-3 text-sm font-semibold text-white hover:bg-trust-blue/90 disabled:opacity-50 transition-all"
>
{loading ? "Creating your account..." : "Create Account & Set Up →"}
</button>
<p className="text-[10px] text-center text-muted-foreground">
Free forever. No credit card needed. Takes 2 minutes.
</p>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-trust-blue font-semibold hover:underline">
Sign In
</Link>
</p>
</div>
</div>
)
}

View File

@@ -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 }

View File

@@ -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 })
}
}

View File

@@ -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 })
}

View File

@@ -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 })
}

View File

@@ -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()

View File

@@ -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),
// 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",
})
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")
} 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: [
{
webhooks: [{
url: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/api/whatsapp/webhook`,
events: ["message"],
},
],
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) })
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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 (
<div className="min-h-screen bg-gray-50/50">
@@ -27,8 +31,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<span className="text-white font-bold text-sm">P</span>
</div>
<div className="hidden sm:block">
<span className="font-black text-sm text-gray-900">PNPL</span>
<span className="text-[10px] text-muted-foreground ml-1">Dashboard</span>
<span className="font-black text-sm text-gray-900">{user?.orgName || "PNPL"}</span>
</div>
</Link>
<div className="flex-1" />
@@ -38,8 +41,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button>
</Link>
<Link href="/" className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ExternalLink className="h-3 w-3" /> <span className="hidden sm:inline">Public Site</span>
<ExternalLink className="h-3 w-3" />
</Link>
{session && (
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<LogOut className="h-3 w-3" />
</button>
)}
</div>
</header>

View File

@@ -33,7 +33,7 @@ export default function DashboardPage() {
const [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(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(() => {})

View File

@@ -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({

View File

@@ -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()

View File

@@ -31,7 +31,7 @@ export default function SettingsPage() {
const [error, setError] = useState<string | null>(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() {
<div className="flex flex-col items-center gap-4">
{qrImage ? (
<div className="relative">
{/* Crop to QR area: the screenshot shows full WhatsApp web page.
QR code is roughly in center. We use overflow hidden + object positioning. */}
<div className="w-72 h-72 rounded-xl border-2 border-[#25D366]/20 overflow-hidden bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrImage} alt="WhatsApp QR Code" className="w-64 h-64 rounded-xl border-2 border-[#25D366]/20" />
<img
src={qrImage}
alt="WhatsApp QR Code"
className="w-[200%] h-auto max-w-none"
style={{ marginLeft: "-30%", marginTop: "-35%" }}
/>
</div>
<div className="absolute -bottom-2 -right-2 w-8 h-8 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg">
<MessageCircle className="h-4 w-4 text-white" />
</div>
</div>
) : (
<div className="w-64 h-64 rounded-xl border-2 border-dashed border-muted flex items-center justify-center">
<div className="w-72 h-72 rounded-xl border-2 border-dashed border-muted flex items-center justify-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
</div>
)}
<div className="text-center space-y-1">
<p className="text-sm font-medium">Scan with WhatsApp</p>
<p className="text-xs text-muted-foreground">QR refreshes automatically every 5 seconds</p>
<p className="text-xs text-muted-foreground">Open WhatsApp Settings Linked Devices Link a Device</p>
<p className="text-xs text-muted-foreground">Auto-refreshes every 5 seconds</p>
</div>
<Button variant="outline" size="sm" onClick={checkStatus} className="gap-1.5">
<RefreshCw className="h-3 w-3" /> Refresh QR
<RefreshCw className="h-3 w-3" /> Refresh
</Button>
</div>
</CardContent>

View File

@@ -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 (
<html lang="en">
<body>
<Providers>
<ToastProvider>
{children}
</ToastProvider>
</Providers>
</body>
</html>
)

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<div className="min-h-screen bg-white">
{/* Nav */}
<header className="sticky top-0 z-40 border-b bg-white/80 backdrop-blur-xl">
<div className="max-w-5xl mx-auto flex h-14 items-center justify-between px-4">
<div className="flex items-center gap-2.5">
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-trust-blue to-blue-600 flex items-center justify-center">
<span className="text-white text-base">🤲</span>
</div>
<span className="font-black text-sm">Pledge Now, Pay Later</span>
</div>
<div className="flex items-center gap-3">
<Link href="/login" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
Sign In
</Link>
<Link href="/signup" className="rounded-lg bg-trust-blue px-4 py-2 text-sm font-semibold text-white hover:bg-trust-blue/90 transition-colors">
Get Started Free
</Link>
</div>
</div>
</header>
{/* Hero */}
<div className="flex flex-col items-center justify-center px-4 pt-20 pb-16">
<div className="text-center max-w-2xl mx-auto space-y-8">
<div className="space-y-4">
<div className="inline-flex items-center gap-2 rounded-full bg-trust-blue/10 px-4 py-2 text-sm font-medium text-trust-blue">
Free forever for UK charities
<section className="py-16 md:py-24 px-4">
<div className="max-w-3xl mx-auto text-center space-y-6">
<div className="inline-flex items-center gap-2 bg-trust-blue/5 border border-trust-blue/20 rounded-full px-4 py-1.5 text-xs font-medium text-trust-blue">
🇬🇧 Built for UK charities · Free to start
</div>
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight text-gray-900">
Pledge Now,{" "}
<span className="text-trust-blue">Pay Later</span>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 leading-tight">
Turn promises into
<span className="text-trust-blue"> payments</span>
</h1>
<p className="text-xl text-muted-foreground max-w-lg mx-auto">
Turn &quot;I&apos;ll donate later&quot; into tracked pledges with automatic follow-up. Built for UK charity fundraising events.
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
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.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/dashboard">
<Button size="xl">Open Dashboard </Button>
</Link>
<Link href="/p/demo">
<Button size="xl" variant="outline">Try Donor Flow </Button>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/signup" className="rounded-xl bg-trust-blue px-6 py-3.5 text-base font-semibold text-white hover:bg-trust-blue/90 transition-all shadow-lg shadow-trust-blue/20">
Start Collecting Pledges
</Link>
<a href="#how" className="rounded-xl border-2 border-gray-200 px-6 py-3.5 text-base font-semibold text-gray-700 hover:border-gray-300 transition-all">
See How It Works
</a>
</div>
<div className="grid grid-cols-3 gap-6 pt-8 border-t max-w-md mx-auto">
<div className="text-center">
<div className="text-2xl font-bold text-trust-blue">0%</div>
<div className="text-xs text-muted-foreground">Bank transfer fees</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-success-green">15s</div>
<div className="text-xs text-muted-foreground">Pledge time</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-warm-amber">+25%</div>
<div className="text-xs text-muted-foreground">Gift Aid boost</div>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">Free forever · No card needed · 2 minute setup</p>
</div>
</section>
{/* Who is this for? */}
<div className="bg-white border-y py-16">
<div className="max-w-4xl mx-auto px-4">
<h2 className="text-2xl font-bold text-center mb-2">Built for everyone in your fundraising chain</h2>
<p className="text-center text-muted-foreground mb-8">From the charity manager to the donor&apos;s phone</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Problem */}
<section className="py-12 bg-gray-50 px-4">
<div className="max-w-4xl mx-auto">
<div className="grid md:grid-cols-3 gap-6">
<div className="bg-white rounded-2xl p-6 border">
<div className="text-3xl mb-3">😤</div>
<h3 className="font-bold mb-1">Pledges go cold</h3>
<p className="text-sm text-muted-foreground">Donors say &quot;I&apos;ll pay £500&quot; at the gala, then forget. You have no way to follow up.</p>
</div>
<div className="bg-white rounded-2xl p-6 border">
<div className="text-3xl mb-3">📝</div>
<h3 className="font-bold mb-1">Paper tracking</h3>
<p className="text-sm text-muted-foreground">Spreadsheets, napkin notes, WhatsApp groups. No system, no references, no proof.</p>
</div>
<div className="bg-white rounded-2xl p-6 border">
<div className="text-3xl mb-3">💸</div>
<h3 className="font-bold mb-1">Money left on the table</h3>
<p className="text-sm text-muted-foreground">UK charities lose 30-50% of pledged amounts because there&apos;s no follow-up system.</p>
</div>
</div>
</div>
</section>
{/* How it works */}
<section id="how" className="py-16 px-4">
<div className="max-w-4xl mx-auto space-y-10">
<div className="text-center">
<h2 className="text-3xl font-black text-gray-900">How it works</h2>
<p className="text-muted-foreground mt-2">From pledge to payment in 4 steps</p>
</div>
<div className="grid md:grid-cols-4 gap-6">
{[
{ 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) => (
<div key={i} className="rounded-2xl border bg-white p-5 space-y-3 hover:shadow-md transition-shadow">
<p.icon className={`h-8 w-8 ${p.color}`} />
<h3 className="font-bold">{p.title}</h3>
<p className="text-xs text-muted-foreground leading-relaxed">{p.desc}</p>
</div>
))}
</div>
</div>
</div>
{/* Payment Methods */}
<div className="max-w-4xl mx-auto px-4 py-16">
<h2 className="text-2xl font-bold text-center mb-2">3 UK Payment Rails, One Platform</h2>
<p className="text-center text-muted-foreground mb-8">Every method a UK donor expects</p>
<div className="grid sm:grid-cols-3 gap-4">
{[
{ 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) => (
<div key={i} className="rounded-2xl border bg-white p-6 text-center space-y-3 hover:shadow-md transition-shadow">
<m.icon className={`h-10 w-10 mx-auto ${m.color}`} />
<span className={`inline-block text-xs font-bold px-3 py-1 rounded-full ${
i === 0 ? "bg-success-green/10 text-success-green" : i === 1 ? "bg-trust-blue/10 text-trust-blue" : "bg-purple-100 text-purple-700"
}`}>{m.tag}</span>
<h3 className="font-bold">{m.title}</h3>
<p className="text-xs text-muted-foreground">{m.desc}</p>
{ 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) => (
<div key={s.step} className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-trust-blue/5 text-2xl">
{s.icon}
</div>
<div className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-trust-blue text-white text-xs font-bold">
{s.step}
</div>
<h3 className="font-bold text-sm">{s.title}</h3>
<p className="text-xs text-muted-foreground">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Features */}
<div className="bg-white border-y py-16">
<div className="max-w-4xl mx-auto px-4">
<h2 className="text-2xl font-bold text-center mb-8">Everything a UK charity needs</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
<section className="py-16 bg-gray-50 px-4">
<div className="max-w-4xl mx-auto space-y-10">
<div className="text-center">
<h2 className="text-3xl font-black text-gray-900">Everything you need</h2>
</div>
<div className="grid md:grid-cols-2 gap-4">
{[
{ 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) => (
<div key={i} className="space-y-2">
<f.icon className="h-6 w-6 text-trust-blue" />
{ 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) => (
<div key={f.title} className="bg-white rounded-xl p-4 border flex gap-3 items-start">
<span className="text-xl">{f.icon}</span>
<div>
<h3 className="font-bold text-sm">{f.title}</h3>
<p className="text-xs text-muted-foreground">{f.desc}</p>
<p className="text-xs text-muted-foreground mt-0.5">{f.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Donor schedule */}
<section className="py-16 px-4">
<div className="max-w-3xl mx-auto text-center space-y-8">
<h2 className="text-3xl font-black text-gray-900">Donors choose when to pay</h2>
<div className="grid grid-cols-3 gap-4">
<div className="rounded-2xl border-2 border-trust-blue/20 p-5 space-y-2">
<div className="text-3xl"></div>
<h3 className="font-bold">Pay Now</h3>
<p className="text-xs text-muted-foreground">Card, bank transfer, or Direct Debit right away</p>
</div>
<div className="rounded-2xl border-2 border-warm-amber/20 p-5 space-y-2">
<div className="text-3xl">📅</div>
<h3 className="font-bold">Pick a Date</h3>
<p className="text-xs text-muted-foreground">&quot;I&apos;ll pay on payday&quot; reminders sent automatically</p>
</div>
<div className="rounded-2xl border-2 border-success-green/20 p-5 space-y-2">
<div className="text-3xl">📆</div>
<h3 className="font-bold">Monthly</h3>
<p className="text-xs text-muted-foreground">Split into 2-12 instalments. Each one tracked separately</p>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="py-16 bg-gradient-to-br from-trust-blue to-blue-600 px-4">
<div className="max-w-2xl mx-auto text-center space-y-6">
<h2 className="text-3xl font-black text-white">Start collecting pledges today</h2>
<p className="text-blue-100">
Free to use. Set up in 2 minutes. No technical knowledge needed.
</p>
<Link href="/signup" className="inline-block rounded-xl bg-white px-8 py-4 text-base font-bold text-trust-blue hover:bg-blue-50 transition-all shadow-xl">
Create Your Free Account
</Link>
<p className="text-xs text-blue-200">Used by mosques, churches, schools and charities across the UK</p>
</div>
</section>
{/* Footer */}
<footer className="py-8 px-4 text-center text-xs text-muted-foreground space-y-2">
<p>Pledge Now, Pay Later Built for UK charities by <a href="https://calvana.quikcue.com" className="text-trust-blue hover:underline">QuikCue</a>.</p>
<p>Free forever. No hidden fees. No card required.</p>
<footer className="py-8 px-4 border-t">
<div className="max-w-4xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-lg bg-trust-blue flex items-center justify-center">
<span className="text-white text-[10px]">🤲</span>
</div>
<span>Pledge Now, Pay Later</span>
</div>
<div className="flex gap-4">
<Link href="/login" className="hover:text-foreground">Sign In</Link>
<Link href="/signup" className="hover:text-foreground">Get Started</Link>
</div>
<span>© {new Date().getFullYear()} QuikCue Ltd</span>
</div>
</footer>
</div>
)

View File

@@ -0,0 +1,7 @@
"use client"
import { SessionProvider } from "next-auth/react"
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}

View File

@@ -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
},
},
}

View File

@@ -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
}

View File

@@ -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<SessionUser | null> {
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<string | null> {
// 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)
}

View File

@@ -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<string, { count: number; resetAt: number }>()
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*"],
}