feat:drinkkaart
This commit is contained in:
127
apps/web/src/components/drinkkaart/QrCodeDisplay.tsx
Normal file
127
apps/web/src/components/drinkkaart/QrCodeDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user