feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)

Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.

- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
  (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
  bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
  (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
  Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
  dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
  einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
  ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
  bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
  "Deaktivieren".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 04:36:36 +02:00
1 parent fb9c2353c6
commit 839bbff595
71 files changed
+762 -416

No files matched your search

+6 -1
View File
@@ -23,7 +23,12 @@ export function ConfirmForm({
action={action}
className={className}
onSubmit={(e) => {
if (!confirm(confirmMessage)) e.preventDefault();
if (!confirm(confirmMessage)) {
e.preventDefault();
// Fuer SubmitFeedback: dieser Klick loest keine Aktion aus, der
// Knopf darf also nicht als "laeuft" markiert werden.
e.currentTarget.dataset.submitCancelled = "true";
}
}}
>
{children}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useI18n } from "@/i18n/i18n-provider";
import { FLASH_COOKIE, type Flash } from "@/lib/flash-core";
// Fehler bleiben laenger stehen: sie verlangen meist eine Reaktion, und wer
// gerade woanders hinschaut, soll sie nicht verpassen.
const AUTO_HIDE_MS: Record<Flash["kind"], number> = { success: 6000, info: 8000, error: 12000 };
const STYLES: Record<Flash["kind"], string> = {
success: "bg-forest text-cream border-forest",
info: "bg-background text-ink border-ink/20",
error: "bg-red-50 text-red-800 border-red-300",
};
/**
* Zeigt die Rueckmeldung der letzten Aktion (lib/flash.ts) als Toast.
* Das Root-Layout reicht die Meldung aus dem Cookie herein; hier wird sie
* angezeigt und das Cookie sofort geloescht, damit sie beim naechsten
* Seitenaufruf nicht erneut erscheint. Jede Meldung traegt eine eigene id —
* dieselbe Aktion zweimal ausgeloest zeigt die Meldung also zweimal.
*/
export function FlashToast({ flash }: { flash: Flash | null }) {
const { t } = useI18n();
const reduceMotion = useReducedMotion();
// Gemerkt wird nur, welche Meldung geschlossen wurde — die sichtbare
// Meldung selbst leitet sich daraus ab, statt im Effect gesetzt zu werden.
const [dismissedId, setDismissedId] = useState<string | null>(null);
const visible = flash && flash.id !== dismissedId ? flash : null;
useEffect(() => {
if (!flash) return;
document.cookie = `${FLASH_COOKIE}=; Max-Age=0; path=/; SameSite=Lax`;
const timer = window.setTimeout(() => setDismissedId(flash.id), AUTO_HIDE_MS[flash.kind]);
return () => window.clearTimeout(timer);
// flash.id statt flash: nach jedem Server-Render ist das Objekt neu, die Meldung aber dieselbe.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flash?.id]);
return (
// Die Live-Region bleibt dauerhaft im DOM, damit Screenreader den
// hinzukommenden Text zuverlaessig ansagen.
<div
className="fixed inset-x-4 top-20 z-[60] flex justify-center pointer-events-none sm:inset-x-auto sm:right-6 sm:top-24"
aria-live={visible?.kind === "error" ? "assertive" : "polite"}
role={visible?.kind === "error" ? "alert" : "status"}
>
<AnimatePresence>
{visible ? (
<motion.div
key={visible.id}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className={`pointer-events-auto flex max-w-md items-start gap-3 border px-4 py-3 text-sm shadow-lg ${STYLES[visible.kind]}`}
>
<p className="flex-1 leading-relaxed">
<span className="sr-only">{t.flash[visible.kind]}: </span>
{visible.message}
</p>
<button
type="button"
onClick={() => setDismissedId(visible.id)}
className="-mr-1 -mt-0.5 px-1 text-lg leading-none opacity-70 hover:opacity-100"
aria-label={t.flash.close}
>
×
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
/** Obergrenze, falls eine Aktion weder weiterleitet noch eine Meldung setzt. */
const MAX_PENDING_MS = 20_000;
function isServerActionForm(form: HTMLFormElement): boolean {
// React rendert fuer Formulare mit Server Action versteckte $ACTION_-Felder
// (progressive enhancement). Formulare mit eigener Client-Logik
// (onSubmit + fetch) haben sie nicht und verwalten ihren Zustand selbst.
return form.querySelector('input[name^="$ACTION_"]') !== null;
}
/**
* Sofortige Rueckmeldung beim Absenden: der geklickte Knopf eines
* Server-Action-Formulars wird als "laeuft" markiert (Spinner, siehe
* globals.css) und bis zur Antwort gesperrt — ohne das sah ein Klick bis zur
* Serverantwort wie "nichts passiert" aus und wurde oft wiederholt
* (doppelte Aktion, doppelte E-Mail).
*
* Zentral statt in jedem der vielen Formulare: freigegeben wird, sobald eine
* neue Flash-Meldung (flashId) oder eine neue Seite kommt, spaetestens nach
* MAX_PENDING_MS.
*/
export function SubmitFeedback({ flashId }: { flashId: string | null }) {
const pathname = usePathname();
useEffect(() => {
const onSubmit = (event: SubmitEvent) => {
const form = event.target;
const button = event.submitter;
if (!(form instanceof HTMLFormElement) || !(button instanceof HTMLButtonElement)) return;
if (!isServerActionForm(form)) return;
// Erst nach den React-Handlern pruefen: ConfirmForm markiert dort einen
// im Dialog abgebrochenen Klick. Das Sperren nach dem Absenden aendert
// die bereits erfassten Formulardaten nicht mehr.
window.setTimeout(() => {
if (form.dataset.submitCancelled === "true") {
delete form.dataset.submitCancelled;
return;
}
button.dataset.pending = "true";
button.setAttribute("aria-busy", "true");
button.disabled = true;
window.setTimeout(() => release(button), MAX_PENDING_MS);
}, 0);
};
document.addEventListener("submit", onSubmit, true);
return () => document.removeEventListener("submit", onSubmit, true);
}, []);
useEffect(() => {
document.querySelectorAll<HTMLButtonElement>('button[data-pending="true"]').forEach(release);
}, [flashId, pathname]);
return null;
}
function release(button: HTMLButtonElement): void {
if (button.dataset.pending !== "true") return;
delete button.dataset.pending;
button.removeAttribute("aria-busy");
button.disabled = false;
}