Phase C: die letzten sechs Typen — PDF, Bild, Feedback und Datei-Upload
Damit sind alle 15 QR-Typen gebaut. PDF und Bild brauchen eine hochgeladene Datei. Die liegt in der Datenbank, nicht auf der Platte: damit bleibt es bei einem Volumen, das gesichert werden muss — ein zweites wird beim Sichern zu leicht vergessen. Geprueft wird der Dateiinhalt, nicht die Endung; eine Textdatei namens harmlos.pdf kommt nicht durch. SVG ist bewusst nicht erlaubt, weil es Skripte enthalten darf und von derselben Adresse wie die Anwendung ausgeliefert wuerde. Ausgeliefert wird mit nosniff und einer CSP-Sandbox, damit auch ein PDF mit Skripten nichts anrichtet. Dateien haengen am Code: wird er geloescht, geht sie mit. Eine ausgetauschte oder nie gespeicherte Datei wird nach 24 Stunden weggeraeumt. Eine fremde Datei laesst sich nicht an den eigenen Code haengen — sonst koennte man sie einem anderen Code entziehen. Feedback sammelt Note und Kommentar, ohne Name, IP oder Geraetekennung. Die Auswertung mit Durchschnitt, Verteilung und Kommentaren ist aus der Codeliste erreichbar — ein Feedback-Code ohne Auswertung waere nur die halbe Sache.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"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-white 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-white 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user