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:
94
pledge-now-pay-later/src/app/(auth)/login/page.tsx
Normal file
94
pledge-now-pay-later/src/app/(auth)/login/page.tsx
Normal 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't have an account?{" "}
|
||||
<Link href="/signup" className="text-trust-blue font-semibold hover:underline">
|
||||
Get Started Free
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
pledge-now-pay-later/src/app/(auth)/signup/page.tsx
Normal file
144
pledge-now-pay-later/src/app/(auth)/signup/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
58
pledge-now-pay-later/src/app/api/auth/signup/route.ts
Normal file
58
pledge-now-pay-later/src/app/api/auth/signup/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 "I'll donate later" 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'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 "I'll pay £500" 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'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">"I'll pay on payday" — 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>
|
||||
)
|
||||
|
||||
7
pledge-now-pay-later/src/app/providers.tsx
Normal file
7
pledge-now-pay-later/src/app/providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>
|
||||
}
|
||||
69
pledge-now-pay-later/src/lib/auth.ts
Normal file
69
pledge-now-pay-later/src/lib/auth.ts
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
18
pledge-now-pay-later/src/lib/qr-store.ts
Normal file
18
pledge-now-pay-later/src/lib/qr-store.ts
Normal 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
|
||||
}
|
||||
43
pledge-now-pay-later/src/lib/session.ts
Normal file
43
pledge-now-pay-later/src/lib/session.ts
Normal 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)
|
||||
}
|
||||
@@ -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*"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user