feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { registerForEvent } from "@/actions/events";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export function EventRegisterButton({
|
||||
eventId,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: {
|
||||
eventId: number;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.events");
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{disabledReason ?? t("cannotRegister")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => registerForEvent({ eventId }), {
|
||||
successMessage: t("registerSuccess"),
|
||||
errorMessage: t("registerError"),
|
||||
})
|
||||
}
|
||||
>
|
||||
{isPending ? t("registering") : t("register")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EventRegisterButton } from "./event-register-button";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function parseEventId(slugOrId: string): number | null {
|
||||
if (/^\d+$/.test(slugOrId)) return Number.parseInt(slugOrId, 10);
|
||||
const match = slugOrId.match(/-(\d+)$/);
|
||||
if (match) return Number.parseInt(match[1], 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slugOrId: string }>;
|
||||
}) {
|
||||
const { slugOrId } = await params;
|
||||
const eventId = parseEventId(slugOrId);
|
||||
if (eventId == null || !Number.isFinite(eventId)) notFound();
|
||||
|
||||
const t = await getTranslations("pages.events");
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
|
||||
const event = await prisma.websiteEvent
|
||||
.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
type: true,
|
||||
prizes: { orderBy: { position: "asc" } },
|
||||
winners: { orderBy: { position: "asc" } },
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!event || (event.status !== "published" && event.status !== "completed")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const alreadyRegistered =
|
||||
Number.isInteger(userId) && userId > 0
|
||||
? Boolean(
|
||||
await prisma.websiteEventRegistration
|
||||
.findUnique({
|
||||
where: { eventId_userId: { eventId: event.id, userId } },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
: false;
|
||||
|
||||
const winnerIds = event.winners.map((w) => w.userId);
|
||||
const winners =
|
||||
winnerIds.length > 0
|
||||
? await prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: winnerIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const winnerName = new Map(winners.map((u) => [u.id, u.username]));
|
||||
|
||||
const isFull =
|
||||
event.maxPlayers != null &&
|
||||
event._count.registrations >= event.maxPlayers;
|
||||
const hasEnded =
|
||||
event.status === "completed" ||
|
||||
(event.endsAt != null && event.endsAt.getTime() < Date.now());
|
||||
const canRegister =
|
||||
event.status === "published" && !hasEnded && !isFull && !alreadyRegistered;
|
||||
|
||||
let disabledReason: string | undefined;
|
||||
if (!session?.user) disabledReason = t("loginToRegister");
|
||||
else if (alreadyRegistered) disabledReason = t("alreadyRegistered");
|
||||
else if (hasEnded) disabledReason = t("eventEnded");
|
||||
else if (isFull) disabledReason = t("eventFull");
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/events">{t("back")}</Link>
|
||||
</p>
|
||||
|
||||
{event.image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "21/9",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={event.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="📅"
|
||||
title={event.title}
|
||||
subtitle={`${event.type.name} · ${formatDate(event.startsAt, "datetime", "")}`}
|
||||
>
|
||||
<div style={{ display: "grid", gap: "0.75rem" }}>
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
|
||||
{event.description}
|
||||
</p>
|
||||
<table style={{ width: "100%" }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={{ width: 140, textAlign: "left" }}>{t("when")}</th>
|
||||
<td>
|
||||
{formatDate(event.startsAt, "datetime", "")}
|
||||
{event.endsAt
|
||||
? ` — ${formatDate(event.endsAt, "datetime", "")}`
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>{t("status")}</th>
|
||||
<td>
|
||||
{event.status === "completed"
|
||||
? t("statusCompleted")
|
||||
: t("statusPublished")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>{t("registrations")}</th>
|
||||
<td>
|
||||
{event.maxPlayers != null
|
||||
? t("spots", {
|
||||
count: event._count.registrations,
|
||||
max: event.maxPlayers,
|
||||
})
|
||||
: t("registered", {
|
||||
count: event._count.registrations,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard icon="✍️" title={t("registerTitle")} subtitle={t("registerSubtitle")}>
|
||||
{session?.user && canRegister ? (
|
||||
<EventRegisterButton eventId={event.id} />
|
||||
) : (
|
||||
<EmptyState icon={session?.user ? "ℹ️" : "🔒"}>
|
||||
{disabledReason ?? t("cannotRegister")}
|
||||
</EmptyState>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{event.prizes.length > 0 ? (
|
||||
<ContentCard icon="🎁" title={t("prizesTitle")}>
|
||||
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
|
||||
{event.prizes.map((prize) => (
|
||||
<li key={prize.id} style={{ marginBottom: "0.35rem" }}>
|
||||
<strong>#{prize.position}</strong>
|
||||
{prize.description
|
||||
? ` — ${prize.description}`
|
||||
: ` — ${prize.prizeType}`}
|
||||
{prize.credits > 0 ? ` · ${prize.credits} credits` : ""}
|
||||
{prize.pixels > 0 ? ` · ${prize.pixels} pixels` : ""}
|
||||
{prize.points > 0 ? ` · ${prize.points} points` : ""}
|
||||
{prize.badgeCode ? ` · badge ${prize.badgeCode}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
{event.winners.length > 0 ? (
|
||||
<ContentCard icon="🏆" title={t("winnersTitle")}>
|
||||
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
|
||||
{event.winners.map((w) => (
|
||||
<li key={w.id}>
|
||||
#{w.position} —{" "}
|
||||
{winnerName.get(w.userId) ?? t("unknownUser", { id: w.userId })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt, slugify } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata = { title: "Events" };
|
||||
|
||||
export default async function EventsPage() {
|
||||
const t = await getTranslations("pages.events");
|
||||
|
||||
const events = await prisma.websiteEvent
|
||||
.findMany({
|
||||
where: { status: { in: ["published", "completed"] } },
|
||||
orderBy: { startsAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
type: { select: { name: true, color: true, slug: true } },
|
||||
_count: { select: { registrations: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={events.length === 0}>
|
||||
{events.length === 0 ? (
|
||||
<EmptyState icon="📅">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{events.map((event) => {
|
||||
const href = `/events/${slugify(event.title)}-${event.id}`;
|
||||
const spots =
|
||||
event.maxPlayers != null
|
||||
? t("spots", {
|
||||
count: event._count.registrations,
|
||||
max: event.maxPlayers,
|
||||
})
|
||||
: t("registered", { count: event._count.registrations });
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={event.id}
|
||||
href={href}
|
||||
className="card hover"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
textDecoration: "none",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
{event.image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={event.image}
|
||||
alt=""
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16/9",
|
||||
backgroundColor:
|
||||
event.type.color ||
|
||||
"color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.1rem",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.35rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{event.type.name}
|
||||
{event.status === "completed"
|
||||
? ` · ${t("statusCompleted")}`
|
||||
: ""}
|
||||
</p>
|
||||
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.85rem" }}
|
||||
>
|
||||
{formatDate(event.startsAt, "datetime", "")}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
||||
{excerpt(event.description, 120)}
|
||||
</p>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "auto 0 0", fontSize: "0.8rem" }}
|
||||
>
|
||||
{spots}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user