feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
241
pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
Normal file
241
pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { QRCodeCanvas } from "@/components/qr-code"
|
||||
|
||||
interface QrSourceInfo {
|
||||
id: string
|
||||
label: string
|
||||
code: string
|
||||
volunteerName: string | null
|
||||
tableName: string | null
|
||||
scanCount: number
|
||||
pledgeCount: number
|
||||
totalPledged: number
|
||||
}
|
||||
|
||||
export default function EventQRPage() {
|
||||
const params = useParams()
|
||||
const eventId = params.id as string
|
||||
const [qrSources, setQrSources] = useState<QrSourceInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({ label: "", volunteerName: "", tableName: "" })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/events/${eventId}/qr`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setQrSources(data)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [eventId])
|
||||
|
||||
const copyLink = async (code: string) => {
|
||||
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
|
||||
setCopiedCode(code)
|
||||
setTimeout(() => setCopiedCode(null), 2000)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await fetch(`/api/events/${eventId}/qr`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (res.ok) {
|
||||
const qr = await res.json()
|
||||
setQrSources((prev) => [{ ...qr, scanCount: 0, pledgeCount: 0, totalPledged: 0 }, ...prev])
|
||||
setShowCreate(false)
|
||||
setForm({ label: "", volunteerName: "", tableName: "" })
|
||||
}
|
||||
} catch {}
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
// Auto-generate label
|
||||
useEffect(() => {
|
||||
if (form.volunteerName || form.tableName) {
|
||||
const parts = [form.tableName, form.volunteerName].filter(Boolean)
|
||||
setForm((f) => ({ ...f, label: parts.join(" - ") }))
|
||||
}
|
||||
}, [form.volunteerName, form.tableName])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalScans = qrSources.reduce((s, q) => s + q.scanCount, 0)
|
||||
const totalPledges = qrSources.reduce((s, q) => s + q.pledgeCount, 0)
|
||||
const totalAmount = qrSources.reduce((s, q) => s + q.totalPledged, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/events"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" /> Back to Events
|
||||
</Link>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">QR Codes</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> New QR Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* QR Grid */}
|
||||
{qrSources.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center space-y-4">
|
||||
<QrCode className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
<p className="text-muted-foreground">No QR codes yet. Create one to start collecting pledges!</p>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Create First QR Code
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{qrSources.map((qr) => (
|
||||
<Card key={qr.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{/* QR Code */}
|
||||
<div className="max-w-[180px] mx-auto bg-white rounded-2xl flex items-center justify-center p-2">
|
||||
<QRCodeCanvas url={`${baseUrl}/p/${qr.code}`} size={164} />
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-bold">{qr.label}</h3>
|
||||
{qr.volunteerName && (
|
||||
<p className="text-xs text-muted-foreground">Volunteer: {qr.volunteerName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-lg bg-gray-50 p-2">
|
||||
<p className="font-bold text-sm">{qr.scanCount}</p>
|
||||
<p className="text-muted-foreground">Scans</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2">
|
||||
<p className="font-bold text-sm">{qr.pledgeCount}</p>
|
||||
<p className="text-muted-foreground">Pledges</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2">
|
||||
<p className="font-bold text-sm">{formatPence(qr.totalPledged)}</p>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversion rate */}
|
||||
{qr.scanCount > 0 && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Conversion: <span className="font-semibold text-foreground">{Math.round((qr.pledgeCount / qr.scanCount) * 100)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => copyLink(qr.code)}
|
||||
>
|
||||
{copiedCode === qr.code ? (
|
||||
<><Check className="h-3 w-3 mr-1" /> Copied</>
|
||||
) : (
|
||||
<><Copy className="h-3 w-3 mr-1" /> Link</>
|
||||
)}
|
||||
</Button>
|
||||
<a href={`/api/events/${eventId}/qr/${qr.id}/download?code=${qr.code}`} download>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-3 w-3 mr-1" /> PNG
|
||||
</Button>
|
||||
</a>
|
||||
<a href={`/p/${qr.code}`} target="_blank">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create QR Code</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Table Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Table 5"
|
||||
value={form.tableName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tableName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Volunteer Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Ahmed"
|
||||
value={form.volunteerName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, volunteerName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Label *</Label>
|
||||
<Input
|
||||
placeholder="e.g. Table 5 - Ahmed"
|
||||
value={form.label}
|
||||
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Auto-generated from table + volunteer, or enter custom</p>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!form.label || creating} className="flex-1">
|
||||
{creating ? "Creating..." : "Create QR Code"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user