Drei Zustaende in der Kopfzeile — System, Hell, Dunkel. Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick fuer immer eine feste Wahl bedeutet; wer "System" waehlt, bekommt abends dunkel und morgens hell. Umgesetzt ueber `light-dark()` im CSS, nicht ueber `dark:`-Klassen. Die Oberflaeche benutzt an rund 440 Stellen feste Farbklassen; jede davon zu verdoppeln waere eine riesige Fleissarbeit, und jede kuenftige Zeile muesste daran denken. Stattdessen entscheiden die Farbvariablen selbst, welchen Wert sie tragen — gesteuert ueber `color-scheme`. Deshalb genuegt fuer den Umschalter ein Attribut am html-Element, und die Systemwahl funktioniert sogar ohne JavaScript. Das Skript im Kopf setzt nur eine ausdrueckliche Wahl, damit die helle Fassung nicht kurz aufblitzt. Zwei Faelle liessen sich nicht ueber Variablen loesen: `bg-white` muss im Dunkeln eine dunkle Flaeche sein, `text-white` dagegen weiss bleiben — beide teilen sich aber dieselbe Variable. Fuer Flaechen gibt es deshalb jetzt `bg-surface`. Ebenso brauchen Akzent-Links im Dunkeln eine hellere Stufe als Akzent-Knoepfe: dafuer `text-accent-text` und `bg-accent-soft`. Bewusst unveraendert bleiben die QR-Codes selbst — deren Farben legt die Gestaltung fest. Die dunklen Werte sind keine Spiegelung: Reines Schwarz unter weisser Schrift flimmert. Alle Textkontraste sind gemessen und liegen ueber 4,5:1, der niedrigste bei 5,9:1.
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
|
|
import { Icon } from "@/components/icons";
|
|
import { Card } from "@/components/ui";
|
|
import { requirePageActor } from "@/lib/auth/guard";
|
|
import { can } from "@/lib/auth/policy";
|
|
import { prisma } from "@/lib/db";
|
|
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export const metadata: Metadata = { title: "Rückmeldungen" };
|
|
|
|
type PageProps = { params: Promise<{ id: string }> };
|
|
|
|
function formatDate(value: Date): string {
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(value);
|
|
}
|
|
|
|
export default async function FeedbackResultsPage({ params }: PageProps) {
|
|
const { id } = await params;
|
|
const actor = await requirePageActor();
|
|
|
|
const code = await prisma.qrCode.findUnique({
|
|
where: { id },
|
|
include: {
|
|
feedback: { orderBy: { createdAt: "desc" }, take: 200 },
|
|
},
|
|
});
|
|
|
|
if (!code || code.type !== "feedback") notFound();
|
|
if (!can(actor, "codes:read", { groupId: code.groupId })) notFound();
|
|
|
|
const daten = feedbackDataSchema.safeParse(code.data);
|
|
const skala = daten.success ? daten.data.scale : "stars";
|
|
const titel = daten.success ? daten.data.title : (code.label ?? "Feedback");
|
|
|
|
const eintraege = code.feedback;
|
|
const anzahl = eintraege.length;
|
|
const schnitt =
|
|
anzahl > 0
|
|
? eintraege.reduce((summe, eintrag) => summe + eintrag.rating, 0) / anzahl
|
|
: 0;
|
|
|
|
// Verteilung von 5 nach 1 — so liest man eine Bewertungsübersicht.
|
|
const verteilung = [5, 4, 3, 2, 1].map((note) => ({
|
|
note,
|
|
anzahl: eintraege.filter((eintrag) => eintrag.rating === note).length,
|
|
}));
|
|
const groesste = Math.max(1, ...verteilung.map((zeile) => zeile.anzahl));
|
|
|
|
const kommentare = eintraege.filter((eintrag) => eintrag.comment);
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
|
<Link
|
|
href="/meine-codes"
|
|
className="mb-5 inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
|
>
|
|
<Icon name="arrowLeft" className="size-4" />
|
|
Zurück zu Meine Codes
|
|
</Link>
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
|
{titel}
|
|
</h1>
|
|
<p className="mt-2 text-slate-600">
|
|
{anzahl === 0
|
|
? "Es ist noch keine Rückmeldung eingegangen."
|
|
: `${anzahl} ${anzahl === 1 ? "Rückmeldung" : "Rückmeldungen"} · ${code.scans} ${code.scans === 1 ? "Scan" : "Scans"}`}
|
|
</p>
|
|
|
|
{anzahl > 0 ? (
|
|
<>
|
|
<Card className="mt-7">
|
|
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
|
|
<div>
|
|
<p className="text-4xl font-bold text-slate-900 tabular-nums">
|
|
{schnitt.toFixed(1).replace(".", ",")}
|
|
</p>
|
|
<p className="text-sm text-slate-500">von 5 im Schnitt</p>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
{verteilung.map((zeile) => (
|
|
<div key={zeile.note} className="flex items-center gap-2.5">
|
|
<span className="w-10 shrink-0 text-right text-sm text-slate-500 tabular-nums">
|
|
{zeile.note}{" "}
|
|
<span aria-hidden="true">
|
|
{FEEDBACK_SCALES[skala].symbols[zeile.note - 1]}
|
|
</span>
|
|
</span>
|
|
<span className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-100">
|
|
<span
|
|
className="block h-full rounded-full bg-indigo-500"
|
|
style={{
|
|
width: `${Math.round((zeile.anzahl / groesste) * 100)}%`,
|
|
}}
|
|
/>
|
|
</span>
|
|
<span className="w-8 shrink-0 text-sm text-slate-600 tabular-nums">
|
|
{zeile.anzahl}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{kommentare.length > 0 ? (
|
|
<div className="mt-7">
|
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">
|
|
Kommentare ({kommentare.length})
|
|
</h2>
|
|
<ul className="space-y-3">
|
|
{kommentare.map((eintrag) => (
|
|
<li
|
|
key={eintrag.id}
|
|
className="rounded-xl border border-slate-200 bg-surface p-4 shadow-sm"
|
|
>
|
|
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
|
|
<span className="font-medium text-slate-900 tabular-nums">
|
|
{eintrag.rating}/5
|
|
</span>
|
|
<span aria-hidden="true">·</span>
|
|
<time dateTime={eintrag.createdAt.toISOString()}>
|
|
{formatDate(eintrag.createdAt)}
|
|
</time>
|
|
</div>
|
|
<p className="whitespace-pre-wrap text-slate-800">
|
|
{eintrag.comment}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{anzahl >= 200 ? (
|
|
<p className="mt-3 text-sm text-slate-500">
|
|
Angezeigt werden die 200 jüngsten Rückmeldungen.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p className="mt-7 text-slate-600">
|
|
Bisher hat niemand etwas dazugeschrieben.
|
|
</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Card className="mt-7">
|
|
<p className="text-slate-600">
|
|
Sobald jemand den Code scannt und eine Bewertung abgibt, steht sie
|
|
hier. Die Rückmeldungen enthalten keinen Namen und keine
|
|
IP-Adresse.
|
|
</p>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|