feat:multiple bezoekers
This commit is contained in:
@@ -40,7 +40,7 @@ export function CookieConsent() {
|
|||||||
<strong>We gebruiken cookies</strong>
|
<strong>We gebruiken cookies</strong>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-white/80">
|
<p className="mt-1 text-sm text-white/80">
|
||||||
We gebruiken analytische cookies om onze website te verbeteren.
|
We gebruiken analytische cookies om onze website te verbeteren.{" "}
|
||||||
<a href="/privacy" className="underline hover:text-white">
|
<a href="/privacy" className="underline hover:text-white">
|
||||||
Meer informatie
|
Meer informatie
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,6 @@ const faqQuestions = [
|
|||||||
answer:
|
answer:
|
||||||
"Iedereen! Of je nu een doorgewinterde artiest bent of voor het eerst op een podium staat — de open mic night is er voor alle niveaus en ervaringen.",
|
"Iedereen! Of je nu een doorgewinterde artiest bent of voor het eerst op een podium staat — de open mic night is er voor alle niveaus en ervaringen.",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
question: "Is er een minimumleeftijd?",
|
|
||||||
answer:
|
|
||||||
"Als je wilt optreden, moet je minimaal 15 jaar oud zijn & toestemming hebben van je ouders. Er is geen maximumleeftijd — iedereen is welkom!",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
question: "Hoelang mag mijn optreden duren?",
|
question: "Hoelang mag mijn optreden duren?",
|
||||||
answer:
|
answer:
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export const Route = createFileRoute("/admin")({
|
|||||||
function AdminPage() {
|
function AdminPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [registrationType, setRegistrationType] = useState<
|
||||||
|
"performer" | "watcher" | ""
|
||||||
|
>("");
|
||||||
const [artForm, setArtForm] = useState("");
|
const [artForm, setArtForm] = useState("");
|
||||||
const [fromDate, setFromDate] = useState("");
|
const [fromDate, setFromDate] = useState("");
|
||||||
const [toDate, setToDate] = useState("");
|
const [toDate, setToDate] = useState("");
|
||||||
@@ -49,6 +52,7 @@ function AdminPage() {
|
|||||||
orpc.getRegistrations.queryOptions({
|
orpc.getRegistrations.queryOptions({
|
||||||
input: {
|
input: {
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
|
registrationType: registrationType || undefined,
|
||||||
artForm: artForm || undefined,
|
artForm: artForm || undefined,
|
||||||
fromDate: fromDate || undefined,
|
fromDate: fromDate || undefined,
|
||||||
toDate: toDate || undefined,
|
toDate: toDate || undefined,
|
||||||
@@ -120,6 +124,29 @@ function AdminPage() {
|
|||||||
const adminRequests = adminRequestsQuery.data ?? [];
|
const adminRequests = adminRequestsQuery.data ?? [];
|
||||||
const pendingRequests = adminRequests.filter((r) => r.status === "pending");
|
const pendingRequests = adminRequests.filter((r) => r.status === "pending");
|
||||||
|
|
||||||
|
const performerCount =
|
||||||
|
stats?.byType.find((t) => t.registrationType === "performer")?.count ?? 0;
|
||||||
|
const watcherCount =
|
||||||
|
stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0;
|
||||||
|
|
||||||
|
// Calculate total attendees including guests
|
||||||
|
const totalRegistrations = registrationsQuery.data?.data ?? [];
|
||||||
|
const watcherWithGuests = totalRegistrations.filter(
|
||||||
|
(r) => r.registrationType === "watcher" && r.guests,
|
||||||
|
);
|
||||||
|
const totalGuestCount = watcherWithGuests.reduce((sum, r) => {
|
||||||
|
try {
|
||||||
|
const guests = JSON.parse(r.guests as string);
|
||||||
|
return sum + (Array.isArray(guests) ? guests.length : 0);
|
||||||
|
} catch {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
const totalWatcherAttendees = watcherCount + totalGuestCount;
|
||||||
|
const totalDrinkCardValue = totalRegistrations
|
||||||
|
.filter((r) => r.registrationType === "watcher")
|
||||||
|
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#214e51]">
|
<div className="min-h-screen bg-[#214e51]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -207,7 +234,7 @@ function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-3">
|
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4">
|
||||||
<Card className="border-white/10 bg-white/5">
|
<Card className="border-white/10 bg-white/5">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription className="text-white/60">
|
<CardDescription className="text-white/60">
|
||||||
@@ -238,23 +265,61 @@ function AdminPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-white/10 bg-white/5">
|
<Card className="border-amber-400/20 bg-amber-400/5">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription className="text-white/60">
|
<CardDescription className="text-amber-300/70">
|
||||||
Per kunstvorm
|
Artiesten
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<div className="mt-2 space-y-1">
|
<CardTitle className="font-['Intro',sans-serif] text-4xl text-amber-300">
|
||||||
{stats?.byArtForm.slice(0, 5).map((item) => (
|
{performerCount}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{stats?.byArtForm.slice(0, 4).map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.artForm}
|
key={item.artForm}
|
||||||
className="flex items-center justify-between text-sm"
|
className="flex items-center justify-between text-xs"
|
||||||
>
|
>
|
||||||
<span className="text-white/80">{item.artForm}</span>
|
<span className="text-amber-300/70">
|
||||||
<span className="text-white">{item.count}</span>
|
{item.artForm || "Onbekend"}
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-300">{item.count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-teal-400/20 bg-teal-400/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="text-teal-300/70">
|
||||||
|
Bezoekers
|
||||||
|
</CardDescription>
|
||||||
|
<CardTitle className="font-['Intro',sans-serif] text-4xl text-teal-300">
|
||||||
|
{watcherCount}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{totalGuestCount > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-teal-300/70">Inclusief gasten</span>
|
||||||
|
<span className="text-teal-300">+{totalGuestCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-teal-300/70">Totaal aanwezig</span>
|
||||||
|
<span className="font-semibold text-teal-300">
|
||||||
|
{totalWatcherAttendees}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-teal-300/70">Drinkkaart</span>
|
||||||
|
<span className="text-teal-300">€{totalDrinkCardValue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,7 +331,7 @@ function AdminPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="search"
|
htmlFor="search"
|
||||||
@@ -286,6 +351,35 @@ function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="typeFilter"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="typeFilter"
|
||||||
|
value={registrationType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRegistrationType(
|
||||||
|
e.target.value as "performer" | "watcher" | "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-white/20 bg-white/10 px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
|
||||||
|
>
|
||||||
|
<option value="" className="bg-[#214e51]">
|
||||||
|
Alle types
|
||||||
|
</option>
|
||||||
|
<option value="performer" className="bg-[#214e51]">
|
||||||
|
Artiesten
|
||||||
|
</option>
|
||||||
|
<option value="watcher" className="bg-[#214e51]">
|
||||||
|
Bezoekers
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="artForm"
|
htmlFor="artForm"
|
||||||
@@ -369,11 +463,20 @@ function AdminPage() {
|
|||||||
Telefoon
|
Telefoon
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
Kunstvorm
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Kunstvorm / Drinkkaart
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Gezelschap
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
Ervaring
|
Ervaring
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
16+
|
||||||
|
</th>
|
||||||
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
Datum
|
Datum
|
||||||
</th>
|
</th>
|
||||||
@@ -383,7 +486,7 @@ function AdminPage() {
|
|||||||
{registrationsQuery.isLoading ? (
|
{registrationsQuery.isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={9}
|
||||||
className="px-6 py-8 text-center text-white/60"
|
className="px-6 py-8 text-center text-white/60"
|
||||||
>
|
>
|
||||||
Laden...
|
Laden...
|
||||||
@@ -392,36 +495,83 @@ function AdminPage() {
|
|||||||
) : registrations.length === 0 ? (
|
) : registrations.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={9}
|
||||||
className="px-6 py-8 text-center text-white/60"
|
className="px-6 py-8 text-center text-white/60"
|
||||||
>
|
>
|
||||||
Geen registraties gevonden
|
Geen registraties gevonden
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
registrations.map((reg) => (
|
registrations.map((reg) => {
|
||||||
<tr
|
const isPerformer = reg.registrationType === "performer";
|
||||||
key={reg.id}
|
return (
|
||||||
className="border-white/5 border-b hover:bg-white/5"
|
<tr
|
||||||
>
|
key={reg.id}
|
||||||
<td className="px-6 py-4 text-white">
|
className="border-white/5 border-b hover:bg-white/5"
|
||||||
{reg.firstName} {reg.lastName}
|
>
|
||||||
</td>
|
<td className="px-6 py-4 text-white">
|
||||||
<td className="px-6 py-4 text-white/80">{reg.email}</td>
|
{reg.firstName} {reg.lastName}
|
||||||
<td className="px-6 py-4 text-white/80">
|
</td>
|
||||||
{reg.phone || "-"}
|
<td className="px-6 py-4 text-white/80">
|
||||||
</td>
|
{reg.email}
|
||||||
<td className="px-6 py-4 text-white/80">
|
</td>
|
||||||
{reg.artForm}
|
<td className="px-6 py-4 text-white/80">
|
||||||
</td>
|
{reg.phone || "-"}
|
||||||
<td className="px-6 py-4 text-white/80">
|
</td>
|
||||||
{reg.experience || "-"}
|
<td className="px-6 py-4">
|
||||||
</td>
|
<span
|
||||||
<td className="px-6 py-4 text-white/60">
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
|
||||||
{new Date(reg.createdAt).toLocaleDateString("nl-BE")}
|
>
|
||||||
</td>
|
{isPerformer ? "Artiest" : "Bezoeker"}
|
||||||
</tr>
|
</span>
|
||||||
))
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">
|
||||||
|
{isPerformer
|
||||||
|
? reg.artForm || "-"
|
||||||
|
: `€${reg.drinkCardValue ?? 5} drinkkaart`}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">
|
||||||
|
{isPerformer
|
||||||
|
? "-"
|
||||||
|
: (() => {
|
||||||
|
if (!reg.guests) return "-";
|
||||||
|
try {
|
||||||
|
const guests = JSON.parse(
|
||||||
|
reg.guests as string,
|
||||||
|
);
|
||||||
|
const count = Array.isArray(guests)
|
||||||
|
? guests.length
|
||||||
|
: 0;
|
||||||
|
return count > 0
|
||||||
|
? `${count} gast${count === 1 ? "" : "en"}`
|
||||||
|
: "-";
|
||||||
|
} catch {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">
|
||||||
|
{isPerformer ? reg.experience || "-" : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">
|
||||||
|
{isPerformer ? (
|
||||||
|
reg.isOver16 ? (
|
||||||
|
<span className="text-green-400">✓</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-400">✗</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/60">
|
||||||
|
{new Date(reg.createdAt).toLocaleDateString(
|
||||||
|
"nl-BE",
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ export const Route = createFileRoute("/manage/$token")({
|
|||||||
component: ManageRegistrationPage,
|
component: ManageRegistrationPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface GuestEntry {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGuests(raw: string | null | undefined): GuestEntry[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((g) => ({
|
||||||
|
firstName: g.firstName ?? "",
|
||||||
|
lastName: g.lastName ?? "",
|
||||||
|
email: g.email ?? "",
|
||||||
|
phone: g.phone ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function ManageRegistrationPage() {
|
function ManageRegistrationPage() {
|
||||||
const { token } = useParams({ from: "/manage/$token" });
|
const { token } = useParams({ from: "/manage/$token" });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -18,11 +43,13 @@ function ManageRegistrationPage() {
|
|||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
wantsToPerform: false,
|
registrationType: "watcher" as "performer" | "watcher",
|
||||||
artForm: "",
|
artForm: "",
|
||||||
experience: "",
|
experience: "",
|
||||||
|
isOver16: false,
|
||||||
extraQuestions: "",
|
extraQuestions: "",
|
||||||
});
|
});
|
||||||
|
const [formGuests, setFormGuests] = useState<GuestEntry[]>([]);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
|
...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
|
||||||
@@ -58,30 +85,41 @@ function ManageRegistrationPage() {
|
|||||||
lastName: data.lastName,
|
lastName: data.lastName,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone: data.phone || "",
|
phone: data.phone || "",
|
||||||
wantsToPerform: data.wantsToPerform ?? false,
|
registrationType:
|
||||||
|
(data.registrationType as "performer" | "watcher") ?? "watcher",
|
||||||
artForm: data.artForm || "",
|
artForm: data.artForm || "",
|
||||||
experience: data.experience || "",
|
experience: data.experience || "",
|
||||||
|
isOver16: data.isOver16 ?? false,
|
||||||
extraQuestions: data.extraQuestions || "",
|
extraQuestions: data.extraQuestions || "",
|
||||||
});
|
});
|
||||||
|
setFormGuests(parseGuests(data.guests));
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = (e: React.FormEvent) => {
|
const handleSave = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const isPerformer = formData.registrationType === "performer";
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
token,
|
token,
|
||||||
firstName: formData.firstName.trim(),
|
firstName: formData.firstName.trim(),
|
||||||
lastName: formData.lastName.trim(),
|
lastName: formData.lastName.trim(),
|
||||||
email: formData.email.trim(),
|
email: formData.email.trim(),
|
||||||
phone: formData.phone.trim() || undefined,
|
phone: formData.phone.trim() || undefined,
|
||||||
wantsToPerform: formData.wantsToPerform,
|
registrationType: formData.registrationType,
|
||||||
artForm: formData.wantsToPerform
|
artForm: isPerformer ? formData.artForm.trim() || undefined : undefined,
|
||||||
? formData.artForm.trim() || undefined
|
experience: isPerformer
|
||||||
: undefined,
|
|
||||||
experience: formData.wantsToPerform
|
|
||||||
? formData.experience.trim() || undefined
|
? formData.experience.trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
|
isOver16: isPerformer ? formData.isOver16 : false,
|
||||||
|
guests: isPerformer
|
||||||
|
? []
|
||||||
|
: formGuests.map((g) => ({
|
||||||
|
firstName: g.firstName.trim(),
|
||||||
|
lastName: g.lastName.trim(),
|
||||||
|
email: g.email.trim() || undefined,
|
||||||
|
phone: g.phone.trim() || undefined,
|
||||||
|
})),
|
||||||
extraQuestions: formData.extraQuestions.trim() || undefined,
|
extraQuestions: formData.extraQuestions.trim() || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -96,6 +134,33 @@ function ManageRegistrationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddGuest = () => {
|
||||||
|
if (formGuests.length >= 9) return;
|
||||||
|
setFormGuests((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ firstName: "", lastName: "", email: "", phone: "" },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveGuest = (index: number) => {
|
||||||
|
setFormGuests((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGuestChange = (
|
||||||
|
index: number,
|
||||||
|
field: keyof GuestEntry,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
setFormGuests((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = { ...next[index], [field]: value };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClasses =
|
||||||
|
"w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none";
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#214e51]">
|
<div className="min-h-screen bg-[#214e51]">
|
||||||
@@ -133,7 +198,13 @@ function ManageRegistrationPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPerformer = data.registrationType === "performer";
|
||||||
|
const viewGuests = parseGuests(data.guests);
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
|
const isEditingWatcher = formData.registrationType === "watcher";
|
||||||
|
const totalDrinkCard = isEditingWatcher ? 5 + formGuests.length * 2 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#214e51]">
|
<div className="min-h-screen bg-[#214e51]">
|
||||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||||
@@ -161,7 +232,7 @@ function ManageRegistrationPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((p) => ({ ...p, firstName: e.target.value }))
|
setFormData((p) => ({ ...p, firstName: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className={inputClasses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -176,7 +247,7 @@ function ManageRegistrationPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((p) => ({ ...p, lastName: e.target.value }))
|
setFormData((p) => ({ ...p, lastName: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className={inputClasses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +264,7 @@ function ManageRegistrationPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((p) => ({ ...p, email: e.target.value }))
|
setFormData((p) => ({ ...p, email: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className={inputClasses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,46 +279,57 @@ function ManageRegistrationPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((p) => ({ ...p, phone: e.target.value }))
|
setFormData((p) => ({ ...p, phone: e.target.value }))
|
||||||
}
|
}
|
||||||
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className={inputClasses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label
|
{/* Registration type */}
|
||||||
htmlFor="wantsToPerform"
|
<div>
|
||||||
className="flex cursor-pointer items-center gap-4"
|
<p className="mb-3 text-white">Type inschrijving</p>
|
||||||
>
|
<div className="flex gap-4">
|
||||||
<div className="relative flex shrink-0">
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
<input
|
<div className="relative flex shrink-0">
|
||||||
id="wantsToPerform"
|
<input
|
||||||
type="checkbox"
|
type="radio"
|
||||||
checked={formData.wantsToPerform}
|
name="registrationType"
|
||||||
onChange={(e) =>
|
value="performer"
|
||||||
setFormData((p) => ({
|
checked={formData.registrationType === "performer"}
|
||||||
...p,
|
onChange={() =>
|
||||||
wantsToPerform: e.target.checked,
|
setFormData((p) => ({
|
||||||
artForm: e.target.checked ? p.artForm : "",
|
...p,
|
||||||
experience: e.target.checked ? p.experience : "",
|
registrationType: "performer",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="peer sr-only"
|
className="peer sr-only"
|
||||||
/>
|
/>
|
||||||
<div className="h-6 w-6 border-2 border-white/50 bg-transparent transition-colors peer-checked:border-white peer-checked:bg-white" />
|
<div className="h-5 w-5 rounded-full border-2 border-white/50 bg-transparent transition-colors peer-checked:border-amber-400 peer-checked:bg-amber-400" />
|
||||||
<svg
|
</div>
|
||||||
className="pointer-events-none absolute top-0 left-0 h-6 w-6 scale-0 text-[#214e51] transition-transform peer-checked:scale-100"
|
<span className="text-white">Optreden</span>
|
||||||
viewBox="0 0 24 24"
|
</label>
|
||||||
fill="none"
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
stroke="currentColor"
|
<div className="relative flex shrink-0">
|
||||||
strokeWidth={3}
|
<input
|
||||||
aria-label="Geselecteerd"
|
type="radio"
|
||||||
>
|
name="registrationType"
|
||||||
<polyline points="20 6 9 17 4 12" />
|
value="watcher"
|
||||||
</svg>
|
checked={formData.registrationType === "watcher"}
|
||||||
|
onChange={() =>
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
registrationType: "watcher",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="peer sr-only"
|
||||||
|
/>
|
||||||
|
<div className="h-5 w-5 rounded-full border-2 border-white/50 bg-transparent transition-colors peer-checked:border-teal-400 peer-checked:bg-teal-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white">Kijken</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white text-xl">Ik wil optreden</span>
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
{formData.wantsToPerform && (
|
{formData.registrationType === "performer" && (
|
||||||
<div className="space-y-6 border border-white/20 p-6">
|
<div className="space-y-6 border border-amber-400/20 bg-amber-400/5 p-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="artForm" className="mb-2 block text-white">
|
<label htmlFor="artForm" className="mb-2 block text-white">
|
||||||
Kunstvorm *
|
Kunstvorm *
|
||||||
@@ -255,16 +337,13 @@ function ManageRegistrationPage() {
|
|||||||
<input
|
<input
|
||||||
id="artForm"
|
id="artForm"
|
||||||
type="text"
|
type="text"
|
||||||
required={formData.wantsToPerform}
|
required={formData.registrationType === "performer"}
|
||||||
value={formData.artForm}
|
value={formData.artForm}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((p) => ({
|
setFormData((p) => ({ ...p, artForm: e.target.value }))
|
||||||
...p,
|
|
||||||
artForm: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
list="artFormSuggestions"
|
list="artFormSuggestions"
|
||||||
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className={inputClasses}
|
||||||
/>
|
/>
|
||||||
<datalist id="artFormSuggestions">
|
<datalist id="artFormSuggestions">
|
||||||
<option value="Muziek" />
|
<option value="Muziek" />
|
||||||
@@ -290,7 +369,7 @@ function ManageRegistrationPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
list="experienceSuggestions"
|
list="experienceSuggestions"
|
||||||
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className={inputClasses}
|
||||||
/>
|
/>
|
||||||
<datalist id="experienceSuggestions">
|
<datalist id="experienceSuggestions">
|
||||||
<option value="Beginner" />
|
<option value="Beginner" />
|
||||||
@@ -298,6 +377,187 @@ function ManageRegistrationPage() {
|
|||||||
<option value="Professional" />
|
<option value="Professional" />
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
|
<div className="relative flex shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.isOver16}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((p) => ({
|
||||||
|
...p,
|
||||||
|
isOver16: e.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="peer sr-only"
|
||||||
|
/>
|
||||||
|
<div className="h-6 w-6 border-2 border-white/50 bg-transparent transition-colors peer-checked:border-amber-400 peer-checked:bg-amber-400" />
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute top-0 left-0 h-6 w-6 scale-0 text-[#214e51] transition-transform peer-checked:scale-100"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
aria-label="Geselecteerd"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-white">Ik ben 16 jaar of ouder</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Guests section for watchers */}
|
||||||
|
{isEditingWatcher && (
|
||||||
|
<div className="border border-teal-400/20 bg-teal-400/5 p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-teal-300/80 uppercase tracking-wider">
|
||||||
|
Medebezoekers{" "}
|
||||||
|
<span className="text-white/50 normal-case">
|
||||||
|
({formGuests.length}/9)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/60">
|
||||||
|
Drinkkaart totaal: €{totalDrinkCard} voor{" "}
|
||||||
|
{1 + formGuests.length} personen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{formGuests.length < 9 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddGuest}
|
||||||
|
className="flex items-center gap-1.5 rounded border border-teal-400/40 bg-teal-400/10 px-3 py-1.5 text-sm text-teal-300 transition-colors hover:bg-teal-400/20"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Toevoegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formGuests.length === 0 && (
|
||||||
|
<p className="text-sm text-white/40">
|
||||||
|
Geen medebezoekers toegevoegd.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{formGuests.map((guest, idx) => (
|
||||||
|
<div
|
||||||
|
key={`edit-guest-${
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: stable index
|
||||||
|
idx
|
||||||
|
}`}
|
||||||
|
className="border border-teal-400/20 p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm text-teal-300">
|
||||||
|
Medebezoeker {idx + 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveGuest(idx)}
|
||||||
|
className="text-red-400/70 text-sm transition-colors hover:text-red-300"
|
||||||
|
>
|
||||||
|
Verwijderen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`edit-guest-${idx}-firstName`}
|
||||||
|
className="mb-1 block text-sm text-white/70"
|
||||||
|
>
|
||||||
|
Voornaam *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-guest-${idx}-firstName`}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={guest.firstName}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleGuestChange(
|
||||||
|
idx,
|
||||||
|
"firstName",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Voornaam"
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`edit-guest-${idx}-lastName`}
|
||||||
|
className="mb-1 block text-sm text-white/70"
|
||||||
|
>
|
||||||
|
Achternaam *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-guest-${idx}-lastName`}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={guest.lastName}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleGuestChange(idx, "lastName", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Achternaam"
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`edit-guest-${idx}-email`}
|
||||||
|
className="mb-1 block text-sm text-white/70"
|
||||||
|
>
|
||||||
|
E-mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-guest-${idx}-email`}
|
||||||
|
type="email"
|
||||||
|
value={guest.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleGuestChange(idx, "email", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="optioneel@email.be"
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`edit-guest-${idx}-phone`}
|
||||||
|
className="mb-1 block text-sm text-white/70"
|
||||||
|
>
|
||||||
|
Telefoon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-guest-${idx}-phone`}
|
||||||
|
type="tel"
|
||||||
|
value={guest.phone}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleGuestChange(idx, "phone", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="06-12345678"
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -358,6 +618,19 @@ function ManageRegistrationPage() {
|
|||||||
Open Mic Night — vrijdag 18 april 2026
|
Open Mic Night — vrijdag 18 april 2026
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<div
|
||||||
|
className={`mb-6 inline-flex items-center gap-2 rounded-full border px-4 py-1.5 font-semibold text-sm ${isPerformer ? "border-amber-400/40 bg-amber-400/10 text-amber-300" : "border-teal-400/40 bg-teal-400/10 text-teal-300"}`}
|
||||||
|
>
|
||||||
|
{isPerformer ? "Artiest" : "Bezoeker"}
|
||||||
|
{!isPerformer && (
|
||||||
|
<span className="ml-1 opacity-80">
|
||||||
|
— Drinkkaart €{data.drinkCardValue ?? 5}
|
||||||
|
{viewGuests.length > 0 && ` (${1 + viewGuests.length} personen)`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8">
|
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8">
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -378,17 +651,51 @@ function ManageRegistrationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-white/10 border-t pt-6">
|
{isPerformer && (
|
||||||
<p className="mb-2 text-sm text-white/50">Rol</p>
|
<div className="border-white/10 border-t pt-6">
|
||||||
<p className="text-lg text-white">
|
<p className="mb-2 text-sm text-white/50">Optreden</p>
|
||||||
{data.wantsToPerform
|
<p className="text-lg text-white">
|
||||||
? `Optreden${data.artForm ? ` — ${data.artForm}` : ""}`
|
{data.artForm || "—"}
|
||||||
: "Toeschouwer"}
|
{data.experience && (
|
||||||
</p>
|
<span className="ml-2 text-white/60">
|
||||||
{data.wantsToPerform && data.experience && (
|
({data.experience})
|
||||||
<p className="mt-1 text-white/60">{data.experience}</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/60">
|
||||||
|
{data.isOver16 ? "16+ bevestigd" : "Leeftijd niet bevestigd"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPerformer && viewGuests.length > 0 && (
|
||||||
|
<div className="border-white/10 border-t pt-6">
|
||||||
|
<p className="mb-3 text-sm text-white/50">
|
||||||
|
Medebezoekers ({viewGuests.length})
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{viewGuests.map((g, idx) => (
|
||||||
|
<div
|
||||||
|
key={`view-guest-${
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: stable index
|
||||||
|
idx
|
||||||
|
}`}
|
||||||
|
className="rounded border border-teal-400/20 bg-teal-400/5 p-3"
|
||||||
|
>
|
||||||
|
<p className="text-white">
|
||||||
|
{g.firstName} {g.lastName}
|
||||||
|
</p>
|
||||||
|
{g.email && (
|
||||||
|
<p className="text-sm text-white/60">{g.email}</p>
|
||||||
|
)}
|
||||||
|
{g.phone && (
|
||||||
|
<p className="text-sm text-white/60">{g.phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.extraQuestions && (
|
{data.extraQuestions && (
|
||||||
<div className="border-white/10 border-t pt-6">
|
<div className="border-white/10 border-t pt-6">
|
||||||
|
|||||||
@@ -12,19 +12,34 @@ import {
|
|||||||
} from "../email";
|
} from "../email";
|
||||||
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
|
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
|
||||||
|
|
||||||
|
const registrationTypeSchema = z.enum(["performer", "watcher"]);
|
||||||
|
|
||||||
|
const guestSchema = z.object({
|
||||||
|
firstName: z.string().min(1),
|
||||||
|
lastName: z.string().min(1),
|
||||||
|
email: z.string().email().optional().or(z.literal("")),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const submitRegistrationSchema = z.object({
|
const submitRegistrationSchema = z.object({
|
||||||
firstName: z.string().min(1),
|
firstName: z.string().min(1),
|
||||||
lastName: z.string().min(1),
|
lastName: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
wantsToPerform: z.boolean().default(false),
|
registrationType: registrationTypeSchema.default("watcher"),
|
||||||
|
// Performer-specific
|
||||||
artForm: z.string().optional(),
|
artForm: z.string().optional(),
|
||||||
experience: z.string().optional(),
|
experience: z.string().optional(),
|
||||||
|
isOver16: z.boolean().optional(),
|
||||||
|
// Watcher-specific: drinkCardValue is computed server-side, guests are named
|
||||||
|
guests: z.array(guestSchema).max(9).optional(),
|
||||||
|
// Shared
|
||||||
extraQuestions: z.string().optional(),
|
extraQuestions: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getRegistrationsSchema = z.object({
|
const getRegistrationsSchema = z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
|
registrationType: registrationTypeSchema.optional(),
|
||||||
artForm: z.string().optional(),
|
artForm: z.string().optional(),
|
||||||
fromDate: z.string().datetime().optional(),
|
fromDate: z.string().datetime().optional(),
|
||||||
toDate: z.string().datetime().optional(),
|
toDate: z.string().datetime().optional(),
|
||||||
@@ -48,15 +63,22 @@ export const appRouter = {
|
|||||||
.input(submitRegistrationSchema)
|
.input(submitRegistrationSchema)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
const managementToken = randomUUID();
|
const managementToken = randomUUID();
|
||||||
|
const isPerformer = input.registrationType === "performer";
|
||||||
|
const guests = isPerformer ? [] : (input.guests ?? []);
|
||||||
|
// €5 for primary registrant + €2 per extra guest
|
||||||
|
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
|
||||||
await db.insert(registration).values({
|
await db.insert(registration).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
lastName: input.lastName,
|
lastName: input.lastName,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
phone: input.phone || null,
|
phone: input.phone || null,
|
||||||
wantsToPerform: input.wantsToPerform,
|
registrationType: input.registrationType,
|
||||||
artForm: input.artForm || null,
|
artForm: isPerformer ? input.artForm || null : null,
|
||||||
experience: input.experience || null,
|
experience: isPerformer ? input.experience || null : null,
|
||||||
|
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
|
||||||
|
drinkCardValue,
|
||||||
|
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
||||||
extraQuestions: input.extraQuestions || null,
|
extraQuestions: input.extraQuestions || null,
|
||||||
managementToken,
|
managementToken,
|
||||||
});
|
});
|
||||||
@@ -65,7 +87,7 @@ export const appRouter = {
|
|||||||
to: input.email,
|
to: input.email,
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
managementToken,
|
managementToken,
|
||||||
wantsToPerform: input.wantsToPerform,
|
wantsToPerform: isPerformer,
|
||||||
artForm: input.artForm,
|
artForm: input.artForm,
|
||||||
}).catch((err) =>
|
}).catch((err) =>
|
||||||
console.error("Failed to send confirmation email:", err),
|
console.error("Failed to send confirmation email:", err),
|
||||||
@@ -98,9 +120,11 @@ export const appRouter = {
|
|||||||
lastName: z.string().min(1),
|
lastName: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
wantsToPerform: z.boolean().default(false),
|
registrationType: registrationTypeSchema.default("watcher"),
|
||||||
artForm: z.string().optional(),
|
artForm: z.string().optional(),
|
||||||
experience: z.string().optional(),
|
experience: z.string().optional(),
|
||||||
|
isOver16: z.boolean().optional(),
|
||||||
|
guests: z.array(guestSchema).max(9).optional(),
|
||||||
extraQuestions: z.string().optional(),
|
extraQuestions: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -119,6 +143,9 @@ export const appRouter = {
|
|||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
|
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
|
||||||
|
|
||||||
|
const isPerformer = input.registrationType === "performer";
|
||||||
|
const guests = isPerformer ? [] : (input.guests ?? []);
|
||||||
|
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
|
||||||
await db
|
await db
|
||||||
.update(registration)
|
.update(registration)
|
||||||
.set({
|
.set({
|
||||||
@@ -126,9 +153,12 @@ export const appRouter = {
|
|||||||
lastName: input.lastName,
|
lastName: input.lastName,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
phone: input.phone || null,
|
phone: input.phone || null,
|
||||||
wantsToPerform: input.wantsToPerform,
|
registrationType: input.registrationType,
|
||||||
artForm: input.artForm || null,
|
artForm: isPerformer ? input.artForm || null : null,
|
||||||
experience: input.experience || null,
|
experience: isPerformer ? input.experience || null : null,
|
||||||
|
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
|
||||||
|
drinkCardValue,
|
||||||
|
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
||||||
extraQuestions: input.extraQuestions || null,
|
extraQuestions: input.extraQuestions || null,
|
||||||
})
|
})
|
||||||
.where(eq(registration.managementToken, input.token));
|
.where(eq(registration.managementToken, input.token));
|
||||||
@@ -137,7 +167,7 @@ export const appRouter = {
|
|||||||
to: input.email,
|
to: input.email,
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
managementToken: input.token,
|
managementToken: input.token,
|
||||||
wantsToPerform: input.wantsToPerform,
|
wantsToPerform: isPerformer,
|
||||||
artForm: input.artForm,
|
artForm: input.artForm,
|
||||||
}).catch((err) => console.error("Failed to send update email:", err));
|
}).catch((err) => console.error("Failed to send update email:", err));
|
||||||
|
|
||||||
@@ -192,6 +222,12 @@ export const appRouter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.registrationType) {
|
||||||
|
conditions.push(
|
||||||
|
eq(registration.registrationType, input.registrationType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (input.artForm) {
|
if (input.artForm) {
|
||||||
conditions.push(eq(registration.artForm, input.artForm));
|
conditions.push(eq(registration.artForm, input.artForm));
|
||||||
}
|
}
|
||||||
@@ -235,20 +271,29 @@ export const appRouter = {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const [totalResult, todayResult, artFormResult] = await Promise.all([
|
const [totalResult, todayResult, artFormResult, typeResult] =
|
||||||
db.select({ count: count() }).from(registration),
|
await Promise.all([
|
||||||
db
|
db.select({ count: count() }).from(registration),
|
||||||
.select({ count: count() })
|
db
|
||||||
.from(registration)
|
.select({ count: count() })
|
||||||
.where(gte(registration.createdAt, today)),
|
.from(registration)
|
||||||
db
|
.where(gte(registration.createdAt, today)),
|
||||||
.select({
|
db
|
||||||
artForm: registration.artForm,
|
.select({
|
||||||
count: count(),
|
artForm: registration.artForm,
|
||||||
})
|
count: count(),
|
||||||
.from(registration)
|
})
|
||||||
.groupBy(registration.artForm),
|
.from(registration)
|
||||||
]);
|
.where(eq(registration.registrationType, "performer"))
|
||||||
|
.groupBy(registration.artForm),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
registrationType: registration.registrationType,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(registration)
|
||||||
|
.groupBy(registration.registrationType),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: totalResult[0]?.count ?? 0,
|
total: totalResult[0]?.count ?? 0,
|
||||||
@@ -257,6 +302,10 @@ export const appRouter = {
|
|||||||
artForm: r.artForm,
|
artForm: r.artForm,
|
||||||
count: r.count,
|
count: r.count,
|
||||||
})),
|
})),
|
||||||
|
byType: typeResult.map((r) => ({
|
||||||
|
registrationType: r.registrationType,
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -272,24 +321,46 @@ export const appRouter = {
|
|||||||
"Last Name",
|
"Last Name",
|
||||||
"Email",
|
"Email",
|
||||||
"Phone",
|
"Phone",
|
||||||
"Wants To Perform",
|
"Type",
|
||||||
"Art Form",
|
"Art Form",
|
||||||
"Experience",
|
"Experience",
|
||||||
|
"Is Over 16",
|
||||||
|
"Drink Card Value",
|
||||||
|
"Guest Count",
|
||||||
|
"Guests",
|
||||||
"Extra Questions",
|
"Extra Questions",
|
||||||
"Created At",
|
"Created At",
|
||||||
];
|
];
|
||||||
const rows = data.map((r) => [
|
const rows = data.map((r) => {
|
||||||
r.id,
|
const guests: Array<{
|
||||||
r.firstName,
|
firstName: string;
|
||||||
r.lastName,
|
lastName: string;
|
||||||
r.email,
|
email?: string;
|
||||||
r.phone || "",
|
phone?: string;
|
||||||
r.wantsToPerform ? "Yes" : "No",
|
}> = r.guests ? JSON.parse(r.guests) : [];
|
||||||
r.artForm || "",
|
const guestSummary = guests
|
||||||
r.experience || "",
|
.map(
|
||||||
r.extraQuestions || "",
|
(g) =>
|
||||||
r.createdAt.toISOString(),
|
`${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
|
||||||
]);
|
)
|
||||||
|
.join(" | ");
|
||||||
|
return [
|
||||||
|
r.id,
|
||||||
|
r.firstName,
|
||||||
|
r.lastName,
|
||||||
|
r.email,
|
||||||
|
r.phone || "",
|
||||||
|
r.registrationType,
|
||||||
|
r.artForm || "",
|
||||||
|
r.experience || "",
|
||||||
|
r.isOver16 ? "Yes" : "No",
|
||||||
|
String(r.drinkCardValue ?? 0),
|
||||||
|
String(guests.length),
|
||||||
|
guestSummary,
|
||||||
|
r.extraQuestions || "",
|
||||||
|
r.createdAt.toISOString(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
headers.join(","),
|
headers.join(","),
|
||||||
@@ -321,16 +392,17 @@ export const appRouter = {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingRequest.length > 0) {
|
if (existingRequest.length > 0) {
|
||||||
if (existingRequest[0].status === "pending") {
|
const existing = existingRequest[0]!;
|
||||||
|
if (existing.status === "pending") {
|
||||||
return { success: false, message: "Je hebt al een aanvraag openstaan" };
|
return { success: false, message: "Je hebt al een aanvraag openstaan" };
|
||||||
}
|
}
|
||||||
if (existingRequest[0].status === "approved") {
|
if (existing.status === "approved") {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Je aanvraag is al goedgekeurd, log opnieuw in",
|
message: "Je aanvraag is al goedgekeurd, log opnieuw in",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (existingRequest[0].status === "rejected") {
|
if (existing.status === "rejected") {
|
||||||
// Allow re-requesting if previously rejected
|
// Allow re-requesting if previously rejected
|
||||||
await db
|
await db
|
||||||
.update(adminRequest)
|
.update(adminRequest)
|
||||||
@@ -387,7 +459,8 @@ export const appRouter = {
|
|||||||
throw new Error("Aanvraag niet gevonden");
|
throw new Error("Aanvraag niet gevonden");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request[0].status !== "pending") {
|
const req = request[0]!;
|
||||||
|
if (req.status !== "pending") {
|
||||||
throw new Error("Deze aanvraag is al behandeld");
|
throw new Error("Deze aanvraag is al behandeld");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +478,7 @@ export const appRouter = {
|
|||||||
await db
|
await db
|
||||||
.update(user)
|
.update(user)
|
||||||
.set({ role: "admin" })
|
.set({ role: "admin" })
|
||||||
.where(eq(user.id, request[0].userId));
|
.where(eq(user.id, req.userId));
|
||||||
|
|
||||||
return { success: true, message: "Admin toegang goedgekeurd" };
|
return { success: true, message: "Admin toegang goedgekeurd" };
|
||||||
}),
|
}),
|
||||||
@@ -423,7 +496,8 @@ export const appRouter = {
|
|||||||
throw new Error("Aanvraag niet gevonden");
|
throw new Error("Aanvraag niet gevonden");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request[0].status !== "pending") {
|
const req = request[0]!;
|
||||||
|
if (req.status !== "pending") {
|
||||||
throw new Error("Deze aanvraag is al behandeld");
|
throw new Error("Deze aanvraag is al behandeld");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import dotenv from "dotenv";
|
|||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: "../../apps/web/.env",
|
path: "../env/.env",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: Replace wantsToPerform with registrationType + add isOver16 + drinkCardValue
|
||||||
|
-- Migrate existing 'wants_to_perform' data to 'registration_type' before dropping
|
||||||
|
|
||||||
|
ALTER TABLE `registration` ADD `registration_type` text NOT NULL DEFAULT 'watcher';--> statement-breakpoint
|
||||||
|
ALTER TABLE `registration` ADD `is_over_16` integer NOT NULL DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE `registration` ADD `drink_card_value` integer DEFAULT 0;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Backfill registration_type from wants_to_perform
|
||||||
|
UPDATE `registration` SET `registration_type` = 'performer' WHERE `wants_to_perform` = 1;--> statement-breakpoint
|
||||||
|
UPDATE `registration` SET `registration_type` = 'watcher' WHERE `wants_to_perform` = 0;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Backfill drink_card_value for watchers
|
||||||
|
UPDATE `registration` SET `drink_card_value` = 5 WHERE `wants_to_perform` = 0;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Create index on registration_type
|
||||||
|
CREATE INDEX `registration_registrationType_idx` ON `registration` (`registration_type`);--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Drop old wants_to_perform column (SQLite requires recreating the table)
|
||||||
|
-- SQLite doesn't support DROP COLUMN directly in older versions, but Turso/libSQL does
|
||||||
|
ALTER TABLE `registration` DROP COLUMN `wants_to_perform`;
|
||||||
2
packages/db/src/migrations/0003_add_guests.sql
Normal file
2
packages/db/src/migrations/0003_add_guests.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Migration: Add guests column (JSON text) to registration table
|
||||||
|
ALTER TABLE `registration` ADD `guests` text;
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1772480778632,
|
"when": 1772480778632,
|
||||||
"tag": "0001_third_stark_industries",
|
"tag": "0001_third_stark_industries",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772520000000,
|
||||||
|
"tag": "0002_registration_type_redesign",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,19 @@ export const registration = sqliteTable(
|
|||||||
lastName: text("last_name").notNull(),
|
lastName: text("last_name").notNull(),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
wantsToPerform: integer("wants_to_perform", { mode: "boolean" })
|
// registrationType: 'performer' | 'watcher'
|
||||||
.notNull()
|
registrationType: text("registration_type").notNull().default("watcher"),
|
||||||
.default(false),
|
// Performer-specific fields
|
||||||
artForm: text("art_form"),
|
artForm: text("art_form"),
|
||||||
experience: text("experience"),
|
experience: text("experience"),
|
||||||
|
isOver16: integer("is_over_16", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
// Watcher-specific fields
|
||||||
|
drinkCardValue: integer("drink_card_value").default(0),
|
||||||
|
// Guests: JSON array of {firstName, lastName, email?, phone?} objects
|
||||||
|
guests: text("guests"),
|
||||||
|
// Shared
|
||||||
extraQuestions: text("extra_questions"),
|
extraQuestions: text("extra_questions"),
|
||||||
managementToken: text("management_token").unique(),
|
managementToken: text("management_token").unique(),
|
||||||
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),
|
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),
|
||||||
@@ -23,6 +31,7 @@ export const registration = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("registration_email_idx").on(table.email),
|
index("registration_email_idx").on(table.email),
|
||||||
|
index("registration_registrationType_idx").on(table.registrationType),
|
||||||
index("registration_artForm_idx").on(table.artForm),
|
index("registration_artForm_idx").on(table.artForm),
|
||||||
index("registration_createdAt_idx").on(table.createdAt),
|
index("registration_createdAt_idx").on(table.createdAt),
|
||||||
index("registration_managementToken_idx").on(table.managementToken),
|
index("registration_managementToken_idx").on(table.managementToken),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const web = await TanStackStart("web", {
|
|||||||
SMTP_PASS: getEnvVar("SMTP_PASS"),
|
SMTP_PASS: getEnvVar("SMTP_PASS"),
|
||||||
SMTP_FROM: getEnvVar("SMTP_FROM"),
|
SMTP_FROM: getEnvVar("SMTP_FROM"),
|
||||||
},
|
},
|
||||||
domains: ["kunstenkamp.be"],
|
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Web -> ${web.url}`);
|
console.log(`Web -> ${web.url}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user