diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx index 10a6ea1..74ebed8 100644 --- a/apps/web/src/components/Header.tsx +++ b/apps/web/src/components/Header.tsx @@ -1,13 +1,24 @@ import { Link, useNavigate } from "@tanstack/react-router"; -import { LogOut } from "lucide-react"; +import { LogOut, User } from "lucide-react"; import { authClient } from "@/lib/auth-client"; interface HeaderProps { userName?: string; isAdmin?: boolean; + /** When true, the user is not authenticated — show login/signup CTAs */ + isGuest?: boolean; + isVisible?: boolean; + /** When true, uses fixed positioning (for homepage scroll behavior) */ + isHomepage?: boolean; } -export function Header({ userName, isAdmin }: HeaderProps) { +export function Header({ + userName, + isAdmin, + isGuest, + isVisible, + isHomepage, +}: HeaderProps) { const navigate = useNavigate(); const handleSignOut = async () => { @@ -15,48 +26,78 @@ export function Header({ userName, isAdmin }: HeaderProps) { navigate({ to: "/" }); }; - if (!userName) return null; - return ( -
-
-
diff --git a/apps/web/src/components/homepage/Footer.tsx b/apps/web/src/components/homepage/Footer.tsx index 220278f..9411adb 100644 --- a/apps/web/src/components/homepage/Footer.tsx +++ b/apps/web/src/components/homepage/Footer.tsx @@ -31,21 +31,21 @@ export default function Footer() { Waar creativiteit tot leven komt

-
+
Privacy Beleid - | + | Algemene Voorwaarden - | + | {!isLoading && isAdmin && ( <> - | + | {/* Main Title */} -

+

Ongedesemd - + Brood?!

@@ -110,8 +116,16 @@ export default function Info() {
{/* Content Section */} -
-
+
+ {/* Background texture */} +
+ +
{/* Ongedesemd Brood Explanation - Full Width Special Treatment */}
diff --git a/apps/web/src/components/registration/PerformerForm.tsx b/apps/web/src/components/registration/PerformerForm.tsx index 78a1a79..9a6e556 100644 --- a/apps/web/src/components/registration/PerformerForm.tsx +++ b/apps/web/src/components/registration/PerformerForm.tsx @@ -21,14 +21,25 @@ interface PerformerErrors { interface Props { onBack: () => void; - onSuccess: (token: string) => void; + onSuccess: (token: string, email: string, name: string) => void; + prefillFirstName?: string; + prefillLastName?: string; + prefillEmail?: string; + isLoggedIn?: boolean; } -export function PerformerForm({ onBack, onSuccess }: Props) { +export function PerformerForm({ + onBack, + onSuccess, + prefillFirstName = "", + prefillLastName = "", + prefillEmail = "", + isLoggedIn = false, +}: Props) { const [data, setData] = useState({ - firstName: "", - lastName: "", - email: "", + firstName: prefillFirstName, + lastName: prefillLastName, + email: prefillEmail, phone: "", artForm: "", experience: "", @@ -42,7 +53,12 @@ export function PerformerForm({ onBack, onSuccess }: Props) { const submitMutation = useMutation({ ...orpc.submitRegistration.mutationOptions(), onSuccess: (result) => { - if (result.managementToken) onSuccess(result.managementToken); + if (result.managementToken) + onSuccess( + result.managementToken, + data.email.trim(), + `${data.firstName.trim()} ${data.lastName.trim()}`.trim(), + ); }, onError: (error) => { toast.error(`Er is iets misgegaan: ${error.message}`); @@ -249,14 +265,15 @@ export function PerformerForm({ onBack, onSuccess }: Props) { id="p-email" name="email" value={data.email} - onChange={handleChange} - onBlur={handleBlur} + onChange={isLoggedIn ? undefined : handleChange} + onBlur={isLoggedIn ? undefined : handleBlur} + readOnly={isLoggedIn} placeholder="jouw@email.be" autoComplete="email" inputMode="email" aria-required="true" aria-invalid={touched.email && !!errors.email} - className={inputCls(!!touched.email && !!errors.email)} + className={`${inputCls(!!touched.email && !!errors.email)}${isLoggedIn ? "cursor-not-allowed opacity-60" : ""}`} /> {touched.email && errors.email && ( diff --git a/apps/web/src/components/registration/SuccessScreen.tsx b/apps/web/src/components/registration/SuccessScreen.tsx index 4e64de8..b3c955f 100644 --- a/apps/web/src/components/registration/SuccessScreen.tsx +++ b/apps/web/src/components/registration/SuccessScreen.tsx @@ -2,10 +2,12 @@ import { useState } from "react"; interface Props { token: string; + email?: string; + name?: string; onReset: () => void; } -export function SuccessScreen({ token, onReset }: Props) { +export function SuccessScreen({ token, email, name, onReset }: Props) { const manageUrl = typeof window !== "undefined" ? `${window.location.origin}/manage/${token}` @@ -33,6 +35,11 @@ export function SuccessScreen({ token, onReset }: Props) { setDrinkkaartPromptDismissed(true); }; + // Build signup URL with pre-filled email query param so /login can pre-fill + const signupUrl = email + ? `/login?signup=1&email=${encodeURIComponent(email)}` + : "/login?signup=1"; + return (
- {/* Drinkkaart account prompt */} + {/* Account creation prompt */} {!drinkkaartPromptDismissed && ( -
-

- Wil je een Drinkkaart? +
+

+ Maak een gratis account aan

- Maak een gratis account aan om je digitale Drinkkaart te - activeren en saldo op te laden vóór het evenement. + Met een account zie je je inschrijving, activeer je je + Drinkkaart en laad je saldo op vóór het evenement.

-
+
    +
  • + + Beheer je inschrijving op één plek +
  • +
  • + + Digitale Drinkkaart met QR-code +
  • +
  • + + Saldo opladen vóór het evenement +
  • +
+
Account aanmaken + {name && ( + + als {name} + + )} diff --git a/apps/web/src/components/registration/WatcherForm.tsx b/apps/web/src/components/registration/WatcherForm.tsx index 9487895..60e9639 100644 --- a/apps/web/src/components/registration/WatcherForm.tsx +++ b/apps/web/src/components/registration/WatcherForm.tsx @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; import { calculateDrinkCard, type GuestEntry, @@ -24,13 +25,231 @@ interface WatcherErrors { interface Props { onBack: () => void; + prefillFirstName?: string; + prefillLastName?: string; + prefillEmail?: string; + isLoggedIn?: boolean; } -export function WatcherForm({ onBack }: Props) { +// ── Account creation modal shown after successful registration ───────────── + +interface AccountModalProps { + prefillName: string; + prefillEmail: string; + onDone: () => void; +} + +function AccountModal({ + prefillName, + prefillEmail, + onDone, +}: AccountModalProps) { + const [name, setName] = useState(prefillName); + const [email] = useState(prefillEmail); // email is fixed (tied to registration) + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordError, setPasswordError] = useState(); + + const signupMutation = useMutation({ + mutationFn: async () => { + if (password.length < 8) + throw new Error("Wachtwoord moet minstens 8 tekens zijn"); + if (password !== confirmPassword) + throw new Error("Wachtwoorden komen niet overeen"); + const result = await authClient.signUp.email({ email, password, name }); + if (result.error) throw new Error(result.error.message); + return result.data; + }, + onSuccess: () => { + toast.success( + "Account aangemaakt! Je wordt nu doorgestuurd naar betaling.", + ); + onDone(); + }, + onError: (error) => { + toast.error(`Account aanmaken mislukt: ${error.message}`); + }, + }); + + const handlePasswordChange = (val: string) => { + setPassword(val); + if (passwordError) setPasswordError(undefined); + }; + + const handleConfirmChange = (val: string) => { + setConfirmPassword(val); + if (passwordError) setPasswordError(undefined); + }; + + const handleSignup = (e: React.FormEvent) => { + e.preventDefault(); + if (password.length < 8) { + setPasswordError("Wachtwoord moet minstens 8 tekens zijn"); + return; + } + if (password !== confirmPassword) { + setPasswordError("Wachtwoorden komen niet overeen"); + return; + } + signupMutation.mutate(); + }; + + // Receive the checkout URL from parent by exposing a setter via ref pattern + // (simpler: the parent renders the modal and passes a prop) + + return ( +
+
+ {/* Header */} +
+
+ ✓ +
+
+

+ Inschrijving gelukt! +

+

+ Maak een gratis account aan om je Drinkkaart bij te houden. +

+
+
+ + {/* Value prop */} +
+
    +
  • + + Zie je drinkkaart-saldo en QR-code +
  • +
  • + + Laad je kaart op vóór het evenement +
  • +
  • + + Beheer je inschrijving op één plek +
  • +
+
+ + {/* Signup form */} +
+ {/* Name */} +
+ + setName(e.target.value)} + required + className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none" + /> +
+ + {/* Email (read-only) */} +
+ + +
+ + {/* Password */} +
+ + handlePasswordChange(e.target.value)} + required + minLength={8} + className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none" + /> +
+ + {/* Confirm password */} +
+ + handleConfirmChange(e.target.value)} + required + className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none" + /> +
+ + {passwordError && ( +

{passwordError}

+ )} + + +
+ + {/* Skip */} + +
+
+ ); +} + +// ── Main watcher form ────────────────────────────────────────────────────── + +export function WatcherForm({ + onBack, + prefillFirstName = "", + prefillLastName = "", + prefillEmail = "", + isLoggedIn = false, +}: Props) { const [data, setData] = useState({ - firstName: "", - lastName: "", - email: "", + firstName: prefillFirstName, + lastName: prefillLastName, + email: prefillEmail, phone: "", extraQuestions: "", }); @@ -40,16 +259,37 @@ export function WatcherForm({ onBack }: Props) { const [guestErrors, setGuestErrors] = useState([]); const [giftAmount, setGiftAmount] = useState(0); + // Modal state: shown after successful registration while we fetch checkout URL + const [modalState, setModalState] = useState<{ + prefillName: string; + prefillEmail: string; + checkoutUrl: string; + } | null>(null); + const submitMutation = useMutation({ ...orpc.submitRegistration.mutationOptions(), onSuccess: async (result) => { if (!result.managementToken) return; - // Redirect to Lemon Squeezy checkout immediately after registration try { + const redirectUrl = isLoggedIn + ? `${window.location.origin}/account?topup=success` + : undefined; const checkout = await client.getCheckoutUrl({ token: result.managementToken, + redirectUrl, }); - window.location.href = checkout.checkoutUrl; + if (isLoggedIn) { + // Already logged in — skip the account-creation modal, go straight to checkout + window.location.href = checkout.checkoutUrl; + } else { + // Show the account-creation modal before redirecting to checkout + setModalState({ + prefillName: + `${data.firstName.trim()} ${data.lastName.trim()}`.trim(), + prefillEmail: data.email.trim(), + checkoutUrl: checkout.checkoutUrl, + }); + } } catch (error) { console.error("Checkout error:", error); toast.error("Er is iets misgegaan bij het aanmaken van de betaling"); @@ -162,6 +402,19 @@ export function WatcherForm({ onBack }: Props) { return (
+ {/* Account-creation interstitial modal */} + {modalState && ( + { + const url = modalState.checkoutUrl; + setModalState(null); + window.location.href = url; + }} + /> + )} + {/* Back + type header */}
+ +
+
+ + {/* QR code */} + {showQr && ( +
+ +
+ )} + + {/* Top-up history */} +
+

+ Opladingen +

+ {drinkkaart ? ( + [0]["topups"] + } + /> + ) : ( +

Laden...

+ )} +
+ + {/* Transaction history */} +
+

+ Transacties +

+ {drinkkaart ? ( + [0]["transactions"] + } + /> + ) : ( +

Laden...

+ )} +
+ +
+ + + {showTopUp && setShowTopUp(false)} />} +
+ ); +} diff --git a/apps/web/src/routes/admin/drinkkaart.tsx b/apps/web/src/routes/admin/drinkkaart.tsx index 3f731f1..b5ae10e 100644 --- a/apps/web/src/routes/admin/drinkkaart.tsx +++ b/apps/web/src/routes/admin/drinkkaart.tsx @@ -98,7 +98,7 @@ function AdminDrinkkaartPage() { }; return ( -
+
{/* Header */}
diff --git a/apps/web/src/routes/admin/index.tsx b/apps/web/src/routes/admin/index.tsx index 3a4a7c0..3bd1c37 100644 --- a/apps/web/src/routes/admin/index.tsx +++ b/apps/web/src/routes/admin/index.tsx @@ -1,10 +1,5 @@ import { useMutation, useQuery } from "@tanstack/react-query"; -import { - createFileRoute, - Link, - redirect, - useNavigate, -} from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { Check, ChevronDown, @@ -34,16 +29,6 @@ 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() { @@ -60,6 +45,12 @@ function AdminPage() { const [copiedId, setCopiedId] = useState(null); + // Get current session to check user role + const sessionQuery = useQuery({ + queryKey: ["session"], + queryFn: () => authClient.getSession(), + }); + const handleCopyManageUrl = (token: string, id: string) => { const url = `${window.location.origin}/manage/${token}`; navigator.clipboard.writeText(url).then(() => { @@ -146,6 +137,16 @@ function AdminPage() { }, }); + const requestAdminMutation = useMutation({ + ...orpc.requestAdminAccess.mutationOptions(), + onSuccess: () => { + toast.success("Admin toegang aangevraagd!"); + }, + onError: (error) => { + toast.error(`Aanvraag mislukt: ${error.message}`); + }, + }); + const handleSignOut = async () => { await authClient.signOut(); navigate({ to: "/" }); @@ -163,6 +164,10 @@ function AdminPage() { rejectRequestMutation.mutate({ requestId }); }; + const handleRequestAdmin = () => { + requestAdminMutation.mutate(undefined); + }; + const stats = statsQuery.data; const registrations = registrationsQuery.data?.data ?? []; const pagination = registrationsQuery.data?.pagination; @@ -273,8 +278,119 @@ function AdminPage() { return `€${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; }; + // Check if user is admin + const user = sessionQuery.data?.data?.user as + | { role?: string; name?: string } + | undefined; + const isAdmin = user?.role === "admin"; + const isAuthenticated = !!sessionQuery.data?.data?.user; + + // Show loading state while checking session + if (sessionQuery.isLoading) { + return ( +
+

Laden...

+
+ ); + } + + // Show login prompt for non-authenticated users + if (!isAuthenticated) { + return ( +
+ + + + Inloggen Vereist + + + Je moet ingelogd zijn om admin toegang aan te vragen + + + +
+ + + + ← Terug naar website + +
+
+
+
+ ); + } + + // Show request admin UI for non-admin users + if (!isAdmin) { + return ( +
+ + + + Admin Toegang Vereist + + + Je hebt geen admin rechten voor dit dashboard + + + +
+
+

+ Admin-toegang aanvragen? +

+ +
+ + + + + + + ← Terug naar website + +
+
+
+
+ ); + } + return ( -
+
{/* Header */}
diff --git a/apps/web/src/routes/api/webhook/lemonsqueezy.ts b/apps/web/src/routes/api/webhook/lemonsqueezy.ts index e028658..b9ce13d 100644 --- a/apps/web/src/routes/api/webhook/lemonsqueezy.ts +++ b/apps/web/src/routes/api/webhook/lemonsqueezy.ts @@ -1,6 +1,8 @@ import { createHmac, randomUUID } from "node:crypto"; +import { creditRegistrationToAccount } from "@kk/api/routers/index"; import { db } from "@kk/db"; import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema"; +import { user } from "@kk/db/schema/auth"; import { env } from "@kk/env/server"; import { createFileRoute } from "@tanstack/react-router"; import { and, eq } from "drizzle-orm"; @@ -176,9 +178,21 @@ async function handleWebhook({ request }: { request: Request }) { const orderId = event.data.id; const customerId = String(event.data.attributes.customer_id); - // Update registration in database. - // Covers both "pending" (initial payment) and "extra_payment_pending" - // (delta payment after adding guests to an already-paid registration). + // Fetch the registration row first so we can use its email + drinkCardValue. + const regRow = await db + .select() + .from(registration) + .where(eq(registration.managementToken, registrationToken)) + .limit(1) + .then((r) => r[0]); + + if (!regRow) { + console.error(`Registration not found for token ${registrationToken}`); + return new Response("Registration not found", { status: 404 }); + } + + // Mark the registration as paid. Covers both "pending" (initial payment) and + // "extra_payment_pending" (delta after adding guests). await db .update(registration) .set({ @@ -194,6 +208,50 @@ async function handleWebhook({ request }: { request: Request }) { `Payment successful for registration ${registrationToken}, order ${orderId}`, ); + // If this is a watcher with a drink card value, try to credit their drinkkaart + // immediately — but only if they already have an account. + if ( + regRow.registrationType === "watcher" && + (regRow.drinkCardValue ?? 0) > 0 + ) { + // Look up user account by email + const accountUser = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, regRow.email)) + .limit(1) + .then((r) => r[0]); + + if (accountUser) { + const creditResult = await creditRegistrationToAccount( + regRow.email, + accountUser.id, + ).catch((err) => { + console.error( + `Failed to credit drinkkaart for ${regRow.email} after registration payment:`, + err, + ); + return null; + }); + + if (creditResult?.credited) { + console.log( + `Drinkkaart credited ${creditResult.amountCents}c for user ${accountUser.id} (registration ${registrationToken})`, + ); + } else if (creditResult) { + console.log( + `Drinkkaart credit skipped for ${regRow.email}: ${creditResult.status}`, + ); + } + } else { + // No account yet — credit will be applied when the user signs up via + // claimRegistrationCredit. + console.log( + `No account for ${regRow.email} — drinkkaart credit deferred until signup`, + ); + } + } + return new Response("OK", { status: 200 }); } catch (error) { console.error("Webhook processing error:", error); diff --git a/apps/web/src/routes/contact.tsx b/apps/web/src/routes/contact.tsx index 086255f..3856724 100644 --- a/apps/web/src/routes/contact.tsx +++ b/apps/web/src/routes/contact.tsx @@ -6,8 +6,8 @@ export const Route = createFileRoute("/contact")({ function ContactPage() { return ( -
-
+
+
) => ({ topup: search.topup as string | undefined, }), - component: DrinkkaartPage, - beforeLoad: async () => { + beforeLoad: async ({ search }) => { const session = await authClient.getSession(); if (!session.data?.user) { throw redirect({ to: "/login" }); } + throw redirect({ + to: "/account", + search: search.topup ? { topup: search.topup } : {}, + }); }, + component: () => null, }); - -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 ( -
-
-
- ); - } - - return ( -
-
-

- Mijn Drinkkaart -

- - {/* Balance card */} -
-

Huidig saldo

-

- {data ? formatCents(data.balance) : "—"} -

- -
- - -
-
- - {/* QR code */} - {showQr && ( -
- -
- )} - - {/* Top-up history */} -
-

- Opladingen -

- {data ? ( - [0]["topups"] - } - /> - ) : ( -

Laden...

- )} -
- - {/* Transaction history */} -
-

- Transacties -

- {data ? ( - [0]["transactions"] - } - /> - ) : ( -

Laden...

- )} -
-
- - {showTopUp && setShowTopUp(false)} />} -
- ); -} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 7564077..7b0a2a4 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -15,13 +15,24 @@ import { authClient } from "@/lib/auth-client"; import { orpc } from "@/utils/orpc"; export const Route = createFileRoute("/login")({ + validateSearch: (search: Record) => { + const signup = + search.signup === "1" || search.signup === "true" ? "1" : undefined; + const email = typeof search.email === "string" ? search.email : undefined; + // Only return defined keys so redirects without search still typecheck + return { + ...(signup !== undefined && { signup }), + ...(email !== undefined && { email }), + } as { signup?: "1"; email?: string }; + }, component: LoginPage, }); function LoginPage() { const navigate = useNavigate(); - const [isSignup, setIsSignup] = useState(false); - const [email, setEmail] = useState(""); + const search = Route.useSearch(); + const [isSignup, setIsSignup] = useState(() => search.signup === "1"); + const [email, setEmail] = useState(() => search.email ?? ""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); @@ -49,13 +60,12 @@ function LoginPage() { }, onSuccess: () => { toast.success("Succesvol ingelogd!"); - // Check role and redirect authClient.getSession().then((session) => { const user = session.data?.user as { role?: string } | undefined; if (user?.role === "admin") { navigate({ to: "/admin" }); } else { - navigate({ to: "/" }); + navigate({ to: "/account" }); } }); }, @@ -85,11 +95,7 @@ function LoginPage() { return result.data; }, onSuccess: () => { - toast.success("Account aangemaakt! Wacht op goedkeuring van een admin."); - setIsSignup(false); - setName(""); - setEmail(""); - setPassword(""); + navigate({ to: "/account", search: { welkom: "1" } }); }, onError: (error) => { toast.error(`Registratie mislukt: ${error.message}`); @@ -119,13 +125,15 @@ function LoginPage() { requestAdminMutation.mutate(undefined); }; - // If already logged in as admin, redirect + // If already logged in, redirect to appropriate page if (sessionQuery.data?.data?.user) { const user = sessionQuery.data.data.user as { role?: string }; if (user.role === "admin") { navigate({ to: "/admin" }); - return null; + } else { + navigate({ to: "/account" }); } + return null; } const isLoggedIn = !!sessionQuery.data?.data?.user; @@ -134,51 +142,64 @@ function LoginPage() { | undefined; return ( -
- +
+ {isLoggedIn - ? "Admin Toegang" + ? `Welkom, ${user?.name}` : isSignup ? "Account Aanmaken" : "Inloggen"} {isLoggedIn - ? `Welkom, ${user?.name}` + ? "Je bent al ingelogd" : isSignup - ? "Maak een account aan om toegang te krijgen" - : "Log in om toegang te krijgen tot het admin dashboard"} + ? "Maak een gratis account aan voor je Drinkkaart en inschrijving" + : "Log in om je account te bekijken"} {isLoggedIn ? (
-
-

- Je bent ingelogd maar hebt geen admin toegang. -

-
+ {/* Primary CTA: go to account */} + + {/* Secondary: request admin access */} +
+

+ Admin-toegang aanvragen? +

+ +
+ ← Terug naar website diff --git a/apps/web/src/routes/manage.$token.tsx b/apps/web/src/routes/manage.$token.tsx index 015919a..4c98809 100644 --- a/apps/web/src/routes/manage.$token.tsx +++ b/apps/web/src/routes/manage.$token.tsx @@ -22,8 +22,8 @@ export const Route = createFileRoute("/manage/$token")({ function PageShell({ children }: { children: React.ReactNode }) { return ( -
-
{children}
+
+
{children}
); } @@ -31,10 +31,10 @@ function PageShell({ children }: { children: React.ReactNode }) { function BackLink() { return ( - ← Terug naar home + ← Terug naar account ); } @@ -427,7 +427,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) { onChange={(e) => setFormData((p) => ({ ...p, extraQuestions: e.target.value })) } - className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:outline-none" + className="w-full border border-white/10 resize-none bg-transparent p-2 text-lg text-white placeholder:text-white/40 focus:outline-none" />
diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx index ac5d834..a0be529 100644 --- a/apps/web/src/routes/privacy.tsx +++ b/apps/web/src/routes/privacy.tsx @@ -6,8 +6,8 @@ export const Route = createFileRoute("/privacy")({ function PrivacyPage() { return ( -
-
+
+
-
+
+
{ + // Find the most recent paid watcher registration for this email that hasn't + // been credited yet and has a non-zero drink card value. + const rows = await db + .select() + .from(registration) + .where( + and( + eq(registration.email, email), + eq(registration.registrationType, "watcher"), + eq(registration.paymentStatus, "paid"), + isNull(registration.cancelledAt), + isNull(registration.drinkkaartCreditedAt), + ), + ) + .orderBy(desc(registration.createdAt)) + .limit(1); + + const reg = rows[0]; + + if (!reg) { + // Either no paid registration exists, or it was already credited. + // Distinguish between the two for better logging. + const credited = await db + .select({ id: registration.id }) + .from(registration) + .where( + and( + eq(registration.email, email), + eq(registration.registrationType, "watcher"), + eq(registration.paymentStatus, "paid"), + isNull(registration.cancelledAt), + ), + ) + .limit(1) + .then((r) => r[0]); + + return { + credited: false, + amountCents: 0, + status: credited ? "already_credited" : "no_paid_registration", + }; + } + + const drinkCardValueEuros = reg.drinkCardValue ?? 0; + if (drinkCardValueEuros === 0) { + return { credited: false, amountCents: 0, status: "zero_value" }; + } + + const amountCents = drinkCardValueEuros * 100; + + // Get or create the drinkkaart for this user. + let card = await db + .select() + .from(drinkkaart) + .where(eq(drinkkaart.userId, userId)) + .limit(1) + .then((r) => r[0]); + + if (!card) { + const now = new Date(); + card = { + id: randomUUID(), + userId, + balance: 0, + version: 0, + qrSecret: generateQrSecret(), + createdAt: now, + updatedAt: now, + }; + await db.insert(drinkkaart).values(card); + } + + const balanceBefore = card.balance; + const balanceAfter = balanceBefore + amountCents; + + // Optimistic-lock update — if concurrent, the second caller will see + // drinkkaartCreditedAt IS NOT NULL and return "already_credited" above. + const updateResult = await db + .update(drinkkaart) + .set({ + balance: balanceAfter, + version: card.version + 1, + updatedAt: new Date(), + }) + .where( + and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)), + ); + + if (updateResult.rowsAffected === 0) { + // Lost the optimistic lock race — the other caller will handle it. + return { credited: false, amountCents: 0, status: "already_credited" }; + } + + // Record the topup. Re-use the registration's lemonsqueezyOrderId so the + // topup row is clearly linked to the original payment. We prefix with + // "reg_" to distinguish from direct drinkkaart top-up orders if needed. + const topupOrderId = reg.lemonsqueezyOrderId + ? `reg_${reg.lemonsqueezyOrderId}` + : null; + + await db.insert(drinkkaartTopup).values({ + id: randomUUID(), + drinkkaartId: card.id, + userId, + amountCents, + balanceBefore, + balanceAfter, + type: "payment", + lemonsqueezyOrderId: topupOrderId, + lemonsqueezyCustomerId: reg.lemonsqueezyCustomerId ?? null, + adminId: null, + reason: "Drinkkaart bij registratie", + paidAt: reg.paidAt ?? new Date(), + }); + + // Mark the registration as credited — prevents any future double-credit. + await db + .update(registration) + .set({ drinkkaartCreditedAt: new Date() }) + .where(eq(registration.id, reg.id)); + + return { credited: true, amountCents, status: "credited" }; +} + /** Fetches a single registration by management token, throws if not found or cancelled. */ async function getActiveRegistration(token: string) { const rows = await db @@ -425,6 +572,39 @@ export const appRouter = { }; }), + // --------------------------------------------------------------------------- + // User account + // --------------------------------------------------------------------------- + + claimRegistrationCredit: protectedProcedure.handler(async ({ context }) => { + const result = await creditRegistrationToAccount( + context.session.user.email, + context.session.user.id, + ); + return result; + }), + + getMyRegistration: protectedProcedure.handler(async ({ context }) => { + const email = context.session.user.email; + + const rows = await db + .select() + .from(registration) + .where( + and(eq(registration.email, email), isNull(registration.cancelledAt)), + ) + .orderBy(desc(registration.createdAt)) + .limit(1); + + const row = rows[0]; + if (!row) return null; + + return { + ...row, + guests: parseGuestsJson(row.guests), + }; + }), + // --------------------------------------------------------------------------- // Admin access requests // --------------------------------------------------------------------------- @@ -555,7 +735,12 @@ export const appRouter = { // --------------------------------------------------------------------------- getCheckoutUrl: publicProcedure - .input(z.object({ token: z.string().uuid() })) + .input( + z.object({ + token: z.string().uuid(), + redirectUrl: z.string().optional(), + }), + ) .handler(async ({ input }) => { if ( !env.LEMON_SQUEEZY_API_KEY || @@ -629,7 +814,9 @@ export const appRouter = { product_options: { name: "Kunstenkamp Evenement", description: productDescription, - redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`, + redirect_url: + input.redirectUrl ?? + `${env.CORS_ORIGIN}/manage/${input.token}`, }, checkout_data: { email: row.email, diff --git a/packages/db/src/migrations/0005_registration_drinkkaart_credit.sql b/packages/db/src/migrations/0005_registration_drinkkaart_credit.sql new file mode 100644 index 0000000..9b47a35 --- /dev/null +++ b/packages/db/src/migrations/0005_registration_drinkkaart_credit.sql @@ -0,0 +1,2 @@ +-- Migration: Track when a watcher's registration fee has been credited to their drinkkaart +ALTER TABLE `registration` ADD COLUMN `drinkkaart_credited_at` integer; diff --git a/packages/db/src/schema/registrations.ts b/packages/db/src/schema/registrations.ts index f9d9ff2..45d5df7 100644 --- a/packages/db/src/schema/registrations.ts +++ b/packages/db/src/schema/registrations.ts @@ -32,6 +32,12 @@ export const registration = sqliteTable( lemonsqueezyOrderId: text("lemonsqueezy_order_id"), lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"), paidAt: integer("paid_at", { mode: "timestamp_ms" }), + // Set when the drinkCardValue has been credited to the user's drinkkaart. + // Null means not yet credited (either unpaid, account doesn't exist yet, or + // the registration is a performer). Used to prevent double-crediting. + drinkkaartCreditedAt: integer("drinkkaart_credited_at", { + mode: "timestamp_ms", + }), createdAt: integer("created_at", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(),