feat(registration): update event date and add required guest fields
Change event date from April 18 to April 24 across all pages and emails. Add birthdate and postcode as required fields for guest registration. Update API to support multiple registrations per user. Enhance admin panel with expandable guest details view.
This commit is contained in:
@@ -36,6 +36,7 @@ export default function EventRegistrationForm() {
|
|||||||
token={successState.token}
|
token={successState.token}
|
||||||
email={successState.email}
|
email={successState.email}
|
||||||
name={successState.name}
|
name={successState.name}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
setSuccessState(null);
|
setSuccessState(null);
|
||||||
setSelectedType(null);
|
setSelectedType(null);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function Hero() {
|
|||||||
{/* Bottom Right - Dark Teal with date - above mic */}
|
{/* Bottom Right - Dark Teal with date - above mic */}
|
||||||
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]">
|
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]">
|
||||||
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
|
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
|
||||||
VRIJDAG 18
|
VRIJDAG 24
|
||||||
<br />
|
<br />
|
||||||
april
|
april
|
||||||
</p>
|
</p>
|
||||||
@@ -153,7 +153,7 @@ export default function Hero() {
|
|||||||
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
|
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
|
||||||
VRIJDAG
|
VRIJDAG
|
||||||
<br />
|
<br />
|
||||||
18 april
|
24 april
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,6 +204,51 @@ export function GuestList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor={`guest-${idx}-birthdate`}
|
||||||
|
className="text-white/80"
|
||||||
|
>
|
||||||
|
Geboortedatum <span className="text-red-300">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id={`guest-${idx}-birthdate`}
|
||||||
|
value={guest.birthdate}
|
||||||
|
onChange={(e) => onChange(idx, "birthdate", e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
className={inputCls(!!errors[idx]?.birthdate)}
|
||||||
|
/>
|
||||||
|
{errors[idx]?.birthdate && (
|
||||||
|
<span className="text-red-300 text-sm" role="alert">
|
||||||
|
{errors[idx].birthdate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor={`guest-${idx}-postcode`}
|
||||||
|
className="text-white/80"
|
||||||
|
>
|
||||||
|
Postcode <span className="text-red-300">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={`guest-${idx}-postcode`}
|
||||||
|
value={guest.postcode}
|
||||||
|
onChange={(e) => onChange(idx, "postcode", e.target.value)}
|
||||||
|
placeholder="1234 AB"
|
||||||
|
autoComplete="off"
|
||||||
|
className={inputCls(!!errors[idx]?.postcode)}
|
||||||
|
/>
|
||||||
|
{errors[idx]?.postcode && (
|
||||||
|
<span className="text-red-300 text-sm" role="alert">
|
||||||
|
{errors[idx].postcode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor={`guest-${idx}-email`} className="text-white/80">
|
<label htmlFor={`guest-${idx}-email`} className="text-white/80">
|
||||||
E-mail
|
E-mail
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ interface Props {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuccessScreen({ token, email, name, onReset }: Props) {
|
export function SuccessScreen({
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
onReset,
|
||||||
|
isLoggedIn,
|
||||||
|
}: Props) {
|
||||||
const manageUrl =
|
const manageUrl =
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? `${window.location.origin}/manage/${token}`
|
? `${window.location.origin}/manage/${token}`
|
||||||
@@ -97,8 +104,8 @@ export function SuccessScreen({ token, email, name, onReset }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account creation prompt */}
|
{/* Account creation prompt — hidden when already logged in */}
|
||||||
{!drinkkaartPromptDismissed && (
|
{!isLoggedIn && !drinkkaartPromptDismissed && (
|
||||||
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6">
|
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6">
|
||||||
<h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
|
<h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
|
||||||
Maak een gratis account aan
|
Maak een gratis account aan
|
||||||
|
|||||||
@@ -389,7 +389,14 @@ export function WatcherForm({
|
|||||||
if (guests.length >= 9) return;
|
if (guests.length >= 9) return;
|
||||||
setGuests((prev) => [
|
setGuests((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ firstName: "", lastName: "", email: "", phone: "" },
|
{
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
birthdate: "",
|
||||||
|
postcode: "",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
setGuestErrors((prev) => [...prev, {}]);
|
setGuestErrors((prev) => [...prev, {}]);
|
||||||
}
|
}
|
||||||
@@ -418,6 +425,8 @@ export function WatcherForm({
|
|||||||
lastName: g.lastName.trim(),
|
lastName: g.lastName.trim(),
|
||||||
email: g.email.trim() || undefined,
|
email: g.email.trim() || undefined,
|
||||||
phone: g.phone.trim() || undefined,
|
phone: g.phone.trim() || undefined,
|
||||||
|
birthdate: g.birthdate.trim(),
|
||||||
|
postcode: g.postcode.trim(),
|
||||||
})),
|
})),
|
||||||
extraQuestions: data.extraQuestions.trim() || undefined,
|
extraQuestions: data.extraQuestions.trim() || undefined,
|
||||||
giftAmount,
|
giftAmount,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface GuestEntry {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
birthdate: string;
|
||||||
|
postcode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuestErrors {
|
export interface GuestErrors {
|
||||||
@@ -35,6 +37,8 @@ export interface GuestErrors {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
postcode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +55,8 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] {
|
|||||||
lastName: g.lastName ?? "",
|
lastName: g.lastName ?? "",
|
||||||
email: g.email ?? "",
|
email: g.email ?? "",
|
||||||
phone: g.phone ?? "",
|
phone: g.phone ?? "",
|
||||||
|
birthdate: g.birthdate ?? "",
|
||||||
|
postcode: g.postcode ?? "",
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -102,6 +108,8 @@ export function validateGuests(guests: GuestEntry[]): {
|
|||||||
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
|
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
|
||||||
? "Voer een geldig telefoonnummer in"
|
? "Voer een geldig telefoonnummer in"
|
||||||
: undefined,
|
: undefined,
|
||||||
|
birthdate: !g.birthdate.trim() ? "Geboortedatum is verplicht" : undefined,
|
||||||
|
postcode: !g.postcode.trim() ? "Postcode is verplicht" : undefined,
|
||||||
}));
|
}));
|
||||||
const valid = !errors.some((e) => Object.values(e).some(Boolean));
|
const valid = !errors.some((e) => Object.values(e).some(Boolean));
|
||||||
return { errors, valid };
|
return { errors, valid };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import appCss from "../index.css?url";
|
|||||||
const siteUrl = "https://kunstenkamp.be";
|
const siteUrl = "https://kunstenkamp.be";
|
||||||
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
|
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
|
||||||
const siteDescription =
|
const siteDescription =
|
||||||
"Doe mee met de Open Mic Night op 18 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
|
"Doe mee met de Open Mic Night op 24 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
|
||||||
const eventImage = `${siteUrl}/assets/og-image.jpg`;
|
const eventImage = `${siteUrl}/assets/og-image.jpg`;
|
||||||
|
|
||||||
export interface RouterAppContext {
|
export interface RouterAppContext {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function AccountPage() {
|
|||||||
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
|
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions());
|
const registrationQuery = useQuery(orpc.getMyRegistrations.queryOptions());
|
||||||
|
|
||||||
// Handle topup=success redirect (from Lemon Squeezy returning to /account)
|
// Handle topup=success redirect (from Lemon Squeezy returning to /account)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -128,7 +128,7 @@ function AccountPage() {
|
|||||||
| { name?: string; email?: string }
|
| { name?: string; email?: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const registration = registrationQuery.data;
|
const registrations = registrationQuery.data ?? [];
|
||||||
const drinkkaart = drinkkaartQuery.data;
|
const drinkkaart = drinkkaartQuery.data;
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -170,112 +170,122 @@ function AccountPage() {
|
|||||||
Mijn Inschrijving
|
Mijn Inschrijving
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{registration ? (
|
{registrations.length > 0 ? (
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Type badge */}
|
{registrations.map((registration) => (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
key={registration.id}
|
||||||
{registration.registrationType === "performer" ? (
|
className="rounded-2xl border border-white/10 bg-white/5 p-6"
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
|
>
|
||||||
<Music className="h-3.5 w-3.5" />
|
{/* Type badge */}
|
||||||
Artiest
|
<div className="mb-4 flex items-center justify-between">
|
||||||
</span>
|
<div className="flex items-center gap-2">
|
||||||
) : (
|
{registration.registrationType === "performer" ? (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
|
||||||
<Users className="h-3.5 w-3.5" />
|
<Music className="h-3.5 w-3.5" />
|
||||||
Bezoeker
|
Artiest
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Bezoeker
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(registration.registrationType !== "performer" ||
|
||||||
|
(registration.giftAmount ?? 0) > 0) && (
|
||||||
|
<PaymentBadge status={registration.paymentStatus} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<p className="mb-1 font-medium text-lg text-white">
|
||||||
|
{registration.firstName} {registration.lastName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Art form (performer only) */}
|
||||||
|
{registration.registrationType === "performer" &&
|
||||||
|
registration.artForm && (
|
||||||
|
<p className="mb-1 text-sm text-white/60">
|
||||||
|
Kunstvorm:{" "}
|
||||||
|
<span className="text-white/80">
|
||||||
|
{registration.artForm}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Guests (watcher only) */}
|
||||||
|
{registration.registrationType === "watcher" &&
|
||||||
|
registration.guests.length > 0 && (
|
||||||
|
<p className="mb-1 text-sm text-white/60">
|
||||||
|
{registration.guests.length + 1} personen (jij +{" "}
|
||||||
|
{registration.guests.length} gast
|
||||||
|
{registration.guests.length > 1 ? "en" : ""})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drink card value */}
|
||||||
|
{registration.registrationType === "watcher" &&
|
||||||
|
(registration.drinkCardValue ?? 0) > 0 && (
|
||||||
|
<p className="mb-1 text-sm text-white/60">
|
||||||
|
Drinkkaart:{" "}
|
||||||
|
<span className="text-white/80">
|
||||||
|
€{registration.drinkCardValue}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gift */}
|
||||||
|
{(registration.giftAmount ?? 0) > 0 && (
|
||||||
|
<p className="mb-1 text-sm text-white/60">
|
||||||
|
Gift:{" "}
|
||||||
|
<span className="text-white/80">
|
||||||
|
€{(registration.giftAmount ?? 0) / 100}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<p className="mt-3 text-white/40 text-xs">
|
||||||
|
Ingeschreven op{" "}
|
||||||
|
{new Date(registration.createdAt).toLocaleDateString(
|
||||||
|
"nl-BE",
|
||||||
|
{
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{registration.managementToken && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Link
|
||||||
|
to="/manage/$token"
|
||||||
|
params={{ token: registration.managementToken }}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
|
||||||
|
>
|
||||||
|
Beheer inschrijving
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PaymentBadge status={registration.paymentStatus} />
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<p className="mb-1 font-medium text-lg text-white">
|
|
||||||
{registration.firstName} {registration.lastName}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Art form (performer only) */}
|
|
||||||
{registration.registrationType === "performer" &&
|
|
||||||
registration.artForm && (
|
|
||||||
<p className="mb-1 text-sm text-white/60">
|
|
||||||
Kunstvorm:{" "}
|
|
||||||
<span className="text-white/80">
|
|
||||||
{registration.artForm}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Guests (watcher only) */}
|
|
||||||
{registration.registrationType === "watcher" &&
|
|
||||||
registration.guests.length > 0 && (
|
|
||||||
<p className="mb-1 text-sm text-white/60">
|
|
||||||
{registration.guests.length + 1} personen (jij +{" "}
|
|
||||||
{registration.guests.length} gast
|
|
||||||
{registration.guests.length > 1 ? "en" : ""})
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drink card value */}
|
|
||||||
{registration.registrationType === "watcher" &&
|
|
||||||
(registration.drinkCardValue ?? 0) > 0 && (
|
|
||||||
<p className="mb-1 text-sm text-white/60">
|
|
||||||
Drinkkaart:{" "}
|
|
||||||
<span className="text-white/80">
|
|
||||||
€{registration.drinkCardValue}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gift */}
|
|
||||||
{(registration.giftAmount ?? 0) > 0 && (
|
|
||||||
<p className="mb-1 text-sm text-white/60">
|
|
||||||
Gift:{" "}
|
|
||||||
<span className="text-white/80">
|
|
||||||
€{(registration.giftAmount ?? 0) / 100}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<p className="mt-3 text-white/40 text-xs">
|
|
||||||
Ingeschreven op{" "}
|
|
||||||
{new Date(registration.createdAt).toLocaleDateString(
|
|
||||||
"nl-BE",
|
|
||||||
{
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Action */}
|
|
||||||
{registration.managementToken && (
|
|
||||||
<div className="mt-5">
|
|
||||||
<Link
|
|
||||||
to="/manage/$token"
|
|
||||||
params={{ token: registration.managementToken }}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
|
|
||||||
>
|
|
||||||
Beheer inschrijving
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +44,19 @@ function AdminPage() {
|
|||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [expandedGuests, setExpandedGuests] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleGuests = (id: string) => {
|
||||||
|
setExpandedGuests((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Get current session to check user role
|
// Get current session to check user role
|
||||||
const sessionQuery = useQuery({
|
const sessionQuery = useQuery({
|
||||||
@@ -278,6 +291,25 @@ function AdminPage() {
|
|||||||
return `€${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
|
return `€${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseGuestsJson = (
|
||||||
|
raw: string | null | undefined,
|
||||||
|
): Array<{
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
postcode?: string;
|
||||||
|
}> => {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw as string);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check if user is admin
|
// Check if user is admin
|
||||||
const user = sessionQuery.data?.data?.user as
|
const user = sessionQuery.data?.data?.user as
|
||||||
| { role?: string; name?: string }
|
| { role?: string; name?: string }
|
||||||
@@ -486,7 +518,6 @@ function AdminPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6">
|
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6">
|
||||||
<Card className="border-white/10 bg-white/5">
|
<Card className="border-white/10 bg-white/5">
|
||||||
@@ -592,7 +623,6 @@ function AdminPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Card className="mb-6 border-white/10 bg-white/5">
|
<Card className="mb-6 border-white/10 bg-white/5">
|
||||||
<CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
|
<CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
|
||||||
@@ -700,7 +730,6 @@ function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm text-white/60">
|
<p className="text-sm text-white/60">
|
||||||
@@ -715,7 +744,6 @@ function AdminPage() {
|
|||||||
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
|
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registrations Table / Cards */}
|
{/* Registrations Table / Cards */}
|
||||||
<Card className="border-white/10 bg-white/5">
|
<Card className="border-white/10 bg-white/5">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -803,15 +831,9 @@ function AdminPage() {
|
|||||||
sortedRegistrations.map((reg) => {
|
sortedRegistrations.map((reg) => {
|
||||||
const isPerformer = reg.registrationType === "performer";
|
const isPerformer = reg.registrationType === "performer";
|
||||||
|
|
||||||
const guestCount = (() => {
|
const guests = parseGuestsJson(reg.guests);
|
||||||
if (!reg.guests) return 0;
|
const guestCount = guests.length;
|
||||||
try {
|
const isGuestsExpanded = expandedGuests.has(reg.id);
|
||||||
const g = JSON.parse(reg.guests as string);
|
|
||||||
return Array.isArray(g) ? g.length : 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const detailLabel = isPerformer
|
const detailLabel = isPerformer
|
||||||
? reg.artForm || "-"
|
? reg.artForm || "-"
|
||||||
@@ -833,121 +855,190 @@ function AdminPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<Fragment key={reg.id}>
|
||||||
key={reg.id}
|
<tr
|
||||||
className="border-white/5 border-b hover:bg-white/5"
|
key={reg.id}
|
||||||
>
|
className="border-white/5 border-b hover:bg-white/5"
|
||||||
<td className="px-4 py-3 font-medium text-white">
|
>
|
||||||
{reg.firstName} {reg.lastName}
|
<td className="px-4 py-3 font-medium text-white">
|
||||||
</td>
|
{reg.firstName} {reg.lastName}
|
||||||
<td className="px-4 py-3 text-sm text-white/70">
|
</td>
|
||||||
{reg.email}
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
</td>
|
{reg.email}
|
||||||
<td className="px-4 py-3 text-sm text-white/60">
|
</td>
|
||||||
{reg.phone || "-"}
|
<td className="px-4 py-3 text-sm text-white/60">
|
||||||
</td>
|
{reg.phone || "-"}
|
||||||
<td className="px-4 py-3">
|
</td>
|
||||||
<span
|
<td className="px-4 py-3">
|
||||||
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
|
<span
|
||||||
>
|
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
|
||||||
{isPerformer ? "Artiest" : "Bezoeker"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white/70">
|
|
||||||
{detailLabel}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white/70">
|
|
||||||
{guestCount > 0
|
|
||||||
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
|
|
||||||
: "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-pink-300/70 text-sm">
|
|
||||||
{formatCents(reg.giftAmount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{isPerformer ? (
|
|
||||||
<span className="text-sm text-white/30">-</span>
|
|
||||||
) : reg.paymentStatus === "paid" ? (
|
|
||||||
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
Betaald
|
|
||||||
</span>
|
|
||||||
) : reg.paymentStatus ===
|
|
||||||
"extra_payment_pending" ? (
|
|
||||||
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
|
|
||||||
Extra (€
|
|
||||||
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-yellow-400">
|
|
||||||
Open
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
|
|
||||||
{dateLabel}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white/70">
|
|
||||||
{reg.postcode || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white/70 tabular-nums">
|
|
||||||
{reg.birthdate || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white/70">
|
|
||||||
{isPerformer ? reg.experience || "-" : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white/70">
|
|
||||||
{isPerformer ? (
|
|
||||||
reg.isOver16 ? (
|
|
||||||
<span className="text-green-400">Ja</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-red-400">Nee</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="max-w-[200px] px-4 py-3 text-sm text-white/60">
|
|
||||||
<span
|
|
||||||
className="block truncate"
|
|
||||||
title={reg.extraQuestions ?? undefined}
|
|
||||||
>
|
|
||||||
{reg.extraQuestions || "-"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{reg.managementToken ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Kopieer beheerlink"
|
|
||||||
onClick={() =>
|
|
||||||
handleCopyManageUrl(
|
|
||||||
reg.managementToken as string,
|
|
||||||
reg.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
>
|
||||||
{copiedId === reg.id ? (
|
{isPerformer ? "Artiest" : "Bezoeker"}
|
||||||
<ClipboardCheck className="h-4 w-4 text-green-400" />
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
|
{detailLabel}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
|
{guestCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGuests(reg.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
{guestCount} gast
|
||||||
|
{guestCount === 1 ? "" : "en"}
|
||||||
|
{isGuestsExpanded ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-pink-300/70 text-sm">
|
||||||
|
{formatCents(reg.giftAmount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isPerformer ? (
|
||||||
|
<span className="text-sm text-white/30">-</span>
|
||||||
|
) : reg.paymentStatus === "paid" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
Betaald
|
||||||
|
</span>
|
||||||
|
) : reg.paymentStatus ===
|
||||||
|
"extra_payment_pending" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
|
||||||
|
Extra (€
|
||||||
|
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-yellow-400">
|
||||||
|
Open
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
|
||||||
|
{dateLabel}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
|
{reg.postcode || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/70 tabular-nums">
|
||||||
|
{reg.birthdate || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
|
{isPerformer ? reg.experience || "-" : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/70">
|
||||||
|
{isPerformer ? (
|
||||||
|
reg.isOver16 ? (
|
||||||
|
<span className="text-green-400">Ja</span>
|
||||||
) : (
|
) : (
|
||||||
<Clipboard className="h-4 w-4" />
|
<span className="text-red-400">Nee</span>
|
||||||
)}
|
)
|
||||||
</button>
|
) : (
|
||||||
) : (
|
"-"
|
||||||
<span className="text-sm text-white/20">—</span>
|
)}
|
||||||
)}
|
</td>
|
||||||
</td>
|
<td className="max-w-[200px] px-4 py-3 text-sm text-white/60">
|
||||||
</tr>
|
<span
|
||||||
|
className="block truncate"
|
||||||
|
title={reg.extraQuestions ?? undefined}
|
||||||
|
>
|
||||||
|
{reg.extraQuestions || "-"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{reg.managementToken ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Kopieer beheerlink"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyManageUrl(
|
||||||
|
reg.managementToken as string,
|
||||||
|
reg.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
{copiedId === reg.id ? (
|
||||||
|
<ClipboardCheck className="h-4 w-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Clipboard className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-white/20">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isGuestsExpanded &&
|
||||||
|
guests.map((guest, gi) => (
|
||||||
|
<tr
|
||||||
|
key={`${reg.id}-guest-${gi}`}
|
||||||
|
className="border-white/5 border-b bg-white/[0.02]"
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-4 pl-8 text-sm text-white/60 italic">
|
||||||
|
↳ {guest.firstName} {guest.lastName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/50">
|
||||||
|
{guest.email || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/50">
|
||||||
|
{guest.phone || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-white/5 px-2 py-0.5 font-semibold text-white/40 text-xs">
|
||||||
|
Gast
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/50">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/50">
|
||||||
|
{guest.postcode || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/50 tabular-nums">
|
||||||
|
{guest.birthdate || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-white/30">
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Cards */}
|
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
{registrationsQuery.isLoading ? (
|
{registrationsQuery.isLoading ? (
|
||||||
<div className="px-4 py-8 text-center text-white/60">
|
<div className="px-4 py-8 text-center text-white/60">
|
||||||
@@ -962,15 +1053,9 @@ function AdminPage() {
|
|||||||
{sortedRegistrations.map((reg) => {
|
{sortedRegistrations.map((reg) => {
|
||||||
const isPerformer = reg.registrationType === "performer";
|
const isPerformer = reg.registrationType === "performer";
|
||||||
|
|
||||||
const guestCount = (() => {
|
const guests = parseGuestsJson(reg.guests);
|
||||||
if (!reg.guests) return 0;
|
const guestCount = guests.length;
|
||||||
try {
|
const isGuestsExpanded = expandedGuests.has(reg.id);
|
||||||
const g = JSON.parse(reg.guests as string);
|
|
||||||
return Array.isArray(g) ? g.length : 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const detailLabel = isPerformer
|
const detailLabel = isPerformer
|
||||||
? reg.artForm || "-"
|
? reg.artForm || "-"
|
||||||
@@ -992,126 +1077,171 @@ function AdminPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={reg.id} className="p-4 hover:bg-white/5">
|
<div key={reg.id} className="hover:bg-white/5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="p-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="truncate font-medium text-white">
|
<div className="flex items-center gap-2">
|
||||||
{reg.firstName} {reg.lastName}
|
<span className="truncate font-medium text-white">
|
||||||
</span>
|
{reg.firstName} {reg.lastName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
|
||||||
|
<span className="truncate">{reg.email}</span>
|
||||||
|
{reg.phone && (
|
||||||
|
<span className="shrink-0">
|
||||||
|
• {reg.phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate">{reg.email}</span>
|
<span
|
||||||
{reg.phone && (
|
className={`shrink-0 rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
|
||||||
<span className="shrink-0">• {reg.phone}</span>
|
>
|
||||||
|
{isPerformer ? "Artiest" : "Bezoeker"}
|
||||||
|
</span>
|
||||||
|
{reg.managementToken && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Kopieer beheerlink"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyManageUrl(
|
||||||
|
reg.managementToken as string,
|
||||||
|
reg.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="shrink-0 rounded p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
{copiedId === reg.id ? (
|
||||||
|
<ClipboardCheck className="h-4 w-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Clipboard className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||||
className={`shrink-0 rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
|
<div className="text-white/70">
|
||||||
>
|
<span className="text-white/40">Details:</span>{" "}
|
||||||
{isPerformer ? "Artiest" : "Bezoeker"}
|
{detailLabel}
|
||||||
</span>
|
</div>
|
||||||
{reg.managementToken && (
|
{guestCount > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Kopieer beheerlink"
|
onClick={() => toggleGuests(reg.id)}
|
||||||
onClick={() =>
|
className="inline-flex items-center gap-1 text-white/70 transition-colors hover:text-white"
|
||||||
handleCopyManageUrl(
|
|
||||||
reg.managementToken as string,
|
|
||||||
reg.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="shrink-0 rounded p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
>
|
||||||
{copiedId === reg.id ? (
|
<span className="text-white/40">Gasten:</span>{" "}
|
||||||
<ClipboardCheck className="h-4 w-4 text-green-400" />
|
{guestCount}
|
||||||
|
{isGuestsExpanded ? (
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<Clipboard className="h-4 w-4" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(reg.giftAmount ?? 0) > 0 && (
|
||||||
|
<div className="text-pink-300">
|
||||||
|
<span className="text-white/40">Gift:</span>{" "}
|
||||||
|
{formatCents(reg.giftAmount)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isPerformer && (
|
||||||
|
<div>
|
||||||
|
<span className="text-white/40">Betaling:</span>{" "}
|
||||||
|
{reg.paymentStatus === "paid" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 font-medium text-green-400">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
Betaald
|
||||||
|
</span>
|
||||||
|
) : reg.paymentStatus ===
|
||||||
|
"extra_payment_pending" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
|
||||||
|
<span className="h-1 w-1 rounded-full bg-orange-400" />
|
||||||
|
Extra (€
|
||||||
|
{((reg.paymentAmount ?? 0) / 100).toFixed(
|
||||||
|
0,
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-yellow-400">Open</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reg.postcode && (
|
||||||
|
<div className="text-white/70">
|
||||||
|
<span className="text-white/40">Postcode:</span>{" "}
|
||||||
|
{reg.postcode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reg.birthdate && (
|
||||||
|
<div className="text-white/70">
|
||||||
|
<span className="text-white/40">
|
||||||
|
Geboortedatum:
|
||||||
|
</span>{" "}
|
||||||
|
{reg.birthdate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPerformer && reg.experience && (
|
||||||
|
<div className="text-white/70">
|
||||||
|
<span className="text-white/40">Ervaring:</span>{" "}
|
||||||
|
{reg.experience}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPerformer && (
|
||||||
|
<div className="text-white/70">
|
||||||
|
<span className="text-white/40">16+:</span>{" "}
|
||||||
|
{reg.isOver16 ? (
|
||||||
|
<span className="text-green-400">Ja</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-400">Nee</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reg.extraQuestions && (
|
||||||
|
<div className="w-full text-white/60">
|
||||||
|
<span className="text-white/40">
|
||||||
|
Opmerkingen:
|
||||||
|
</span>{" "}
|
||||||
|
{reg.extraQuestions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-white/50">{dateLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isGuestsExpanded && guestCount > 0 && (
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
<div className="border-white/5 border-t bg-white/[0.02] px-4 pt-2 pb-3">
|
||||||
<div className="text-white/70">
|
<div className="mb-1 text-white/30 text-xs">
|
||||||
<span className="text-white/40">Details:</span>{" "}
|
Gasten
|
||||||
{detailLabel}
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{guests.map((guest, gi) => (
|
||||||
|
<div
|
||||||
|
key={`${reg.id}-guest-${gi}`}
|
||||||
|
className="rounded border border-white/10 bg-white/5 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-white/80">
|
||||||
|
{guest.firstName} {guest.lastName}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-white/50">
|
||||||
|
{guest.email && <span>{guest.email}</span>}
|
||||||
|
{guest.phone && <span>{guest.phone}</span>}
|
||||||
|
{guest.birthdate && (
|
||||||
|
<span>{guest.birthdate}</span>
|
||||||
|
)}
|
||||||
|
{guest.postcode && (
|
||||||
|
<span>{guest.postcode}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{guestCount > 0 && (
|
)}
|
||||||
<div className="text-white/70">
|
|
||||||
<span className="text-white/40">Gasten:</span>{" "}
|
|
||||||
{guestCount}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(reg.giftAmount ?? 0) > 0 && (
|
|
||||||
<div className="text-pink-300">
|
|
||||||
<span className="text-white/40">Gift:</span>{" "}
|
|
||||||
{formatCents(reg.giftAmount)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isPerformer && (
|
|
||||||
<div>
|
|
||||||
<span className="text-white/40">Betaling:</span>{" "}
|
|
||||||
{reg.paymentStatus === "paid" ? (
|
|
||||||
<span className="inline-flex items-center gap-1 font-medium text-green-400">
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
Betaald
|
|
||||||
</span>
|
|
||||||
) : reg.paymentStatus ===
|
|
||||||
"extra_payment_pending" ? (
|
|
||||||
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
|
|
||||||
<span className="h-1 w-1 rounded-full bg-orange-400" />
|
|
||||||
Extra (€
|
|
||||||
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-yellow-400">Open</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{reg.postcode && (
|
|
||||||
<div className="text-white/70">
|
|
||||||
<span className="text-white/40">Postcode:</span>{" "}
|
|
||||||
{reg.postcode}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{reg.birthdate && (
|
|
||||||
<div className="text-white/70">
|
|
||||||
<span className="text-white/40">
|
|
||||||
Geboortedatum:
|
|
||||||
</span>{" "}
|
|
||||||
{reg.birthdate}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isPerformer && reg.experience && (
|
|
||||||
<div className="text-white/70">
|
|
||||||
<span className="text-white/40">Ervaring:</span>{" "}
|
|
||||||
{reg.experience}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isPerformer && (
|
|
||||||
<div className="text-white/70">
|
|
||||||
<span className="text-white/40">16+:</span>{" "}
|
|
||||||
{reg.isOver16 ? (
|
|
||||||
<span className="text-green-400">Ja</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-red-400">Nee</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{reg.extraQuestions && (
|
|
||||||
<div className="w-full text-white/60">
|
|
||||||
<span className="text-white/40">
|
|
||||||
Opmerkingen:
|
|
||||||
</span>{" "}
|
|
||||||
{reg.extraQuestions}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-white/50">{dateLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1120,8 +1250,6 @@ function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
|
<div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function ContactPage() {
|
|||||||
|
|
||||||
<section className="rounded-lg bg-white/5 p-6">
|
<section className="rounded-lg bg-white/5 p-6">
|
||||||
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3>
|
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3>
|
||||||
<p>Vrijdag 18 april 2026</p>
|
<p>Vrijdag 24 april 2026</p>
|
||||||
<p>Aanvang: 19:00 uur</p>
|
<p>Aanvang: 19:00 uur</p>
|
||||||
<p className="mt-2 text-white/60">
|
<p className="mt-2 text-white/60">
|
||||||
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.
|
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
|
|||||||
lastName: g.lastName.trim(),
|
lastName: g.lastName.trim(),
|
||||||
email: g.email.trim() || undefined,
|
email: g.email.trim() || undefined,
|
||||||
phone: g.phone.trim() || undefined,
|
phone: g.phone.trim() || undefined,
|
||||||
|
birthdate: g.birthdate.trim(),
|
||||||
|
postcode: g.postcode.trim(),
|
||||||
})),
|
})),
|
||||||
extraQuestions: formData.extraQuestions.trim() || undefined,
|
extraQuestions: formData.extraQuestions.trim() || undefined,
|
||||||
giftAmount,
|
giftAmount,
|
||||||
@@ -435,7 +437,14 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
|
|||||||
if (formGuests.length >= 9) return;
|
if (formGuests.length >= 9) return;
|
||||||
setFormGuests((prev) => [
|
setFormGuests((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ firstName: "", lastName: "", email: "", phone: "" },
|
{
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
birthdate: "",
|
||||||
|
postcode: "",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}}
|
}}
|
||||||
onRemove={(idx) =>
|
onRemove={(idx) =>
|
||||||
@@ -611,7 +620,7 @@ function ManageRegistrationPage() {
|
|||||||
Jouw inschrijving
|
Jouw inschrijving
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mb-8 text-white/60">
|
<p className="mb-8 text-white/60">
|
||||||
Open Mic Night — vrijdag 18 april 2026
|
Open Mic Night — vrijdag 24 april 2026
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Type badge */}
|
{/* Type badge */}
|
||||||
@@ -627,18 +636,19 @@ function ManageRegistrationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment status - shown for everyone with pending/extra payment or gift */}
|
{/* Payment status - not shown for performers without a gift */}
|
||||||
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
|
{(!isPerformer || (data.giftAmount ?? 0) > 0) &&
|
||||||
<div className="mb-6">
|
(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
|
||||||
{data.paymentStatus === "paid" ? (
|
<div className="mb-6">
|
||||||
<PaidBadge />
|
{data.paymentStatus === "paid" ? (
|
||||||
) : data.paymentStatus === "extra_payment_pending" ? (
|
<PaidBadge />
|
||||||
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
|
) : data.paymentStatus === "extra_payment_pending" ? (
|
||||||
) : (
|
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
|
||||||
<PendingBadge />
|
) : (
|
||||||
)}
|
<PendingBadge />
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Gift display */}
|
{/* Gift display */}
|
||||||
{(data.giftAmount ?? 0) > 0 && (
|
{(data.giftAmount ?? 0) > 0 && (
|
||||||
@@ -710,6 +720,16 @@ function ManageRegistrationPage() {
|
|||||||
<p className="text-white">
|
<p className="text-white">
|
||||||
{g.firstName} {g.lastName}
|
{g.firstName} {g.lastName}
|
||||||
</p>
|
</p>
|
||||||
|
{g.birthdate && (
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Geboortedatum: {g.birthdate}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{g.postcode && (
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Postcode: {g.postcode}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{g.email && (
|
{g.email && (
|
||||||
<p className="text-sm text-white/60">{g.email}</p>
|
<p className="text-sm text-white/60">{g.email}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function registrationConfirmationHtml(params: {
|
|||||||
Hoi ${params.firstName},
|
Hoi ${params.firstName},
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
|
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
|
||||||
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> in goede orde ontvangen.
|
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 24 april 2026</strong> in goede orde ontvangen.
|
||||||
</p>
|
</p>
|
||||||
<!-- Registration summary -->
|
<!-- Registration summary -->
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ function parseGuestsJson(raw: string | null): Array<{
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
postcode?: string;
|
||||||
}> {
|
}> {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
try {
|
try {
|
||||||
@@ -218,6 +220,8 @@ const guestSchema = z.object({
|
|||||||
lastName: z.string().min(1),
|
lastName: z.string().min(1),
|
||||||
email: z.string().email().optional().or(z.literal("")),
|
email: z.string().email().optional().or(z.literal("")),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
|
birthdate: z.string().min(1),
|
||||||
|
postcode: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const coreRegistrationFields = {
|
const coreRegistrationFields = {
|
||||||
@@ -542,7 +546,7 @@ export const appRouter = {
|
|||||||
const guestSummary = guests
|
const guestSummary = guests
|
||||||
.map(
|
.map(
|
||||||
(g) =>
|
(g) =>
|
||||||
`${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
|
`${g.firstName} ${g.lastName}${g.birthdate ? ` (${g.birthdate})` : ""}${g.postcode ? ` [${g.postcode}]` : ""}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
|
||||||
)
|
)
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
return [
|
return [
|
||||||
@@ -593,7 +597,7 @@ export const appRouter = {
|
|||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getMyRegistration: protectedProcedure.handler(async ({ context }) => {
|
getMyRegistrations: protectedProcedure.handler(async ({ context }) => {
|
||||||
const email = context.session.user.email;
|
const email = context.session.user.email;
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -602,16 +606,12 @@ export const appRouter = {
|
|||||||
.where(
|
.where(
|
||||||
and(eq(registration.email, email), isNull(registration.cancelledAt)),
|
and(eq(registration.email, email), isNull(registration.cancelledAt)),
|
||||||
)
|
)
|
||||||
.orderBy(desc(registration.createdAt))
|
.orderBy(desc(registration.createdAt));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const row = rows[0];
|
return rows.map((row) => ({
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...row,
|
...row,
|
||||||
guests: parseGuestsJson(row.guests),
|
guests: parseGuestsJson(row.guests),
|
||||||
};
|
}));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user