Beide Formulare lasen NEXT_PUBLIC_TURNSTILE_SITE_KEY direkt aus process.env, und zwar im Modulrumpf einer Client Component. NEXT_PUBLIC_-Variablen werden dort zur BUILD-Zeit ins Bundle eingebacken. Gesetzt wird der Wert hier aber erst zur Laufzeit (k8s/01-configmap.yaml) — im Docker-Build existiert er nicht und wird auch nicht als Build-Arg hereingereicht. Der Wert war damit dauerhaft undefined, das Widget erschien nie. Solange TURNSTILE_SECRET_KEY ebenfalls leer ist, faellt das nicht auf: verifyTurnstileToken gibt ohne Secret bewusst true zurueck. Sobald das Secret aber gesetzt wird — also genau beim Scharfschalten des Schutzes —, verlangt der Server einen Token, den der Browser nirgends erzeugen kann. Newsletter- Anmeldung und Firmenanfrage haetten dann jede Eingabe abgewiesen, ohne dass irgendwo ein Fehler sichtbar wird. Alle uebrigen Formulare (Login, Registrierung, Passwort-Reset, Zwei-Faktor, Checkout, Verfuegbarkeits-Benachrichtigung) lesen den Wert bereits im Server Component und reichen ihn als Prop weiter — dort wirkt die Laufzeit-Variable. Diese beiden folgen jetzt demselben Muster. Bewusst so und nicht ueber ein Docker-Build-Arg geloest: ein Build-Arg wuerde den Schluessel ins Image einbacken, womit ein Schluesselwechsel einen Neubau braeuchte und die ConfigMap fuer diese beiden Stellen wirkungslos bliebe. Ein einziger Weg fuer alle Formulare ist weniger fehleranfaellig als zwei. Hinweis fuer den Betrieb: der Schutz ist damit noch nicht aktiv. Dafuer muessen NEXT_PUBLIC_TURNSTILE_SITE_KEY (ConfigMap) UND TURNSTILE_SECRET_KEY (Secret) gesetzt sein — beide oder keiner. tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
148 lines
5.2 KiB
TypeScript
148 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, type FormEvent } from "react";
|
|
import { Button } from "@/components/ui";
|
|
import { TurnstileWidget } from "@/components/turnstile-widget";
|
|
import { useI18n } from "@/i18n/i18n-provider";
|
|
|
|
type Status = "idle" | "loading" | "success" | "error";
|
|
|
|
/**
|
|
* Der Turnstile-Sitekey kommt als Prop vom Server und NICHT aus
|
|
* process.env in dieser Datei.
|
|
*
|
|
* Grund: NEXT_PUBLIC_-Variablen werden in Client Components zur BUILD-Zeit
|
|
* ins Bundle eingebacken. Der Wert wird hier aber erst zur Laufzeit gesetzt
|
|
* (k8s/01-configmap.yaml). Ein Zugriff auf process.env an dieser Stelle
|
|
* ergibt deshalb dauerhaft undefined — das Widget erschiene nie, waehrend die
|
|
* Serverseite bei gesetztem TURNSTILE_SECRET_KEY sehr wohl einen Token
|
|
* verlangt. Das Formular waere damit unabsendbar, ohne dass irgendwo ein
|
|
* Fehler auftaucht.
|
|
*
|
|
* Alle uebrigen Formulare (Login, Registrierung, Passwort-Reset, Zwei-Faktor,
|
|
* Checkout, Verfuegbarkeits-Benachrichtigung) lesen den Wert bereits im
|
|
* Server Component und reichen ihn als Prop weiter. Diese Datei folgt jetzt
|
|
* demselben Muster.
|
|
*/
|
|
export function BusinessInquiryForm({ turnstileSiteKey }: { turnstileSiteKey?: string }) {
|
|
const { t } = useI18n();
|
|
const [status, setStatus] = useState<Status>("idle");
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [turnstileToken, setTurnstileToken] = useState("");
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setStatus("loading");
|
|
setErrorMessage(null);
|
|
|
|
const formData = new FormData(event.currentTarget);
|
|
const payload = {
|
|
companyName: String(formData.get("companyName") ?? ""),
|
|
contactPerson: String(formData.get("contactPerson") ?? ""),
|
|
email: String(formData.get("email") ?? ""),
|
|
phone: String(formData.get("phone") ?? "") || undefined,
|
|
desiredQuantity: String(formData.get("desiredQuantity") ?? ""),
|
|
desiredDeliveryDate: String(formData.get("desiredDeliveryDate") ?? "") || undefined,
|
|
package: String(formData.get("package") ?? "KLASSIK"),
|
|
message: String(formData.get("message") ?? "") || undefined,
|
|
turnstileToken,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch("/api/business-inquiry", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!response.ok) throw new Error("request failed");
|
|
setStatus("success");
|
|
event.currentTarget.reset();
|
|
} catch {
|
|
setStatus("error");
|
|
setErrorMessage(t.businessInquiryForm.errorMessage);
|
|
}
|
|
}
|
|
|
|
if (status === "success") {
|
|
return (
|
|
<p className="text-forest">
|
|
{t.businessInquiryForm.successMessage}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
<div className="grid sm:grid-cols-2 gap-4">
|
|
<TextField name="companyName" label={t.businessInquiryForm.companyLabel} required />
|
|
<TextField name="contactPerson" label={t.businessInquiryForm.contactPersonLabel} required />
|
|
</div>
|
|
<div className="grid sm:grid-cols-2 gap-4">
|
|
<TextField name="email" label={t.businessInquiryForm.emailLabel} type="email" required />
|
|
<TextField name="phone" label={t.businessInquiryForm.phoneOptionalLabel} />
|
|
</div>
|
|
<div className="grid sm:grid-cols-2 gap-4">
|
|
<TextField name="desiredQuantity" label={t.businessInquiryForm.quantityLabel} type="number" required />
|
|
<TextField name="desiredDeliveryDate" label={t.businessInquiryForm.deliveryDateLabel} type="date" />
|
|
</div>
|
|
|
|
<label className="block text-sm">
|
|
<span className="text-ink/70">{t.businessInquiryForm.packageLabel}</span>
|
|
<select
|
|
name="package"
|
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3"
|
|
>
|
|
{t.unternehmenPage.packages.map((p) => (
|
|
<option key={p.key} value={p.key}>
|
|
{p.title} — {p.text}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="block text-sm">
|
|
<span className="text-ink/70">{t.businessInquiryForm.messageLabel}</span>
|
|
<textarea
|
|
name="message"
|
|
rows={4}
|
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3"
|
|
/>
|
|
</label>
|
|
|
|
{errorMessage ? <p className="text-sm text-red-700">{errorMessage}</p> : null}
|
|
|
|
{turnstileSiteKey ? (
|
|
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
|
) : null}
|
|
|
|
<Button type="submit" disabled={status === "loading"}>
|
|
{status === "loading" ? t.businessInquiryForm.submitting : t.businessInquiryForm.submit}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function TextField({
|
|
name,
|
|
label,
|
|
type = "text",
|
|
required = false,
|
|
}: {
|
|
name: string;
|
|
label: string;
|
|
type?: string;
|
|
required?: boolean;
|
|
}) {
|
|
return (
|
|
<label className="block text-sm">
|
|
<span className="text-ink/70">{label}</span>
|
|
<input
|
|
name={name}
|
|
type={type}
|
|
required={required}
|
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
|
/>
|
|
</label>
|
|
);
|
|
}
|