feat:drinkkaart

This commit is contained in:
2026-03-03 23:52:49 +01:00
parent b8cefd373d
commit 79869a9a21
35 changed files with 3189 additions and 76 deletions

View File

@@ -9,6 +9,7 @@ import {
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { CookieConsent } from "@/components/CookieConsent";
import { SiteHeader } from "@/components/SiteHeader";
import { Toaster } from "@/components/ui/sonner";
import type { orpc } from "@/utils/orpc";
@@ -165,6 +166,7 @@ function RootDocument() {
>
Ga naar hoofdinhoud
</a>
<SiteHeader />
<div id="main-content">
<Outlet />
</div>

View File

@@ -0,0 +1,236 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { AdminTransactionLog } from "@/components/drinkkaart/AdminTransactionLog";
import { DeductionPanel } from "@/components/drinkkaart/DeductionPanel";
import { ManualCreditForm } from "@/components/drinkkaart/ManualCreditForm";
import { QrScanner } from "@/components/drinkkaart/QrScanner";
import { ScanResultCard } from "@/components/drinkkaart/ScanResultCard";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/admin/drinkkaart")({
component: AdminDrinkkaartPage,
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: "/" });
}
},
});
type ScanState =
| { step: "idle" }
| { step: "scanning" }
| { step: "resolving" }
| {
step: "resolved";
drinkkaartId: string;
userId: string;
userName: string;
userEmail: string;
balance: number;
}
| {
step: "success";
transactionId: string;
userName: string;
balanceAfter: number;
}
| { step: "error"; message: string; retryable: boolean }
| { step: "manual_credit" };
function AdminDrinkkaartPage() {
const [scanState, setScanState] = useState<ScanState>({ step: "idle" });
const resolveQrMutation = useMutation(
orpc.drinkkaart.resolveQrToken.mutationOptions(),
);
const handleScan = useCallback(
(token: string) => {
setScanState({ step: "resolving" });
resolveQrMutation.mutate(
{ token },
{
onSuccess: (data) => {
setScanState({
step: "resolved",
drinkkaartId: data.drinkkaartId,
userId: data.userId,
userName: data.userName,
userEmail: data.userEmail,
balance: data.balance,
});
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Ongeldig QR-token",
retryable: true,
});
},
},
);
},
[resolveQrMutation],
);
const handleDeductSuccess = (transactionId: string, balanceAfter: number) => {
const userName =
scanState.step === "resolved" ? scanState.userName : "Gebruiker";
const amountDeducted =
scanState.step === "resolved"
? scanState.balance - balanceAfter
: undefined;
toast.success(
`Afschrijving verwerkt${amountDeducted !== undefined ? `${formatCents(amountDeducted)}` : ""}`,
);
setScanState({ step: "success", transactionId, userName, balanceAfter });
};
return (
<div className="min-h-screen bg-[#214e51]">
{/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-6 py-5">
<div className="mx-auto flex max-w-2xl items-center gap-4">
<Link to="/admin" className="text-sm text-white/60 hover:text-white">
Admin
</Link>
<h1 className="font-['Intro',sans-serif] text-2xl text-white">
Drinkkaart Beheer
</h1>
</div>
</header>
<main className="mx-auto max-w-2xl space-y-8 px-6 py-8">
{/* Scan / State machine */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{scanState.step === "idle" && (
<div className="space-y-4">
<p className="text-sm text-white/60">
Scan een QR-code of laad handmatig op.
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => setScanState({ step: "scanning" })}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Scan QR
</Button>
<Button
onClick={() => setScanState({ step: "manual_credit" })}
variant="outline"
className="flex-1 border-white/30 bg-transparent text-white hover:bg-white/10"
>
Handmatig opladen
</Button>
</div>
</div>
)}
{scanState.step === "scanning" && (
<QrScanner
onScan={handleScan}
onCancel={() => setScanState({ step: "idle" })}
/>
)}
{scanState.step === "resolving" && (
<div className="py-8 text-center">
<p className="text-white/60">QR-code wordt gecontroleerd...</p>
</div>
)}
{scanState.step === "resolved" && (
<div className="space-y-5">
<ScanResultCard
userName={scanState.userName}
userEmail={scanState.userEmail}
balance={scanState.balance}
/>
<DeductionPanel
drinkkaartId={scanState.drinkkaartId}
userName={scanState.userName}
userEmail={scanState.userEmail}
balance={scanState.balance}
onSuccess={handleDeductSuccess}
onCancel={() => setScanState({ step: "idle" })}
/>
</div>
)}
{scanState.step === "success" && (
<div className="space-y-5">
<div className="rounded-xl border border-green-400/20 bg-green-400/5 p-5 text-center">
<p className="font-semibold text-green-300 text-lg">
Betaling verwerkt
</p>
<p className="mt-1 text-sm text-white/60">
{scanState.userName} nieuw saldo:{" "}
<strong className="text-white">
{formatCents(scanState.balanceAfter)}
</strong>
</p>
</div>
<Button
onClick={() => setScanState({ step: "idle" })}
className="w-full bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Volgende scan
</Button>
</div>
)}
{scanState.step === "error" && (
<div className="space-y-4">
<div className="rounded-xl border border-red-400/20 bg-red-400/5 p-4">
<p className="font-medium text-red-300">Fout</p>
<p className="mt-1 text-sm text-white/60">
{scanState.message}
</p>
</div>
<div className="flex gap-3">
{scanState.retryable && (
<Button
onClick={() => setScanState({ step: "scanning" })}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Opnieuw proberen
</Button>
)}
<Button
onClick={() => setScanState({ step: "idle" })}
variant="outline"
className="flex-1 border-white/20 bg-transparent text-white hover:bg-white/10"
>
Annuleren
</Button>
</div>
</div>
)}
{scanState.step === "manual_credit" && (
<ManualCreditForm onDone={() => setScanState({ step: "idle" })} />
)}
</div>
{/* Transaction log */}
<div>
<h2 className="mb-4 font-['Intro',sans-serif] text-white text-xl">
Transactieoverzicht
</h2>
<AdminTransactionLog />
</div>
</main>
</div>
);
}

View File

@@ -32,7 +32,7 @@ import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/admin")({
export const Route = createFileRoute("/admin/")({
component: AdminPage,
beforeLoad: async () => {
const session = await authClient.getSession();
@@ -286,14 +286,22 @@ function AdminPage() {
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 className="flex items-center gap-3">
<Link
to="/admin/drinkkaart"
className="inline-flex items-center rounded-lg border border-white/30 px-4 py-2 text-sm text-white/80 transition-colors hover:bg-white/10 hover:text-white"
>
Drinkkaart beheer
</Link>
<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>
</div>
</header>

View File

@@ -1,9 +1,9 @@
import { createHmac } from "node:crypto";
import { createHmac, randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { registration } from "@kk/db/schema";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
// Webhook payload types
interface LemonSqueezyWebhookPayload {
@@ -11,6 +11,9 @@ interface LemonSqueezyWebhookPayload {
event_name: string;
custom_data?: {
registration_token?: string;
type?: string;
drinkkaartId?: string;
userId?: string;
};
};
data: {
@@ -20,6 +23,7 @@ interface LemonSqueezyWebhookPayload {
customer_id: number;
order_number: number;
status: string;
total: number;
};
};
}
@@ -68,7 +72,102 @@ async function handleWebhook({ request }: { request: Request }) {
return new Response("Event ignored", { status: 200 });
}
const registrationToken = event.meta.custom_data?.registration_token;
const customData = event.meta.custom_data;
// ---------------------------------------------------------------------------
// Branch: Drinkkaart top-up
// ---------------------------------------------------------------------------
if (customData?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = customData;
if (!drinkkaartId || !userId) {
console.error(
"Missing drinkkaartId or userId in drinkkaart_topup webhook",
);
return new Response("Missing drinkkaart data", { status: 400 });
}
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
// Use Lemon Squeezy's confirmed total (in cents)
const amountCents = event.data.attributes.total;
// Idempotency: skip if already processed
const existing = await db
.select({ id: drinkkaartTopup.id })
.from(drinkkaartTopup)
.where(eq(drinkkaartTopup.lemonsqueezyOrderId, orderId))
.limit(1)
.then((r) => r[0]);
if (existing) {
console.log(`Drinkkaart topup already processed for order ${orderId}`);
return new Response("OK", { status: 200 });
}
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
console.error(`Drinkkaart not found: ${drinkkaartId}`);
return new Response("Not found", { status: 404 });
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + amountCents;
// Optimistic lock update
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: new Date(),
})
.where(
and(
eq(drinkkaart.id, drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
// Return 500 so Lemon Squeezy retries; idempotency check prevents double-credit
console.error(
`Drinkkaart optimistic lock conflict for ${drinkkaartId}`,
);
return new Response("Conflict — please retry", { status: 500 });
}
await db.insert(drinkkaartTopup).values({
id: randomUUID(),
drinkkaartId,
userId,
amountCents,
balanceBefore,
balanceAfter,
type: "payment",
lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId,
adminId: null,
reason: null,
paidAt: new Date(),
});
console.log(
`Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, order=${orderId}`,
);
return new Response("OK", { status: 200 });
}
// ---------------------------------------------------------------------------
// Branch: Registration payment
// ---------------------------------------------------------------------------
const registrationToken = customData?.registration_token;
if (!registrationToken) {
console.error("No registration token in webhook payload");
return new Response("Missing registration token", { status: 400 });

View File

@@ -0,0 +1,138 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { QrCode, Wallet } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { QrCodeDisplay } from "@/components/drinkkaart/QrCodeDisplay";
import { TopUpHistory } from "@/components/drinkkaart/TopUpHistory";
import { TopUpModal } from "@/components/drinkkaart/TopUpModal";
import { TransactionHistory } from "@/components/drinkkaart/TransactionHistory";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/drinkkaart")({
validateSearch: (search: Record<string, unknown>) => ({
topup: search.topup as string | undefined,
}),
component: DrinkkaartPage,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
},
});
function DrinkkaartPage() {
const queryClient = useQueryClient();
const search = Route.useSearch();
const [showQr, setShowQr] = useState(false);
const [showTopUp, setShowTopUp] = useState(false);
const { data, isLoading } = useQuery(
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
);
// Handle topup=success redirect
useEffect(() => {
if (search.topup === "success") {
toast.success("Oplading geslaagd! Je saldo is bijgewerkt.");
queryClient.invalidateQueries({
queryKey: orpc.drinkkaart.getMyDrinkkaart.key(),
});
// Remove query param without full navigation
const url = new URL(window.location.href);
url.searchParams.delete("topup");
window.history.replaceState({}, "", url.toString());
}
}, [search.topup, queryClient]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#214e51]">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
);
}
return (
<div className="min-h-screen bg-[#214e51]">
<main className="mx-auto max-w-lg px-4 py-10">
<h1 className="mb-6 font-['Intro',sans-serif] text-3xl text-white">
Mijn Drinkkaart
</h1>
{/* Balance card */}
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-6">
<p className="mb-1 text-sm text-white/50">Huidig saldo</p>
<p className="mb-4 font-['Intro',sans-serif] text-5xl text-white">
{data ? formatCents(data.balance) : "—"}
</p>
<div className="flex flex-wrap gap-3">
<Button
onClick={() => setShowQr((v) => !v)}
variant="outline"
className="border-white/30 bg-transparent text-white hover:bg-white/10"
>
<QrCode className="mr-2 h-4 w-4" />
{showQr ? "Verberg QR-code" : "Toon QR-code"}
</Button>
<Button
onClick={() => setShowTopUp(true)}
className="bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
<Wallet className="mr-2 h-4 w-4" />
Opladen
</Button>
</div>
</div>
{/* QR code */}
{showQr && (
<div className="mb-6 flex justify-center rounded-2xl border border-white/10 bg-white/5 p-6">
<QrCodeDisplay />
</div>
)}
{/* Top-up history */}
<section className="mb-8">
<h2 className="mb-3 font-['Intro',sans-serif] text-lg text-white/80">
Opladingen
</h2>
{data ? (
<TopUpHistory
topups={
data.topups as Parameters<typeof TopUpHistory>[0]["topups"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
{/* Transaction history */}
<section>
<h2 className="mb-3 font-['Intro',sans-serif] text-lg text-white/80">
Transacties
</h2>
{data ? (
<TransactionHistory
transactions={
data.transactions as Parameters<
typeof TransactionHistory
>[0]["transactions"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
</main>
{showTopUp && <TopUpModal onClose={() => setShowTopUp(false)} />}
</div>
);
}