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:
1 parent
fb9c2353c6
commit
839bbff595
71 files changed
+762
-416
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user