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,225 @@
|
||||
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 { PollVoteForm } from "./poll-vote-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PollDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const pollId = Number.parseInt(id, 10);
|
||||
if (!Number.isFinite(pollId) || pollId <= 0) notFound();
|
||||
|
||||
const t = await getTranslations("pages.polls");
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
|
||||
const poll = await prisma.websitePoll
|
||||
.findUnique({
|
||||
where: { id: pollId },
|
||||
include: {
|
||||
questions: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!poll || (poll.status !== "active" && poll.status !== "closed")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (poll.startsAt && poll.startsAt.getTime() > now && poll.status === "active") {
|
||||
// Not started yet — treat as unavailable for guests
|
||||
notFound();
|
||||
}
|
||||
|
||||
const hasEnded =
|
||||
poll.status === "closed" ||
|
||||
(poll.endsAt != null && poll.endsAt.getTime() < now);
|
||||
const isOpen = poll.status === "active" && !hasEnded;
|
||||
|
||||
const questionIds = poll.questions.map((q) => q.id);
|
||||
const myVotes =
|
||||
Number.isInteger(userId) && userId > 0 && questionIds.length > 0
|
||||
? await prisma.websitePollVote
|
||||
.findMany({
|
||||
where: { userId, questionId: { in: questionIds } },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const hasVoted = myVotes.length > 0;
|
||||
|
||||
const allVotes =
|
||||
poll.showResults === 1
|
||||
? await prisma.websitePollVote
|
||||
.findMany({
|
||||
where: { questionId: { in: questionIds } },
|
||||
select: { questionId: true, answer: true },
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
|
||||
const votesByQuestion = new Map<number, string[]>();
|
||||
for (const vote of allVotes) {
|
||||
const list = votesByQuestion.get(vote.questionId) ?? [];
|
||||
list.push(vote.answer);
|
||||
votesByQuestion.set(vote.questionId, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
<Link href="/polls">{t("back")}</Link>
|
||||
</p>
|
||||
|
||||
<ContentCard
|
||||
icon="📊"
|
||||
title={poll.title}
|
||||
subtitle={
|
||||
hasEnded
|
||||
? t("statusClosed")
|
||||
: t("statusActive") +
|
||||
(poll.endsAt
|
||||
? ` · ${t("ends", { date: formatDate(poll.endsAt, "datetime", "") })}`
|
||||
: "")
|
||||
}
|
||||
>
|
||||
{poll.description ? (
|
||||
<p style={{ margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
|
||||
{poll.description}
|
||||
</p>
|
||||
) : null}
|
||||
</ContentCard>
|
||||
|
||||
{isOpen && !hasVoted ? (
|
||||
session?.user ? (
|
||||
<ContentCard icon="🗳️" title={t("voteTitle")} subtitle={t("voteSubtitle")}>
|
||||
<PollVoteForm
|
||||
pollId={poll.id}
|
||||
questions={poll.questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
type: q.type,
|
||||
options: q.options,
|
||||
}))}
|
||||
/>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard padded>
|
||||
<EmptyState icon="🔒">{t("loginToVote")}</EmptyState>
|
||||
</ContentCard>
|
||||
)
|
||||
) : (
|
||||
<ContentCard padded>
|
||||
<EmptyState icon={hasVoted ? "✅" : "ℹ️"}>
|
||||
{hasVoted
|
||||
? t("alreadyVoted")
|
||||
: hasEnded
|
||||
? t("pollClosed")
|
||||
: t("cannotVote")}
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{poll.showResults === 1 && (hasVoted || hasEnded) ? (
|
||||
<ContentCard icon="📈" title={t("resultsTitle")}>
|
||||
<div style={{ display: "grid", gap: "1.25rem" }}>
|
||||
{poll.questions.map((q) => {
|
||||
const options = q.options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
const votes = votesByQuestion.get(q.id) ?? [];
|
||||
const total = votes.length;
|
||||
|
||||
if (q.type === "text" || options.length === 0) {
|
||||
return (
|
||||
<div key={q.id}>
|
||||
<h3 style={{ margin: "0 0 0.4rem", fontSize: "1rem" }}>
|
||||
{q.question}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t("textResponses", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const opt of options) counts.set(opt, 0);
|
||||
for (const vote of votes) {
|
||||
for (const part of vote.split("\n").map((a) => a.trim()).filter(Boolean)) {
|
||||
counts.set(part, (counts.get(part) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={q.id}>
|
||||
<h3 style={{ margin: "0 0 0.65rem", fontSize: "1rem" }}>
|
||||
{q.question}
|
||||
</h3>
|
||||
<div style={{ display: "grid", gap: "0.55rem" }}>
|
||||
{options.map((opt) => {
|
||||
const count = counts.get(opt) ?? 0;
|
||||
const pct =
|
||||
total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div key={opt}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.85rem",
|
||||
marginBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
<span>{opt}</span>
|
||||
<span className="muted">
|
||||
{t("voteCount", { count, pct })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 16%, transparent)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${pct}%`,
|
||||
borderRadius: 999,
|
||||
background: "var(--color-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0.5rem 0 0", fontSize: "0.8rem" }}
|
||||
>
|
||||
{t("totalVotes", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { voteOnPoll } from "@/actions/polls";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
question: string;
|
||||
type: string;
|
||||
options: string;
|
||||
}
|
||||
|
||||
function parseOptions(options: string): string[] {
|
||||
return options
|
||||
.split("\n")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function PollVoteForm({
|
||||
pollId,
|
||||
questions,
|
||||
}: {
|
||||
pollId: number;
|
||||
questions: Question[];
|
||||
}) {
|
||||
const t = useTranslations("pages.polls");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({});
|
||||
const [multi, setMulti] = useState<Record<number, string[]>>({});
|
||||
|
||||
function setSingle(questionId: number, value: string) {
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
}
|
||||
|
||||
function toggleMulti(questionId: number, option: string) {
|
||||
setMulti((prev) => {
|
||||
const current = prev[questionId] ?? [];
|
||||
const next = current.includes(option)
|
||||
? current.filter((o) => o !== option)
|
||||
: [...current, option];
|
||||
return { ...prev, [questionId]: next };
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const votes = questions.map((q) => {
|
||||
if (q.type === "multiple") {
|
||||
return {
|
||||
questionId: q.id,
|
||||
answer: (multi[q.id] ?? []).join("\n"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
questionId: q.id,
|
||||
answer: (answers[q.id] ?? "").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
if (votes.some((v) => !v.answer)) {
|
||||
toast.error(t("voteError"));
|
||||
return;
|
||||
}
|
||||
|
||||
run(() => voteOnPoll({ pollId, votes }), {
|
||||
successMessage: t("voteSuccess"),
|
||||
errorMessage: t("voteError"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: "grid", gap: "1.25rem" }}>
|
||||
{questions.map((q) => {
|
||||
const options = parseOptions(q.options);
|
||||
return (
|
||||
<fieldset
|
||||
key={q.id}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
|
||||
borderRadius: "0.65rem",
|
||||
}}
|
||||
>
|
||||
<legend style={{ padding: "0 0.35rem", fontWeight: 700 }}>
|
||||
{q.question}
|
||||
</legend>
|
||||
|
||||
{q.type === "text" ? (
|
||||
<textarea
|
||||
required
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
value={answers[q.id] ?? ""}
|
||||
onChange={(e) => setSingle(q.id, e.target.value)}
|
||||
placeholder={t("textPlaceholder")}
|
||||
style={{ width: "100%", marginTop: "0.35rem", resize: "vertical" }}
|
||||
/>
|
||||
) : q.type === "multiple" ? (
|
||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(multi[q.id] ?? []).includes(opt)}
|
||||
onChange={() => toggleMulti(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
required
|
||||
checked={(answers[q.id] ?? "") === opt}
|
||||
onChange={() => setSingle(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={isPending}>
|
||||
{isPending ? t("voting") : t("submitVote")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata = { title: "Polls" };
|
||||
|
||||
export default async function PollsPage() {
|
||||
const t = await getTranslations("pages.polls");
|
||||
const now = new Date();
|
||||
|
||||
const polls = await prisma.websitePoll
|
||||
.findMany({
|
||||
where: {
|
||||
status: { in: ["active", "closed"] },
|
||||
OR: [{ startsAt: null }, { startsAt: { lte: now } }],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
_count: { select: { questions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard icon="📊" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={polls.length === 0}>
|
||||
{polls.length === 0 ? (
|
||||
<EmptyState icon="📊">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{polls.map((poll) => {
|
||||
const ended =
|
||||
poll.status === "closed" ||
|
||||
(poll.endsAt != null && poll.endsAt.getTime() < Date.now());
|
||||
return (
|
||||
<Link
|
||||
key={poll.id}
|
||||
href={`/polls/${poll.id}`}
|
||||
className="card hover"
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
display: "grid",
|
||||
gap: "0.35rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{ended ? t("statusClosed") : t("statusActive")}
|
||||
{" · "}
|
||||
{t("questionsCount", { count: poll._count.questions })}
|
||||
</p>
|
||||
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
|
||||
{poll.title}
|
||||
</h3>
|
||||
{poll.description ? (
|
||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
||||
{excerpt(poll.description, 120)}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: 0, fontSize: "0.85rem" }}
|
||||
>
|
||||
{poll.endsAt
|
||||
? t("ends", {
|
||||
date: formatDate(poll.endsAt, "datetime", ""),
|
||||
})
|
||||
: formatDate(poll.createdAt, "date", "")}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user