Files
Epicnabbo-Catalogus-Updated…/src/app/(site)/polls/[id]/page.tsx
T
openhands dc8fb8a6ed feat: speed up admin clone import and enable Cache Components
- Clone import: defer FurnitureData.json writes and append all entries in a
  single batched write instead of one read-modify-write per item, removing
  the main serialization bottleneck for large batches.
- Clone import: raise SSE batch concurrency cap from 5 to 10 and bump the
  clone client/route default from 2 to 6.
- Add a flush hook to runSseBatch so callers can batch deferred work before
  batch_complete is emitted, and surface flush errors as an error event.
- Enable Next.js Cache Components (instant: false opt-out) and silence the
  related build warnings in next.config.ts.
- Switch isomorphic-dompurify to dompurify and refresh dependencies.
2026-08-05 11:10:16 +02:00

257 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, asc, eq, inArray } from "drizzle-orm";
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 {
db,
WebsitePoll,
WebsitePollQuestion,
WebsitePollVote,
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PollVoteForm } from "./poll-vote-form";
// TODO: Cache Components adoption. Refactor this route so this opt-out can be removed.
// See: https://nextjs.org/docs/app/guides/migrating-to-cache-components
export const instant = false;
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 db
.select()
.from(WebsitePoll)
.where(eq(WebsitePoll.id, pollId))
.limit(1)
.catch(() => []);
if (!poll || (poll.status !== "active" && poll.status !== "closed")) {
notFound();
}
const questions = await db
.select()
.from(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.pollId, poll.id))
.orderBy(asc(WebsitePollQuestion.sortOrder))
.catch(() => []);
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 = questions.map((q) => q.id);
const myVotes =
Number.isInteger(userId) && userId > 0 && questionIds.length > 0
? await db
.select()
.from(WebsitePollVote)
.where(
and(
eq(WebsitePollVote.userId, userId),
inArray(WebsitePollVote.questionId, questionIds),
),
)
.catch(() => [])
: [];
const hasVoted = myVotes.length > 0;
const allVotes =
poll.showResults === 1 && questionIds.length > 0
? await db
.select({
questionId: WebsitePollVote.questionId,
answer: WebsitePollVote.answer,
})
.from(WebsitePollVote)
.where(inArray(WebsitePollVote.questionId, questionIds))
.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={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" }}>
{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 voteCount = counts.get(opt) ?? 0;
const pct =
total > 0 ? Math.round((voteCount / 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: voteCount, 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>
);
}