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.
149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
|
|
import { LandingShell } from "@/components/landing/shell";
|
|
import type { LandingProps } from "@/components/landing/types";
|
|
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
|
|
|
|
export function FeedbackLanding({ data, codeId }: LandingProps) {
|
|
const { title, question, scale, allowComment, commentLabel, thankYou } =
|
|
feedbackDataSchema.parse(data);
|
|
|
|
const [note, setNote] = useState<number | null>(null);
|
|
const [kommentar, setKommentar] = useState("");
|
|
const [laeuft, setLaeuft] = useState(false);
|
|
const [fertig, setFertig] = useState(false);
|
|
const [fehler, setFehler] = useState<string | null>(null);
|
|
|
|
const symbole = FEEDBACK_SCALES[scale].symbols;
|
|
|
|
async function absenden() {
|
|
if (note === null) return;
|
|
setLaeuft(true);
|
|
setFehler(null);
|
|
try {
|
|
const response = await fetch("/api/feedback", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ codeId, rating: note, comment: kommentar }),
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
setFehler(body?.error ?? "Die Rückmeldung konnte nicht gesendet werden.");
|
|
return;
|
|
}
|
|
setFertig(true);
|
|
} catch {
|
|
setFehler(
|
|
"Die Rückmeldung konnte nicht gesendet werden. Besteht die Verbindung noch?",
|
|
);
|
|
} finally {
|
|
setLaeuft(false);
|
|
}
|
|
}
|
|
|
|
if (fertig) {
|
|
return (
|
|
<LandingShell title={title}>
|
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-6 py-10 text-center">
|
|
<p className="text-4xl" aria-hidden="true">
|
|
✓
|
|
</p>
|
|
<p className="mt-3 text-lg font-medium text-emerald-900">
|
|
{thankYou}
|
|
</p>
|
|
</div>
|
|
</LandingShell>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<LandingShell title={title}>
|
|
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6">
|
|
<fieldset>
|
|
<legend className="text-center text-base font-medium text-slate-900">
|
|
{question}
|
|
</legend>
|
|
|
|
<div className="mt-5 flex justify-center gap-1.5 sm:gap-2">
|
|
{symbole.map((symbol, index) => {
|
|
const wert = index + 1;
|
|
const gewaehlt = note !== null && note >= wert;
|
|
return (
|
|
<button
|
|
key={wert}
|
|
type="button"
|
|
aria-label={`${wert} von 5`}
|
|
aria-pressed={note === wert}
|
|
disabled={laeuft}
|
|
onClick={() => setNote(wert)}
|
|
className={
|
|
scale === "stars"
|
|
? `rounded-lg px-1 text-4xl transition-transform hover:scale-110 sm:text-5xl ${
|
|
gewaehlt ? "text-amber-400" : "text-slate-200"
|
|
}`
|
|
: `rounded-lg px-1 text-3xl transition-transform hover:scale-110 sm:text-4xl ${
|
|
note === wert
|
|
? "scale-110"
|
|
: "opacity-40 grayscale"
|
|
}`
|
|
}
|
|
>
|
|
{symbol}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{note !== null ? (
|
|
<p className="mt-2 text-center text-sm text-slate-500">
|
|
{note} von 5
|
|
</p>
|
|
) : null}
|
|
</fieldset>
|
|
|
|
{allowComment ? (
|
|
<div className="mt-6">
|
|
<label
|
|
htmlFor="feedback-comment"
|
|
className="mb-1.5 block text-sm font-medium text-slate-700"
|
|
>
|
|
{commentLabel}
|
|
</label>
|
|
<textarea
|
|
id="feedback-comment"
|
|
rows={3}
|
|
maxLength={1000}
|
|
value={kommentar}
|
|
disabled={laeuft}
|
|
onChange={(event) => setKommentar(event.target.value)}
|
|
className="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
|
placeholder="Optional"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{fehler ? (
|
|
<p role="alert" className="mt-4 text-sm text-red-600">
|
|
{fehler}
|
|
</p>
|
|
) : null}
|
|
|
|
<button
|
|
type="button"
|
|
disabled={note === null || laeuft}
|
|
onClick={() => void absenden()}
|
|
className="mt-6 w-full rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
|
>
|
|
{laeuft ? "Wird gesendet …" : "Absenden"}
|
|
</button>
|
|
|
|
<p className="mt-3 text-center text-xs text-slate-400">
|
|
Deine Rückmeldung wird ohne Namen und ohne IP-Adresse gespeichert.
|
|
</p>
|
|
</div>
|
|
</LandingShell>
|
|
);
|
|
}
|