"use client"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "@/components/icons"; import { DesignPanel } from "@/components/design-panel"; import { QrPreview } from "@/components/qr-preview"; import { qrForms } from "@/components/qr-forms"; import { Badge, Button, Card, CardTitle, Field, Notice, TextInput, cx, } from "@/components/ui"; import { ApiError, apiJson, downloadFile } from "@/lib/api-client"; import type { QrCodeDto } from "@/lib/dto"; import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design"; import { qrTypes } from "@/lib/qr/registry"; import type { QrFormData, QrTypeId } from "@/lib/qr/type-module"; import { describeSlugProblem } from "@/lib/slug"; type PreviewState = { dataUrl: string; content: string }; /** Vergleicht zwei Datenobjekte unabhängig von der Schlüsselreihenfolge. */ function stableStringify(value: unknown): string { return JSON.stringify(value, (_key, inner: unknown) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries( Object.entries(inner as Record).sort(([a], [b]) => a.localeCompare(b), ), ) : inner, ); } function sameData(a: unknown, b: unknown): boolean { return stableStringify(a) === stableStringify(b); } /** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */ type Dublette = { id: string; label: string | null; createdAt: string }; /** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */ function leseDublette(details: unknown): Dublette | null { if (details === null || typeof details !== "object") return null; const feld = (details as { duplicate?: unknown }).duplicate; if (feld === null || typeof feld !== "object") return null; const { id, label, createdAt } = feld as Record; if (typeof id !== "string" || typeof createdAt !== "string") return null; return { id, label: typeof label === "string" ? label : null, createdAt }; } const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); export function QrGenerator({ typeId, /** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */ maySetSlug = true, }: { typeId: QrTypeId; maySetSlug?: boolean; }) { const qrType = qrTypes[typeId]; const Form = qrForms[typeId]; const [data, setData] = useState(qrType.defaultData); const [design, setDesign] = useState(defaultDesign); // Typen ohne statische Variante — etwa der App-Link — starten dynamisch. const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic); const [label, setLabel] = useState(""); /** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */ const [wunschSlug, setWunschSlug] = useState(""); const [saved, setSaved] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); /** * Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der * Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne * einen Effekt, der sie hinterherräumt. */ const [duplicate, setDuplicate] = useState< { treffer: Dublette; inhalt: string } | null >(null); const [copied, setCopied] = useState(false); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [downloading, setDownloading] = useState<"png" | "svg" | null>(null); const [downloadError, setDownloadError] = useState(null); const validation = useMemo(() => qrType.validate(data), [qrType, data]); const designValid = useMemo( () => designSchema.safeParse(design).success, [design], ); // Fehler erst zeigen, wenn tatsächlich etwas eingegeben wurde. const touched = !sameData(data, qrType.defaultData); const dataError = !validation.ok && touched ? validation.message : null; const mode = isDynamic ? "dynamic" : "static"; const slug = (isDynamic ? saved?.slug : undefined) ?? undefined; const requestBody = useMemo( // Der Name geht mit: Der Server bildet daraus den Dateinamen beim // Herunterladen. Auf die Vorschau hat er keinen Einfluss. () => ({ type: typeId, data, design, mode, slug, label: label.trim() || undefined }), [typeId, data, design, mode, slug, label], ); const ready = validation.ok && designValid; /* ------------------------------------------------------------ Vorschau */ const requestId = useRef(0); useEffect(() => { if (!ready) return; const id = (requestId.current += 1); const controller = new AbortController(); const timer = setTimeout(() => { setPreviewLoading(true); apiJson("/api/qr/preview", { method: "POST", json: requestBody, signal: controller.signal, }) .then((result) => { if (id !== requestId.current) return; setPreview(result); setPreviewError(null); }) .catch((error: unknown) => { if (controller.signal.aborted || id !== requestId.current) return; setPreview(null); setPreviewError( error instanceof ApiError ? error.message : "Die Vorschau konnte nicht geladen werden.", ); }) .finally(() => { if (id === requestId.current) setPreviewLoading(false); }); }, 250); return () => { clearTimeout(timer); controller.abort(); }; }, [ready, requestBody]); // Solange die Eingaben unvollständig sind, gibt es nichts anzuzeigen. Das // wird abgeleitet statt im Effekt zurückgesetzt — so bleibt beim Tippen die // zuletzt gültige Vorschau stehen, statt bei jedem Zeichen zu flackern. const shownPreview = ready ? preview : null; const shownPreviewError = ready ? previewError : null; const shownPreviewLoading = ready && previewLoading; /* ------------------------------------------------------------ Download */ const downloadDisabledReason = isDynamic && !saved ? "Speichere den dynamischen Code, damit die Kurz-URL entsteht — danach ist der Download möglich." : null; const handleDownload = useCallback( async (format: "png" | "svg") => { setDownloadError(null); setDownloading(format); try { await downloadFile( "/api/qr/download", { ...requestBody, format }, `qr-code.${format}`, ); } catch (error) { setDownloadError( error instanceof ApiError ? error.message : "Der Download ist fehlgeschlagen.", ); } finally { setDownloading(null); } }, [requestBody], ); /* ----------------------------------------------- Dynamischen Code speichern */ // Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass // ein Zeichen nicht erlaubt ist. const slugProblem = wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null; const hasUnsavedChanges = saved !== null && (!sameData(saved.data, validation.ok ? validation.data : data) || !sameData(saved.design, design) || (saved.label ?? "") !== label.trim()); // Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam. const dublette = duplicate !== null && duplicate.inhalt === stableStringify(validation.ok ? validation.data : data) ? duplicate.treffer : null; const handleSave = useCallback(async (trotzDublette = false) => { if (!validation.ok || !designValid || slugProblem) return; setSaving(true); setSaveError(null); setDuplicate(null); try { const body = { type: typeId, data: validation.data, design, mode, label: label.trim() || undefined, // Ein statischer Code hat keinen Kurz-Link. slug: isDynamic ? wunschSlug.trim() || undefined : undefined, // Nur beim zweiten Anlauf, nachdem die Warnung dastand. allowDuplicate: trotzDublette, }; const result = saved ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { method: "PATCH", json: { data: validation.data, design, label: label.trim() || null }, }) : await apiJson<{ code: QrCodeDto }>("/api/codes", { method: "POST", json: body, }); setSaved(result.code); } catch (error) { // Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage. const gemeldet = error instanceof ApiError && error.status === 409 ? leseDublette(error.details) : null; if (gemeldet) { setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) }); } else { setSaveError( error instanceof ApiError ? error.message : "Der Code konnte nicht gespeichert werden.", ); } } finally { setSaving(false); } }, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]); /** * Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten * Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein * gespeicherter dynamischer — beides gleichzeitig festzuhalten führte * dazu, dass die Oberfläche den falschen Zustand zeigt. */ const wechsleModus = useCallback((dynamisch: boolean) => { setIsDynamic(dynamisch); setSaved(null); setSaveError(null); setDuplicate(null); }, []); const handleCopy = useCallback(async () => { if (!saved?.shortUrl) return; try { await navigator.clipboard.writeText(saved.shortUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { setCopied(false); } }, [saved]); /* ---------------------------------------------------------------- View */ return (
Alle Typen

{qrType.title}-QR-Code erstellen

{qrType.description}

{/* Die Rasterplätze werden ab „lg“ ausdrücklich vergeben. Dadurch steht die Vorschau auf dem Handy direkt unter der Eingabe — statt ganz unten hinter der Gestaltung — und auf dem Desktop trotzdem rechts daneben. */}
Inhalt {Form ? (
) : ( Für diesen Typ ist noch kein Formular hinterlegt. )}
Vorschau
Art des Codes
setLabel(event.target.value)} /> {!isDynamic || saved || !maySetSlug ? null : ( setWunschSlug(event.target.value)} /> )} {!isDynamic ? ( /* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist ein Angebot, kein Zwang — es geht nur ums Wiederfinden. Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss von vorher; dann wird sie wieder angeboten. */ saved && !hasUnsavedChanges ? (
Abgelegt. Du findest den Code jetzt unter „Meine Codes“ und kannst ihn dort erneut herunterladen. Der Code selbst ändert sich dadurch nicht — sein Inhalt steckt in ihm. Zu „Meine Codes“
) : (
{dublette ? ( Diesen Inhalt hast du schon abgelegt {" "} — {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "} vom {dublettenDatum.format(new Date(dublette.createdAt))}. Zweimal derselbe Inhalt ergibt zweimal denselben Code. Verglichen wird nur der Inhalt, nicht die Gestaltung — derselbe Code in anderen Farben gilt hier als Dublette. Den vorhandenen ansehen ) : null}
) ) : saved ? (
Code gespeichert. Der QR-Code zeigt dauerhaft auf diese Kurz-URL: {saved.shortUrl}
{hasUnsavedChanges ? ( ) : null} Zu „Meine Codes“
{hasUnsavedChanges ? (

Du hast Änderungen vorgenommen, die noch nicht gespeichert sind. Der gedruckte Code bleibt gültig — nur das Ziel ändert sich.

) : null}
) : (
Die Vorschau zeigt einen Platzhalter. Die endgültige Kurz-URL — und damit der fertige QR-Code — entsteht beim Speichern.
)} {saveError ? {saveError} : null}
Gestaltung
); }