Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m13s
Regression aus dem vorigen Commit (Turnstile per Prop statt process.env): sobald das CAPTCHA auf der Newsletter-Anmeldung tatsaechlich rendert, sprengt es die Eingabezeile. Das Formular war "flex flex-col sm:flex-row gap-3 max-w-lg" ohne flex-wrap, und das Widget lag als dritter Eintrag in derselben Zeile. Ab 640 px teilten sich damit Eingabefeld, Knopf und ein rund 300 px breites CAPTCHA eine Zeile von 512 px. Auf wabenhain.com bei 1440 px nachgemessen: Eingabefeld 34x71, Knopf 174x71, CAPTCHA 300x71. Solange kein Sitekey ankam, fiel das nicht auf — das Widget erschien nie, die Zeile hatte nur zwei Eintraege. Der Fehler lag also schon laenger im Markup und wurde erst sichtbar, als das CAPTCHA wie vorgesehen erschien. Jetzt bilden Eingabefeld und Knopf eine eigene Zeile, das CAPTCHA steht darunter. Die Firmenanfrage ist nicht betroffen, sie stapelt ueber space-y-5. Dazu zwei Einstellungen am Widget selbst, die von Anfang an gefehlt haben: - language: Turnstile folgte der BROWSER-Sprache statt der Seitensprache. Auf der deutschen Seite stand englisch "Verify you are human". - theme: "light": Turnstile folgte dem Farbschema des Betriebssystems. Wer es auf "dunkel" stehen hat, bekam einen schwarzen Kasten mitten in einen Abschnitt, den diese Seite ausschliesslich in Creme und Waldgruen zeichnet. Die Seite hat keinen Dunkelmodus, "auto" ist hier also immer falsch. Beides betrifft alle Formulare, nicht nur den Newsletter. tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
212 lines
8.3 KiB
TypeScript
212 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useI18n } from "@/i18n/i18n-provider";
|
|
|
|
interface TurnstileApi {
|
|
render: (container: HTMLElement, options: Record<string, unknown>) => string;
|
|
remove: (widgetId: string) => void;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
turnstile?: TurnstileApi;
|
|
}
|
|
}
|
|
|
|
// "render=explicit" schaltet die automatische Suche nach .cf-turnstile-
|
|
// Elementen ab: dieses Widget wird ausschliesslich von Hand gerendert, damit
|
|
// wir die Widget-ID kennen und es beim Verlassen wieder entfernen koennen.
|
|
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
|
|
/**
|
|
* Laedt das Turnstile-Skript je Seitenbesuch genau einmal; alle Widgets
|
|
* teilen sich dieses eine Versprechen.
|
|
*
|
|
* Zuvor uebernahm das <Script> von next/script diese Aufgabe, und genau daran
|
|
* scheiterte es: next/script fuehrt fuer eine bereits geladene Quelle KEIN
|
|
* onLoad mehr aus. Der Schluessel ist dabei die URL (id || src), also fuer
|
|
* alle Widgets derselbe — nachzulesen in next/dist/client/script.js:
|
|
*
|
|
* const cacheKey = id || src;
|
|
* // Script has already loaded
|
|
* if (cacheKey && LoadCache.has(cacheKey)) {
|
|
* return;
|
|
* }
|
|
*
|
|
* Damit bekam nur das ERSTE Widget nach einem echten Seitenaufbau sein
|
|
* onLoad. Jedes weitere wartete auf ein Ereignis, das nie kam, und blieb
|
|
* unsichtbar: das zweite Widget auf der Kontoseite (Anmeldung und
|
|
* Registrierung stehen dort untereinander) ebenso wie jedes Formular, das
|
|
* ueber einen Link im Seitenverlauf erreicht wurde statt ueber F5. Weil die
|
|
* Newsletter-Anmeldung im Footer steht, ist das Skript praktisch immer schon
|
|
* geladen — fast jeder Weg durch die Seite fuehrte also in den kaputten
|
|
* Zustand, und nur ein Neuladen half.
|
|
*
|
|
* Deshalb haengt hier nichts mehr an einem Ereignis, das man verpassen kann:
|
|
* es zaehlt allein, ob window.turnstile vorhanden ist.
|
|
*/
|
|
let loadPromise: Promise<TurnstileApi> | null = null;
|
|
|
|
function loadTurnstile(): Promise<TurnstileApi> {
|
|
if (window.turnstile) return Promise.resolve(window.turnstile);
|
|
if (loadPromise) return loadPromise;
|
|
|
|
const pending = new Promise<TurnstileApi>((resolve, reject) => {
|
|
// Ein vorhandenes Skript-Element wiederverwenden, statt ein zweites
|
|
// einzuhaengen: nach einem fehlgeschlagenen Versuch (loadPromise wird
|
|
// unten zurueckgesetzt) steht es unter Umstaenden noch im Dokument.
|
|
const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC}"]`);
|
|
const script = existing ?? document.createElement("script");
|
|
|
|
script.addEventListener(
|
|
"load",
|
|
() => {
|
|
if (window.turnstile) resolve(window.turnstile);
|
|
else reject(new Error("Turnstile geladen, aber window.turnstile fehlt."));
|
|
},
|
|
{ once: true }
|
|
);
|
|
script.addEventListener(
|
|
"error",
|
|
() => reject(new Error("Turnstile-Skript konnte nicht geladen werden.")),
|
|
{ once: true }
|
|
);
|
|
|
|
if (!existing) {
|
|
script.src = SCRIPT_SRC;
|
|
script.async = true;
|
|
script.defer = true;
|
|
document.head.appendChild(script);
|
|
}
|
|
});
|
|
|
|
// Nach einem Fehlschlag darf der naechste Versuch nicht auf demselben
|
|
// abgelehnten Versprechen haengen bleiben — sonst waere der "Erneut
|
|
// versuchen"-Knopf wirkungslos.
|
|
loadPromise = pending;
|
|
pending.catch(() => {
|
|
loadPromise = null;
|
|
});
|
|
|
|
return pending;
|
|
}
|
|
|
|
interface TurnstileWidgetProps {
|
|
siteKey: string;
|
|
onVerify: (token: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Cloudflare-Turnstile-CAPTCHA-Widget. Wird nur eingebunden, wenn eine
|
|
* NEXT_PUBLIC_TURNSTILE_SITE_KEY konfiguriert ist (siehe README) — ohne
|
|
* Konfiguration rendern die umgebenden Formulare gar nicht erst diese
|
|
* Komponente und bleiben unveraendert nutzbar.
|
|
*
|
|
* Achtung beim Anpassen: ohne sichtbares Widget gibt es kein Token, und ohne
|
|
* Token weist der Server das Formular ab. Ein stiller Ausfall hier sperrt
|
|
* Kundschaft aus Anmeldung, Registrierung und Checkout aus, ohne dass
|
|
* irgendwo etwas davon zu sehen waere — daher der sichtbare Hinweis samt
|
|
* Wiederholung unten.
|
|
*/
|
|
export function TurnstileWidget({ siteKey, onVerify }: TurnstileWidgetProps) {
|
|
const { t, locale } = useI18n();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const widgetIdRef = useRef<string | null>(null);
|
|
const onVerifyRef = useRef(onVerify);
|
|
const [failed, setFailed] = useState(false);
|
|
const [attempt, setAttempt] = useState(0);
|
|
|
|
// Den Callback im Ref halten, statt ihn in die Abhaengigkeiten zu nehmen:
|
|
// eine bei jedem Render neu erzeugte Funktion wuerde das Widget sonst
|
|
// staendig abbauen und neu aufbauen — und mit ihm ein bereits geloestes
|
|
// CAPTCHA.
|
|
useEffect(() => {
|
|
onVerifyRef.current = onVerify;
|
|
}, [onVerify]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
// setFailed gehoert in die Rueckrufe, nicht in den Rumpf des Effekts: ein
|
|
// synchrones setState loest eine sofortige zweite Renderrunde aus (siehe
|
|
// react-hooks/set-state-in-effect). Beim Wiederholen wird der Hinweis
|
|
// deshalb erst zurueckgenommen, wenn das Laden tatsaechlich geklappt hat.
|
|
loadTurnstile()
|
|
.then((turnstile) => {
|
|
if (cancelled || !containerRef.current) return;
|
|
setFailed(false);
|
|
widgetIdRef.current = turnstile.render(containerRef.current, {
|
|
sitekey: siteKey,
|
|
// Ohne diese beiden Angaben richtet sich Turnstile nach dem BROWSER
|
|
// des Besuchers statt nach der Seite: die Beschriftung folgte der
|
|
// Browsersprache (englisches "Verify you are human" auf einer
|
|
// deutschen Seite) und die Farbgebung dem Farbschema des
|
|
// Betriebssystems. Wer sein System auf "dunkel" stehen hat, bekam
|
|
// deshalb einen schwarzen Kasten mitten in einen Abschnitt, den
|
|
// diese Seite ausschliesslich in Creme und Waldgruen zeichnet.
|
|
language: locale,
|
|
theme: "light",
|
|
callback: (token: string) => onVerifyRef.current(token),
|
|
// Ein abgelaufenes oder fehlgeschlagenes Token muss das Formular
|
|
// erfahren, sonst schickt es ein laengst ungueltiges mit.
|
|
"expired-callback": () => onVerifyRef.current(""),
|
|
"error-callback": () => onVerifyRef.current(""),
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (cancelled) return;
|
|
console.error("[turnstile] Widget konnte nicht geladen werden:", error);
|
|
setFailed(true);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
const widgetId = widgetIdRef.current;
|
|
widgetIdRef.current = null;
|
|
if (!widgetId) return;
|
|
try {
|
|
window.turnstile?.remove(widgetId);
|
|
} catch (error: unknown) {
|
|
// Nicht weiterwerfen: ein Fehler beim Aufraeumen darf den
|
|
// Seitenwechsel nicht abbrechen.
|
|
console.warn("[turnstile] Widget konnte nicht entfernt werden:", error);
|
|
}
|
|
};
|
|
}, [siteKey, attempt, locale]);
|
|
|
|
// "max-w-full overflow-x-auto" ist kein Schoenheitsmittel, sondern eine
|
|
// Zusicherung: Cloudflare liefert einen iframe mit FESTER Breite (rund
|
|
// 300 px), der nicht mitschrumpft. Steht er in einem schmaleren Kasten,
|
|
// ragt er heraus und schiebt sich ueber das, was daneben liegt — auf der
|
|
// Kontoseite traf das den "Passwort vergessen"-Link. Mit diesen Klassen
|
|
// bleibt er in jedem Fall innerhalb seines Kastens; ist der zu schmal,
|
|
// laesst sich der Inhalt seitlich schieben, statt das Layout zu zerlegen.
|
|
// "w-fit" verhindert zugleich, dass der Kasten in einer Flex-Zeile mehr
|
|
// Platz beansprucht als der iframe braucht.
|
|
return (
|
|
<div className="w-fit max-w-full overflow-x-auto">
|
|
<div ref={containerRef} />
|
|
{failed ? (
|
|
<p className="text-sm text-red-700">
|
|
{t.common.turnstile.loadError}{" "}
|
|
<button type="button" onClick={() => setAttempt((value) => value + 1)} className="underline">
|
|
{t.common.turnstile.retry}
|
|
</button>
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Turnstile-Widget fuer klassische (nicht per fetch/JSON gesendete) Formulare: legt das Token als Hidden-Field ab. */
|
|
export function TurnstileField({ siteKey, name = "turnstileToken" }: { siteKey: string; name?: string }) {
|
|
const [token, setToken] = useState("");
|
|
return (
|
|
<>
|
|
<TurnstileWidget siteKey={siteKey} onVerify={setToken} />
|
|
<input type="hidden" name={name} value={token} />
|
|
</>
|
|
);
|
|
}
|