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

@@ -0,0 +1,127 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { orpc } from "@/utils/orpc";
interface QrCodeCanvasProps {
token: string;
}
function QrCodeCanvas({ token }: QrCodeCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current || !token) return;
// Dynamic import to avoid SSR issues
import("qrcode").then((QRCode) => {
if (canvasRef.current) {
QRCode.toCanvas(canvasRef.current, token, {
width: 280,
margin: 2,
color: { dark: "#214e51", light: "#ffffff" },
});
}
});
}, [token]);
return <canvas ref={canvasRef} className="rounded-xl" />;
}
function useCountdown(expiresAt: Date | undefined) {
const [secondsLeft, setSecondsLeft] = useState<number>(0);
useEffect(() => {
if (!expiresAt) return;
const tick = () => {
const diff = Math.max(
0,
Math.floor((expiresAt.getTime() - Date.now()) / 1000),
);
setSecondsLeft(diff);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
return secondsLeft;
}
export function QrCodeDisplay() {
const { data, refetch, isLoading, isError } = useQuery(
orpc.drinkkaart.getMyQrToken.queryOptions(),
);
const expiresAt = data?.expiresAt ? new Date(data.expiresAt) : undefined;
const secondsLeft = useCountdown(expiresAt);
// Auto-refresh 60s before expiry
useEffect(() => {
if (!expiresAt) return;
const refreshAt = expiresAt.getTime() - 60_000;
const delay = refreshAt - Date.now();
if (delay <= 0) {
refetch();
return;
}
const timer = setTimeout(() => refetch(), delay);
return () => clearTimeout(timer);
}, [expiresAt, refetch]);
// Refresh on tab focus if token is about to expire
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible" && expiresAt) {
if (Date.now() >= expiresAt.getTime() - 10_000) refetch();
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [expiresAt, refetch]);
const minutes = Math.floor(secondsLeft / 60);
const seconds = secondsLeft % 60;
const countdownLabel = `${minutes}:${String(seconds).padStart(2, "0")}`;
if (isLoading) {
return (
<div className="flex h-[280px] w-[280px] items-center justify-center rounded-xl bg-white/10">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
);
}
if (isError || !data?.token) {
return (
<div className="flex h-[280px] w-[280px] flex-col items-center justify-center gap-2 rounded-xl bg-white/10">
<p className="text-sm text-white/60">Kon QR-code niet laden</p>
<button
type="button"
onClick={() => refetch()}
className="text-sm text-white underline"
>
Opnieuw proberen
</button>
</div>
);
}
return (
<div className="flex flex-col items-center gap-3">
<div className="rounded-xl bg-white p-3 shadow-lg">
<QrCodeCanvas token={data.token} />
</div>
<p className="text-sm text-white/60">
{secondsLeft > 0 ? (
<>
Vervalt over{" "}
<span className="text-white/80 tabular-nums">{countdownLabel}</span>
</>
) : (
<span className="text-yellow-400">Verlopen vernieuwen...</span>
)}
</p>
</div>
);
}