diff --git a/apps/web/src/components/homepage/EventRegistrationForm.tsx b/apps/web/src/components/homepage/EventRegistrationForm.tsx index 9ca20b0..c590b70 100644 --- a/apps/web/src/components/homepage/EventRegistrationForm.tsx +++ b/apps/web/src/components/homepage/EventRegistrationForm.tsx @@ -1,12 +1,9 @@ -import { Link } from "@tanstack/react-router"; import confetti from "canvas-confetti"; import { useEffect, useRef, useState } from "react"; import { CountdownBanner } from "@/components/homepage/CountdownBanner"; import { PerformerForm } from "@/components/registration/PerformerForm"; -import { SuccessScreen } from "@/components/registration/SuccessScreen"; import { TypeSelector } from "@/components/registration/TypeSelector"; import { WatcherForm } from "@/components/registration/WatcherForm"; -import { authClient } from "@/lib/auth-client"; import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; function fireConfetti() { @@ -38,14 +35,16 @@ function fireConfetti() { type RegistrationType = "performer" | "watcher"; -interface SuccessState { - token: string; - email: string; - name: string; +function redirectToSignup(email: string) { + const params = new URLSearchParams({ + signup: "1", + email, + next: "/account", + }); + window.location.href = `/login?${params.toString()}`; } export default function EventRegistrationForm() { - const { data: session } = authClient.useSession(); const { isOpen } = useRegistrationOpen(); const confettiFired = useRef(false); @@ -59,30 +58,6 @@ export default function EventRegistrationForm() { const [selectedType, setSelectedType] = useState( null, ); - const [successState, setSuccessState] = useState(null); - - const isLoggedIn = !!session?.user; - const user = session?.user as { name?: string; email?: string } | undefined; - - // Split "Jan De Smet" → firstName="Jan", lastName="De Smet" - const nameParts = (user?.name ?? "").trim().split(/\s+/); - const prefillFirstName = nameParts[0] ?? ""; - const prefillLastName = nameParts.slice(1).join(" "); - const prefillEmail = user?.email ?? ""; - - if (successState) { - return ( - { - setSuccessState(null); - setSelectedType(null); - }} - /> - ); - } if (!isOpen) { return ( @@ -113,54 +88,17 @@ export default function EventRegistrationForm() { Doe je mee of kom je kijken? Kies je rol en vul het formulier in.

- {/* Login nudge — shown only to guests */} - {!isLoggedIn && ( -
-

- - Al een account? - - Log in om je gegevens automatisch in te vullen en je Drinkkaart - direct te activeren na registratie. -

-
- - Inloggen - - · - - Nog geen account? Aanmaken - -
-
- )} {!selectedType && } {selectedType === "performer" && ( setSelectedType(null)} - onSuccess={(token, email, name) => - setSuccessState({ token, email, name }) - } - prefillFirstName={prefillFirstName} - prefillLastName={prefillLastName} - prefillEmail={prefillEmail} - isLoggedIn={isLoggedIn} + onSuccess={(_token, email, _name) => redirectToSignup(email)} /> )} {selectedType === "watcher" && ( setSelectedType(null)} - prefillFirstName={prefillFirstName} - prefillLastName={prefillLastName} - prefillEmail={prefillEmail} - isLoggedIn={isLoggedIn} + onSuccess={(_token, email, _name) => redirectToSignup(email)} /> )} diff --git a/apps/web/src/components/registration/PerformerForm.tsx b/apps/web/src/components/registration/PerformerForm.tsx index 9a6e556..f323215 100644 --- a/apps/web/src/components/registration/PerformerForm.tsx +++ b/apps/web/src/components/registration/PerformerForm.tsx @@ -22,24 +22,13 @@ interface PerformerErrors { interface Props { onBack: () => void; onSuccess: (token: string, email: string, name: string) => void; - prefillFirstName?: string; - prefillLastName?: string; - prefillEmail?: string; - isLoggedIn?: boolean; } -export function PerformerForm({ - onBack, - onSuccess, - prefillFirstName = "", - prefillLastName = "", - prefillEmail = "", - isLoggedIn = false, -}: Props) { +export function PerformerForm({ onBack, onSuccess }: Props) { const [data, setData] = useState({ - firstName: prefillFirstName, - lastName: prefillLastName, - email: prefillEmail, + firstName: "", + lastName: "", + email: "", phone: "", artForm: "", experience: "", @@ -265,15 +254,14 @@ export function PerformerForm({ id="p-email" name="email" value={data.email} - onChange={isLoggedIn ? undefined : handleChange} - onBlur={isLoggedIn ? undefined : handleBlur} - readOnly={isLoggedIn} + onChange={handleChange} + onBlur={handleBlur} placeholder="jouw@email.be" autoComplete="email" inputMode="email" aria-required="true" aria-invalid={touched.email && !!errors.email} - className={`${inputCls(!!touched.email && !!errors.email)}${isLoggedIn ? "cursor-not-allowed opacity-60" : ""}`} + className={inputCls(!!touched.email && !!errors.email)} /> {touched.email && errors.email && ( diff --git a/apps/web/src/components/registration/SuccessScreen.tsx b/apps/web/src/components/registration/SuccessScreen.tsx deleted file mode 100644 index b3c955f..0000000 --- a/apps/web/src/components/registration/SuccessScreen.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useState } from "react"; - -interface Props { - token: string; - email?: string; - name?: string; - onReset: () => void; -} - -export function SuccessScreen({ token, email, name, onReset }: Props) { - const manageUrl = - typeof window !== "undefined" - ? `${window.location.origin}/manage/${token}` - : `/manage/${token}`; - - const [drinkkaartPromptDismissed, setDrinkkaartPromptDismissed] = useState( - () => { - try { - return ( - typeof localStorage !== "undefined" && - localStorage.getItem("drinkkaart_prompt_dismissed") === "1" - ); - } catch { - return false; - } - }, - ); - - const handleDismissPrompt = () => { - try { - localStorage.setItem("drinkkaart_prompt_dismissed", "1"); - } catch { - // ignore - } - 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 ( -
-
-
-
- - - -
-

- Gelukt! -

-

- Je inschrijving is bevestigd. We sturen je zo dadelijk een - bevestigingsmail. -

-
-

- Geen mail ontvangen? Gebruik deze link: -

- - {manageUrl} - -
-
- - Bekijk mijn inschrijving - - -
- - {/* Account creation prompt */} - {!drinkkaartPromptDismissed && ( -
-

- Maak een gratis account aan -

-

- 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 -
  • -
- -
- )} -
-
-
- ); -} diff --git a/apps/web/src/components/registration/WatcherForm.tsx b/apps/web/src/components/registration/WatcherForm.tsx index 254c786..ae18be7 100644 --- a/apps/web/src/components/registration/WatcherForm.tsx +++ b/apps/web/src/components/registration/WatcherForm.tsx @@ -1,7 +1,6 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; -import { authClient } from "@/lib/auth-client"; import { calculateDrinkCard, type GuestEntry, @@ -12,8 +11,7 @@ import { validatePhone, validateTextField, } from "@/lib/registration"; -import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; -import { client, orpc } from "@/utils/orpc"; +import { orpc } from "@/utils/orpc"; import { GiftSelector } from "./GiftSelector"; import { GuestList } from "./GuestList"; @@ -26,236 +24,16 @@ interface WatcherErrors { interface Props { onBack: () => void; - prefillFirstName?: string; - prefillLastName?: string; - prefillEmail?: string; - isLoggedIn?: boolean; -} - -// ── 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 { isOpen } = useRegistrationOpen(); - - 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 (!isOpen) { - toast.error("Registratie is nog niet open"); - return; - } - 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 */} - -
-
- ); + onSuccess: (token: string, email: string, name: string) => void; } // ── Main watcher form ────────────────────────────────────────────────────── -export function WatcherForm({ - onBack, - prefillFirstName = "", - prefillLastName = "", - prefillEmail = "", - isLoggedIn = false, -}: Props) { +export function WatcherForm({ onBack, onSuccess }: Props) { const [data, setData] = useState({ - firstName: prefillFirstName, - lastName: prefillLastName, - email: prefillEmail, + firstName: "", + lastName: "", + email: "", phone: "", extraQuestions: "", }); @@ -265,40 +43,15 @@ export function WatcherForm({ 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; - try { - const redirectUrl = isLoggedIn - ? `${window.location.origin}/account?topup=success` - : undefined; - const checkout = await client.getCheckoutUrl({ - token: result.managementToken, - redirectUrl, - }); - 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"); + onSuccess: (result) => { + if (result.managementToken) { + onSuccess( + result.managementToken, + data.email.trim(), + `${data.firstName.trim()} ${data.lastName.trim()}`.trim(), + ); } }, onError: (error) => { @@ -408,19 +161,6 @@ export function WatcherForm({ return (
- {/* Account-creation interstitial modal */} - {modalState && ( - { - const url = modalState.checkoutUrl; - setModalState(null); - window.location.href = url; - }} - /> - )} - {/* Back + type header */} )} + + {/* Pay now CTA — shown for watchers with pending payment */} + {registration.registrationType === "watcher" && + registration.paymentStatus === "pending" && + registration.managementToken && ( +
+

+ Je inschrijving is bevestigd maar de betaling staat nog + open. Betaal nu om je Drinkkaart te activeren. +

+ +
+ )}
) : (
diff --git a/apps/web/src/routes/api/webhook/mollie.ts b/apps/web/src/routes/api/webhook/mollie.ts index 2054e09..198f227 100644 --- a/apps/web/src/routes/api/webhook/mollie.ts +++ b/apps/web/src/routes/api/webhook/mollie.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { sendPaymentConfirmationEmail } from "@kk/api/email"; import { creditRegistrationToAccount } from "@kk/api/routers/index"; import { db } from "@kk/db"; import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema"; @@ -206,6 +207,20 @@ async function handleWebhook({ request }: { request: Request }) { `Payment successful for registration ${registrationToken}, payment ${payment.id}`, ); + // Send payment confirmation email (fire-and-forget; don't block webhook) + sendPaymentConfirmationEmail({ + to: regRow.email, + firstName: regRow.firstName, + managementToken: regRow.managementToken!, + drinkCardValue: regRow.drinkCardValue ?? undefined, + giftAmount: regRow.giftAmount ?? undefined, + }).catch((err) => + console.error( + `Failed to send payment confirmation email for ${regRow.email}:`, + err, + ), + ); + // 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 ( diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts index b95b2ea..addce7b 100644 --- a/packages/api/src/email.ts +++ b/packages/api/src/email.ts @@ -30,6 +30,7 @@ const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be"; function registrationConfirmationHtml(params: { firstName: string; manageUrl: string; + signupUrl: string; wantsToPerform: boolean; artForm?: string | null; giftAmount?: number; @@ -99,13 +100,26 @@ function registrationConfirmationHtml(params: { ${paymentSummary}

- Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet. + Maak een account aan om je inschrijving te beheren en (voor bezoekers) je Drinkkaart te activeren.

- - + +
+ +
- + + Account aanmaken + +
+

+ Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet. +

+ + + + @@ -280,6 +294,8 @@ export async function sendConfirmationEmail(params: { artForm?: string | null; giftAmount?: number; drinkCardValue?: number; + /** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */ + signupUrl?: string; }) { const transport = getTransport(); if (!transport) { @@ -287,6 +303,9 @@ export async function sendConfirmationEmail(params: { return; } const manageUrl = `${baseUrl}/manage/${params.managementToken}`; + const signupUrl = + params.signupUrl ?? + `${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`; await transport.sendMail({ from, to: params.to, @@ -294,6 +313,7 @@ export async function sendConfirmationEmail(params: { html: registrationConfirmationHtml({ firstName: params.firstName, manageUrl, + signupUrl, wantsToPerform: params.wantsToPerform, artForm: params.artForm, giftAmount: params.giftAmount, @@ -444,3 +464,220 @@ export async function sendReminderEmail(params: { html: reminderHtml({ firstName: params.firstName }), }); } + +function paymentReminderHtml(params: { + firstName: string; + accountUrl: string; + manageUrl: string; + drinkCardValue?: number; + giftAmount?: number; +}) { + const formatEuro = (cents: number) => + `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; + + const giftCents = params.giftAmount ?? 0; + const drinkCardCents = (params.drinkCardValue ?? 0) * 100; + const totalCents = drinkCardCents + giftCents; + const totalStr = totalCents > 0 ? formatEuro(totalCents) : ""; + + const amountLine = totalStr + ? `

+ Je betaling van ${totalStr} staat nog open. Log in op je account om te betalen. +

` + : `

+ Je betaling staat nog open. Log in op je account om te betalen. +

`; + + return ` + + + + + Herinnering: betaling open + + +
+ Beheer mijn inschrijving
+ + + +
+ + + + + + + + + + +
+

Kunstenkamp

+

Betaling nog open

+
+

+ Hoi ${params.firstName}, +

+

+ Je hebt je ingeschreven voor Open Mic Night — vrijdag 18 april 2026, maar we hebben nog geen betaling ontvangen. +

+ ${amountLine} + + + + + +
+ + Betaal nu + +
+

+ Je inschrijving beheren? Klik hier +

+
+

+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp vzw — Een initiatief voor en door kunstenaars. +

+
+
+ +`; +} + +export async function sendPaymentReminderEmail(params: { + to: string; + firstName: string; + managementToken: string; + drinkCardValue?: number; + giftAmount?: number; +}) { + const transport = getTransport(); + if (!transport) { + console.warn("SMTP not configured — skipping payment reminder email"); + return; + } + const accountUrl = `${baseUrl}/account`; + const manageUrl = `${baseUrl}/manage/${params.managementToken}`; + await transport.sendMail({ + from, + to: params.to, + subject: "Herinnering: betaling open — Open Mic Night", + html: paymentReminderHtml({ + firstName: params.firstName, + accountUrl, + manageUrl, + drinkCardValue: params.drinkCardValue, + giftAmount: params.giftAmount, + }), + }); +} + +function paymentConfirmationHtml(params: { + firstName: string; + manageUrl: string; + drinkCardValue?: number; + giftAmount?: number; +}) { + const formatEuro = (cents: number) => + `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; + + const giftCents = params.giftAmount ?? 0; + const drinkCardCents = (params.drinkCardValue ?? 0) * 100; + const totalCents = drinkCardCents + giftCents; + + const paymentSummary = + totalCents > 0 + ? ` + + + +
+

Betaling ontvangen

+ ${drinkCardCents > 0 ? `

Drinkkaart: ${formatEuro(drinkCardCents)}

` : ""} + ${giftCents > 0 ? `

Vrijwillige gift: ${formatEuro(giftCents)}

` : ""} +

Totaal: ${formatEuro(totalCents)}

+
` + : ""; + + return ` + + + + + Betaling ontvangen + + + + + + +
+ + + + + + + + + + +
+

Kunstenkamp

+

Betaling ontvangen!

+
+

+ Hoi ${params.firstName}, +

+

+ We hebben je betaling voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen. Tot dan! +

+ ${paymentSummary} + + + + + +
+ + Beheer mijn inschrijving + +
+
+

+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp vzw — Een initiatief voor en door kunstenaars. +

+
+
+ +`; +} + +export async function sendPaymentConfirmationEmail(params: { + to: string; + firstName: string; + managementToken: string; + drinkCardValue?: number; + giftAmount?: number; +}) { + const transport = getTransport(); + if (!transport) { + console.warn("SMTP not configured — skipping payment confirmation email"); + return; + } + const manageUrl = `${baseUrl}/manage/${params.managementToken}`; + await transport.sendMail({ + from, + to: params.to, + subject: "Betaling ontvangen — Open Mic Night", + html: paymentConfirmationHtml({ + firstName: params.firstName, + manageUrl, + drinkCardValue: params.drinkCardValue, + giftAmount: params.giftAmount, + }), + }); +} diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index a1bcbcb..9b41877 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { sendCancellationEmail, sendConfirmationEmail, + sendPaymentReminderEmail, sendReminderEmail, sendUpdateEmail, } from "../email"; @@ -975,5 +976,40 @@ export async function runSendReminders(): Promise<{ } } + // Payment reminders: watchers with pending payment older than 3 days + const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; + const unpaidWatchers = await db + .select() + .from(registration) + .where( + and( + eq(registration.registrationType, "watcher"), + eq(registration.paymentStatus, "pending"), + isNull(registration.paymentReminderSentAt), + lte(registration.createdAt, new Date(threeDaysAgo)), + ), + ); + + for (const reg of unpaidWatchers) { + if (!reg.managementToken) continue; + try { + await sendPaymentReminderEmail({ + to: reg.email, + firstName: reg.firstName, + managementToken: reg.managementToken, + drinkCardValue: reg.drinkCardValue ?? undefined, + giftAmount: reg.giftAmount ?? undefined, + }); + await db + .update(registration) + .set({ paymentReminderSentAt: new Date() }) + .where(eq(registration.id, reg.id)); + sent++; + } catch (err) { + errors.push(`payment-reminder ${reg.email}: ${String(err)}`); + console.error(`Failed to send payment reminder to ${reg.email}:`, err); + } + } + return { sent, skipped: false, errors }; } diff --git a/packages/db/src/migrations/0008_payment_reminder_sent_at.sql b/packages/db/src/migrations/0008_payment_reminder_sent_at.sql new file mode 100644 index 0000000..6c48542 --- /dev/null +++ b/packages/db/src/migrations/0008_payment_reminder_sent_at.sql @@ -0,0 +1,2 @@ +-- Migration: add payment_reminder_sent_at column to registration table +ALTER TABLE registration ADD COLUMN payment_reminder_sent_at INTEGER; diff --git a/packages/db/src/schema/registrations.ts b/packages/db/src/schema/registrations.ts index b6d27bf..0a57991 100644 --- a/packages/db/src/schema/registrations.ts +++ b/packages/db/src/schema/registrations.ts @@ -37,6 +37,9 @@ export const registration = sqliteTable( drinkkaartCreditedAt: integer("drinkkaart_credited_at", { mode: "timestamp_ms", }), + paymentReminderSentAt: integer("payment_reminder_sent_at", { + mode: "timestamp_ms", + }), createdAt: integer("created_at", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(),