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:
@@ -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">
|
||||
{/* 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" />
|
||||
{/* 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-[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>
|
||||
|
||||
Reference in New Issue
Block a user