|
|
|
@@ -0,0 +1,180 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onDestroy } from "svelte";
|
|
|
|
|
import { env as publicEnv } from "$env/dynamic/public";
|
|
|
|
|
import { useTranslate, T } from "$lib/translations";
|
|
|
|
|
import { validateNewsletter, type NewsletterErrors, type NewsletterFields } from "./newsletter-form-validation";
|
|
|
|
|
|
|
|
|
|
type FormState = "idle" | "pending" | "success" | "error";
|
|
|
|
|
|
|
|
|
|
const t = useTranslate();
|
|
|
|
|
|
|
|
|
|
const TURNSTILE_SITE_KEY = publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? "";
|
|
|
|
|
const TURNSTILE_ENABLED = TURNSTILE_SITE_KEY !== "";
|
|
|
|
|
|
|
|
|
|
let email = $state("");
|
|
|
|
|
let consent = $state(false);
|
|
|
|
|
|
|
|
|
|
// Honeypots
|
|
|
|
|
let honeypot = $state("");
|
|
|
|
|
let website = $state("");
|
|
|
|
|
let company = $state("");
|
|
|
|
|
|
|
|
|
|
const pageLoadedAt = Date.now();
|
|
|
|
|
|
|
|
|
|
let formState = $state<FormState>("idle");
|
|
|
|
|
let serverError = $state("");
|
|
|
|
|
let submitAttempted = $state(false);
|
|
|
|
|
|
|
|
|
|
let turnstileToken = $state("");
|
|
|
|
|
let turnstileEl: HTMLDivElement | null = null;
|
|
|
|
|
let turnstileWidgetId: string | null = null;
|
|
|
|
|
let turnstileArmed = $state(false);
|
|
|
|
|
|
|
|
|
|
const fields = $derived<NewsletterFields>({ name: "", email, consent });
|
|
|
|
|
const errors = $derived<NewsletterErrors>(validateNewsletter(fields));
|
|
|
|
|
const isValid = $derived(Object.keys(errors).length === 0);
|
|
|
|
|
|
|
|
|
|
function showError(field: keyof NewsletterFields): string {
|
|
|
|
|
if (!submitAttempted) return "";
|
|
|
|
|
const e = errors[field];
|
|
|
|
|
return e ? t(e.key, e.params) : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TurnstileAPI = {
|
|
|
|
|
render: (el: HTMLElement, opts: {
|
|
|
|
|
sitekey: string; theme?: "light" | "dark" | "auto"; size?: "normal" | "compact" | "flexible";
|
|
|
|
|
callback?: (token: string) => void; "expired-callback"?: () => void; "error-callback"?: () => void;
|
|
|
|
|
}) => string;
|
|
|
|
|
reset: (id?: string) => void;
|
|
|
|
|
remove: (id: string) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function getTurnstile(): TurnstileAPI | undefined {
|
|
|
|
|
return (window as unknown as { turnstile?: TurnstileAPI }).turnstile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTurnstile() {
|
|
|
|
|
if (!TURNSTILE_ENABLED || !turnstileEl || turnstileWidgetId !== null) return;
|
|
|
|
|
const ts = getTurnstile();
|
|
|
|
|
if (!ts) return;
|
|
|
|
|
turnstileWidgetId = ts.render(turnstileEl, {
|
|
|
|
|
sitekey: TURNSTILE_SITE_KEY,
|
|
|
|
|
theme: "dark",
|
|
|
|
|
size: "flexible",
|
|
|
|
|
callback: (token) => { turnstileToken = token; },
|
|
|
|
|
"expired-callback": () => { turnstileToken = ""; },
|
|
|
|
|
"error-callback": () => { turnstileToken = ""; },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Lazy: Turnstile-Script + Widget erst beim ersten Fokus laden — nicht auf
|
|
|
|
|
// jeder Seite (Footer ist überall).
|
|
|
|
|
function armTurnstile() {
|
|
|
|
|
if (!TURNSTILE_ENABLED || turnstileArmed) return;
|
|
|
|
|
turnstileArmed = true;
|
|
|
|
|
if (document.querySelector("script[data-turnstile]")) { renderTurnstile(); return; }
|
|
|
|
|
const s = document.createElement("script");
|
|
|
|
|
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
|
|
|
s.async = true; s.defer = true; s.dataset.turnstile = "1";
|
|
|
|
|
s.onload = renderTurnstile;
|
|
|
|
|
document.head.appendChild(s);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onDestroy(() => {
|
|
|
|
|
if (turnstileWidgetId !== null) { getTurnstile()?.remove(turnstileWidgetId); turnstileWidgetId = null; }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e: SubmitEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
submitAttempted = true;
|
|
|
|
|
if (formState === "pending") return;
|
|
|
|
|
if (!isValid) return;
|
|
|
|
|
if (TURNSTILE_ENABLED && !turnstileToken) {
|
|
|
|
|
serverError = t(T.contact_captcha_pending);
|
|
|
|
|
formState = "error";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formState = "pending";
|
|
|
|
|
serverError = "";
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/newsletter", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
email, consent,
|
|
|
|
|
_honeypot: honeypot, website, company,
|
|
|
|
|
_client_age_ms: Date.now() - pageLoadedAt,
|
|
|
|
|
cf_turnstile_token: turnstileToken,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) { formState = "success"; return; }
|
|
|
|
|
const body = await res.json().catch(() => ({}));
|
|
|
|
|
if (res.status === 429) serverError = t(T.contact_server_error_throttle);
|
|
|
|
|
else if (res.status === 403) serverError = body?.error === "captcha" ? t(T.contact_server_error_captcha) : t(T.contact_server_error_blocked);
|
|
|
|
|
else serverError = body?.error || t(T.contact_server_error_generic);
|
|
|
|
|
formState = "error";
|
|
|
|
|
if (TURNSTILE_ENABLED) { turnstileToken = ""; if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId); }
|
|
|
|
|
} catch {
|
|
|
|
|
serverError = t(T.contact_server_error_network);
|
|
|
|
|
formState = "error";
|
|
|
|
|
if (TURNSTILE_ENABLED) { turnstileToken = ""; if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="max-w-md">
|
|
|
|
|
<h3 class="text-stein-0 text-base font-semibold">{t(T.newsletter_inline_heading)}</h3>
|
|
|
|
|
|
|
|
|
|
{#if formState === "success"}
|
|
|
|
|
<p class="mt-2 text-sm text-wald-200">{t(T.newsletter_success_body)}</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<form onsubmit={handleSubmit} novalidate class="mt-3" aria-busy={formState === "pending"}>
|
|
|
|
|
<!-- Honeypots -->
|
|
|
|
|
<div aria-hidden="true" class="pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0">
|
|
|
|
|
<input name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
|
|
|
|
<input name="website" type="url" tabindex="-1" autocomplete="off" bind:value={website} />
|
|
|
|
|
<input name="company" type="text" tabindex="-1" autocomplete="off" bind:value={company} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<input
|
|
|
|
|
type="email"
|
|
|
|
|
name="email"
|
|
|
|
|
required
|
|
|
|
|
placeholder={t(T.newsletter_placeholder_email)}
|
|
|
|
|
bind:value={email}
|
|
|
|
|
onfocus={armTurnstile}
|
|
|
|
|
aria-label={t(T.newsletter_label_email)}
|
|
|
|
|
class="min-w-0 flex-1 rounded-sm border border-stein-700 bg-stein-800 px-3 py-2 text-sm text-stein-0 placeholder:text-stein-400 focus:border-wald-400 focus:outline-none"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={formState === "pending"}
|
|
|
|
|
class="shrink-0 rounded-sm bg-wald-600 px-4 py-2 text-sm font-semibold text-stein-0 transition-colors hover:bg-wald-500 disabled:opacity-60"
|
|
|
|
|
>
|
|
|
|
|
{formState === "pending" ? t(T.newsletter_submitting) : t(T.newsletter_submit)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{#if showError("email")}
|
|
|
|
|
<p class="mt-1 text-xs text-error" role="alert">{showError("email")}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<label class="mt-2 flex items-start gap-2 text-xs text-stein-300">
|
|
|
|
|
<input type="checkbox" name="consent" bind:checked={consent} class="mt-0.5 shrink-0 accent-wald-500" />
|
|
|
|
|
<span>{t(T.newsletter_inline_consent)}</span>
|
|
|
|
|
</label>
|
|
|
|
|
{#if showError("consent")}
|
|
|
|
|
<p class="mt-1 text-xs text-error" role="alert">{showError("consent")}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if TURNSTILE_ENABLED}
|
|
|
|
|
<div bind:this={turnstileEl} class="mt-2"></div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if serverError}
|
|
|
|
|
<p class="mt-2 text-xs text-error" role="alert">{serverError}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</form>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|