- Add contact, privacy, and terms pages - Add CookieConsent component with accept/decline and localStorage - Add self-hosted DM Sans font with @font-face definitions - Improve registration form with field validation, blur handlers, and performer toggle - Redesign Info section with 'Ongedesemd Brood' hero and FAQ layout - Remove scroll-snap behavior from all sections - Add reduced motion support and selection color theming - Add SVG favicon and SEO meta tags in root layout - Improve accessibility: aria attributes, semantic HTML, focus styles - Add link-hover underline animation utility
462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
createFileRoute,
|
|
Link,
|
|
redirect,
|
|
useNavigate,
|
|
} from "@tanstack/react-router";
|
|
import { Check, Download, LogOut, Search, Users, X } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { authClient } from "@/lib/auth-client";
|
|
import { orpc } from "@/utils/orpc";
|
|
|
|
export const Route = createFileRoute("/admin")({
|
|
component: AdminPage,
|
|
beforeLoad: async () => {
|
|
const session = await authClient.getSession();
|
|
if (!session.data?.user) {
|
|
throw redirect({ to: "/login" });
|
|
}
|
|
const user = session.data.user as { role?: string };
|
|
if (user.role !== "admin") {
|
|
throw redirect({ to: "/login" });
|
|
}
|
|
},
|
|
});
|
|
|
|
function AdminPage() {
|
|
const navigate = useNavigate();
|
|
const [search, setSearch] = useState("");
|
|
const [artForm, setArtForm] = useState("");
|
|
const [fromDate, setFromDate] = useState("");
|
|
const [toDate, setToDate] = useState("");
|
|
const [page, setPage] = useState(1);
|
|
const pageSize = 20;
|
|
|
|
const statsQuery = useQuery(orpc.getRegistrationStats.queryOptions());
|
|
|
|
const registrationsQuery = useQuery(
|
|
orpc.getRegistrations.queryOptions({
|
|
input: {
|
|
search: search || undefined,
|
|
artForm: artForm || undefined,
|
|
fromDate: fromDate || undefined,
|
|
toDate: toDate || undefined,
|
|
page,
|
|
pageSize,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions());
|
|
|
|
const exportMutation = useMutation({
|
|
...orpc.exportRegistrations.mutationOptions(),
|
|
onSuccess: (data: { csv: string; filename: string }) => {
|
|
const blob = new Blob([data.csv], { type: "text/csv" });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = data.filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
});
|
|
|
|
const approveRequestMutation = useMutation({
|
|
...orpc.approveAdminRequest.mutationOptions(),
|
|
onSuccess: () => {
|
|
toast.success("Admin toegang goedgekeurd");
|
|
adminRequestsQuery.refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Fout: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const rejectRequestMutation = useMutation({
|
|
...orpc.rejectAdminRequest.mutationOptions(),
|
|
onSuccess: () => {
|
|
toast.success("Admin toegang geweigerd");
|
|
adminRequestsQuery.refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Fout: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const handleSignOut = async () => {
|
|
await authClient.signOut();
|
|
navigate({ to: "/" });
|
|
};
|
|
|
|
const handleExport = () => {
|
|
exportMutation.mutate(undefined);
|
|
};
|
|
|
|
const handleApprove = (requestId: string) => {
|
|
approveRequestMutation.mutate({ requestId });
|
|
};
|
|
|
|
const handleReject = (requestId: string) => {
|
|
rejectRequestMutation.mutate({ requestId });
|
|
};
|
|
|
|
const stats = statsQuery.data;
|
|
const registrations = registrationsQuery.data?.data ?? [];
|
|
const pagination = registrationsQuery.data?.pagination;
|
|
const adminRequests = adminRequestsQuery.data ?? [];
|
|
const pendingRequests = adminRequests.filter((r) => r.status === "pending");
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#214e51]">
|
|
{/* Header */}
|
|
<header className="border-white/10 border-b bg-[#214e51]/95 px-8 py-6">
|
|
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link to="/" className="text-white hover:opacity-80">
|
|
← Terug naar website
|
|
</Link>
|
|
<h1 className="font-['Intro',sans-serif] text-3xl text-white">
|
|
Admin Dashboard
|
|
</h1>
|
|
</div>
|
|
<Button
|
|
onClick={handleSignOut}
|
|
variant="outline"
|
|
className="border-white/30 bg-transparent text-white hover:bg-white/10"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
Uitloggen
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="mx-auto max-w-7xl p-8">
|
|
{/* Pending Admin Requests */}
|
|
{pendingRequests.length > 0 && (
|
|
<Card className="mb-6 border-yellow-500/30 bg-yellow-500/10">
|
|
<CardHeader>
|
|
<CardTitle className="font-['Intro',sans-serif] text-xl text-yellow-200">
|
|
Openstaande Admin Aanvragen ({pendingRequests.length})
|
|
</CardTitle>
|
|
<CardDescription className="text-yellow-200/60">
|
|
Gebruikers die admin toegang hebben aangevraagd
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{pendingRequests.map((request) => (
|
|
<div
|
|
key={request.id}
|
|
className="flex items-center justify-between rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4"
|
|
>
|
|
<div>
|
|
<p className="font-medium text-white">
|
|
{request.userName}
|
|
</p>
|
|
<p className="text-sm text-white/60">
|
|
{request.userEmail}
|
|
</p>
|
|
<p className="text-white/40 text-xs">
|
|
Aangevraagd:{" "}
|
|
{new Date(request.requestedAt).toLocaleDateString(
|
|
"nl-BE",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => handleApprove(request.id)}
|
|
disabled={approveRequestMutation.isPending}
|
|
size="sm"
|
|
className="bg-green-600 text-white hover:bg-green-700"
|
|
>
|
|
<Check className="mr-1 h-4 w-4" />
|
|
Goedkeuren
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleReject(request.id)}
|
|
disabled={rejectRequestMutation.isPending}
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
|
>
|
|
<X className="mr-1 h-4 w-4" />
|
|
Weigeren
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Stats Cards */}
|
|
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
<Card className="border-white/10 bg-white/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-white/60">
|
|
Totaal inschrijvingen
|
|
</CardDescription>
|
|
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
|
|
{stats?.total ?? 0}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Users className="h-5 w-5 text-white/40" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-white/10 bg-white/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-white/60">
|
|
Vandaag ingeschreven
|
|
</CardDescription>
|
|
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
|
|
{stats?.today ?? 0}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<span className="text-sm text-white/40">
|
|
Nieuwe registraties vandaag
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-white/10 bg-white/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-white/60">
|
|
Per kunstvorm
|
|
</CardDescription>
|
|
<div className="mt-2 space-y-1">
|
|
{stats?.byArtForm.slice(0, 5).map((item) => (
|
|
<div
|
|
key={item.artForm}
|
|
className="flex items-center justify-between text-sm"
|
|
>
|
|
<span className="text-white/80">{item.artForm}</span>
|
|
<span className="text-white">{item.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="mb-6 border-white/10 bg-white/5">
|
|
<CardHeader>
|
|
<CardTitle className="font-['Intro',sans-serif] text-white text-xl">
|
|
Filters
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
<div>
|
|
<label
|
|
htmlFor="search"
|
|
className="mb-2 block text-sm text-white/60"
|
|
>
|
|
Zoeken
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-white/40" />
|
|
<Input
|
|
id="search"
|
|
placeholder="Naam of email..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="border-white/20 bg-white/10 pl-10 text-white placeholder:text-white/40"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="artForm"
|
|
className="mb-2 block text-sm text-white/60"
|
|
>
|
|
Kunstvorm
|
|
</label>
|
|
<Input
|
|
id="artForm"
|
|
placeholder="Filter op kunstvorm..."
|
|
value={artForm}
|
|
onChange={(e) => setArtForm(e.target.value)}
|
|
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="fromDate"
|
|
className="mb-2 block text-sm text-white/60"
|
|
>
|
|
Vanaf
|
|
</label>
|
|
<Input
|
|
id="fromDate"
|
|
type="date"
|
|
value={fromDate}
|
|
onChange={(e) => setFromDate(e.target.value)}
|
|
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="toDate"
|
|
className="mb-2 block text-sm text-white/60"
|
|
>
|
|
Tot
|
|
</label>
|
|
<Input
|
|
id="toDate"
|
|
type="date"
|
|
value={toDate}
|
|
onChange={(e) => setToDate(e.target.value)}
|
|
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Export Button */}
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<p className="text-white/60">
|
|
{pagination?.total ?? 0} registraties gevonden
|
|
</p>
|
|
<Button
|
|
onClick={handleExport}
|
|
disabled={exportMutation.isPending}
|
|
className="bg-white text-[#214e51] hover:bg-white/90"
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Registrations Table */}
|
|
<Card className="border-white/10 bg-white/5">
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-white/10 border-b">
|
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
|
Naam
|
|
</th>
|
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
|
Email
|
|
</th>
|
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
|
Telefoon
|
|
</th>
|
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
|
Kunstvorm
|
|
</th>
|
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
|
Ervaring
|
|
</th>
|
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
|
Datum
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{registrationsQuery.isLoading ? (
|
|
<tr>
|
|
<td
|
|
colSpan={6}
|
|
className="px-6 py-8 text-center text-white/60"
|
|
>
|
|
Laden...
|
|
</td>
|
|
</tr>
|
|
) : registrations.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={6}
|
|
className="px-6 py-8 text-center text-white/60"
|
|
>
|
|
Geen registraties gevonden
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
registrations.map((reg) => (
|
|
<tr
|
|
key={reg.id}
|
|
className="border-white/5 border-b hover:bg-white/5"
|
|
>
|
|
<td className="px-6 py-4 text-white">
|
|
{reg.firstName} {reg.lastName}
|
|
</td>
|
|
<td className="px-6 py-4 text-white/80">{reg.email}</td>
|
|
<td className="px-6 py-4 text-white/80">
|
|
{reg.phone || "-"}
|
|
</td>
|
|
<td className="px-6 py-4 text-white/80">
|
|
{reg.artForm}
|
|
</td>
|
|
<td className="px-6 py-4 text-white/80">
|
|
{reg.experience || "-"}
|
|
</td>
|
|
<td className="px-6 py-4 text-white/60">
|
|
{new Date(reg.createdAt).toLocaleDateString("nl-BE")}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Pagination */}
|
|
{pagination && pagination.totalPages > 1 && (
|
|
<div className="mt-6 flex items-center justify-center gap-2">
|
|
<Button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
variant="outline"
|
|
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
|
|
>
|
|
Vorige
|
|
</Button>
|
|
<span className="mx-4 text-white">
|
|
Pagina {page} van {pagination.totalPages}
|
|
</span>
|
|
<Button
|
|
onClick={() =>
|
|
setPage((p) => Math.min(pagination.totalPages, p + 1))
|
|
}
|
|
disabled={page === pagination.totalPages}
|
|
variant="outline"
|
|
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
|
|
>
|
|
Volgende
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|