242 lines
9.1 KiB
TypeScript
242 lines
9.1 KiB
TypeScript
"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>
|
|
)
|
|
}
|