b29dd587b8
- MitmachenFormComponent: Name der BI*, Anliegen*, Ansprechpartner, Telefon, E-Mail, Datei-Upload (max 5×5MB, JPG/PNG/WebP/GIF/PDF), Consent — Honeypots + Client-Age + Turnstile wie Kontaktformular - /api/mitmachen: Multipart-Validierung (shared Modul), Forwarding an CMS /api/forms/mitmachen/submit-multipart - internal_component: form-mitmachen registriert - ~35 neue mitmachen_*-Translation-Keys Prod braucht BODY_SIZE_LIMIT=30M (adapter-node default 512K) — via secrets .env.prod deployt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
390 lines
12 KiB
Svelte
390 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { env as publicEnv } from "$env/dynamic/public";
|
|
import Button from "$lib/ui/Button.svelte";
|
|
import TextInput from "$lib/ui/TextInput.svelte";
|
|
import Textarea from "$lib/ui/Textarea.svelte";
|
|
import Checkbox from "$lib/ui/Checkbox.svelte";
|
|
import { useTranslate, T } from "$lib/translations";
|
|
import {
|
|
FILE_ACCEPT,
|
|
LIMITS,
|
|
validateFiles,
|
|
validateMitmachen,
|
|
type MitmachenErrors,
|
|
type MitmachenFields,
|
|
} from "./mitmachen-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 title = $state("");
|
|
let description = $state("");
|
|
let contactName = $state("");
|
|
let phone = $state("");
|
|
let email = $state("");
|
|
let consent = $state(false);
|
|
let files = $state<File[]>([]);
|
|
let fileInputEl = $state<HTMLInputElement | null>(null);
|
|
|
|
// Honeypots — three with realistic names. Real users never see them.
|
|
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 = $state<HTMLDivElement | null>(null);
|
|
let turnstileWidgetId: string | null = null;
|
|
|
|
const fields = $derived<MitmachenFields>({
|
|
title,
|
|
description,
|
|
contactName,
|
|
phone,
|
|
email,
|
|
consent,
|
|
});
|
|
const errors = $derived<MitmachenErrors>(validateMitmachen(fields));
|
|
const fileError = $derived(validateFiles(files));
|
|
const isValid = $derived(Object.keys(errors).length === 0 && !fileError);
|
|
|
|
function showError(field: keyof MitmachenFields): string {
|
|
if (!submitAttempted) return "";
|
|
const e = errors[field];
|
|
return e ? t(e.key, e.params) : "";
|
|
}
|
|
|
|
const descriptionRemaining = $derived(LIMITS.DESCRIPTION_MAX - description.length);
|
|
const descriptionHelp = $derived(
|
|
descriptionRemaining >= 0
|
|
? t(T.contact_message_remaining, { n: descriptionRemaining })
|
|
: t(T.contact_message_overflow, { n: -descriptionRemaining }),
|
|
);
|
|
|
|
function onFilesChosen(e: Event) {
|
|
const input = e.currentTarget as HTMLInputElement;
|
|
const chosen = [...(input.files ?? [])];
|
|
// Anhängen statt ersetzen — Nutzer kann in mehreren Schritten auswählen.
|
|
files = [...files, ...chosen].slice(0, LIMITS.MAX_FILES + 1);
|
|
input.value = "";
|
|
}
|
|
|
|
function removeFile(idx: number) {
|
|
files = files.filter((_, i) => i !== idx);
|
|
}
|
|
|
|
function fmtSize(bytes: number): string {
|
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
}
|
|
|
|
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) return;
|
|
const ts = getTurnstile();
|
|
if (!ts) return;
|
|
if (turnstileWidgetId !== null) return;
|
|
turnstileWidgetId = ts.render(turnstileEl, {
|
|
sitekey: TURNSTILE_SITE_KEY,
|
|
theme: "light",
|
|
size: "flexible",
|
|
callback: (token) => {
|
|
turnstileToken = token;
|
|
},
|
|
"expired-callback": () => {
|
|
turnstileToken = "";
|
|
},
|
|
"error-callback": () => {
|
|
turnstileToken = "";
|
|
},
|
|
});
|
|
}
|
|
|
|
function loadTurnstileScript() {
|
|
if (!TURNSTILE_ENABLED) return;
|
|
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);
|
|
}
|
|
|
|
onMount(loadTurnstileScript);
|
|
onDestroy(() => {
|
|
if (turnstileWidgetId !== null) {
|
|
getTurnstile()?.remove(turnstileWidgetId);
|
|
turnstileWidgetId = null;
|
|
}
|
|
});
|
|
|
|
function resetTurnstile() {
|
|
if (!TURNSTILE_ENABLED) return;
|
|
turnstileToken = "";
|
|
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
|
|
}
|
|
|
|
async function handleSubmit(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
submitAttempted = true;
|
|
if (formState === "pending") return;
|
|
if (!isValid) {
|
|
const first = Object.keys(errors)[0];
|
|
const el = document.querySelector<HTMLElement>(`[data-field="${first}"]`);
|
|
el?.focus();
|
|
return;
|
|
}
|
|
if (TURNSTILE_ENABLED && !turnstileToken) {
|
|
serverError = t(T.contact_captcha_pending);
|
|
formState = "error";
|
|
return;
|
|
}
|
|
|
|
formState = "pending";
|
|
serverError = "";
|
|
|
|
try {
|
|
const fd = new FormData();
|
|
fd.set("title", title);
|
|
fd.set("description", description);
|
|
fd.set("contactName", contactName);
|
|
fd.set("phone", phone);
|
|
fd.set("email", email);
|
|
fd.set("consent", consent ? "true" : "false");
|
|
fd.set("_honeypot", honeypot);
|
|
fd.set("website", website);
|
|
fd.set("company", company);
|
|
fd.set("_client_age_ms", String(Date.now() - pageLoadedAt));
|
|
fd.set("cf_turnstile_token", turnstileToken);
|
|
for (const f of files) fd.append("file", f, f.name);
|
|
|
|
const res = await fetch("/api/mitmachen", { method: "POST", body: fd });
|
|
|
|
if (res.ok) {
|
|
formState = "success";
|
|
return;
|
|
}
|
|
|
|
const body = await res.json().catch(() => ({}));
|
|
if (res.status === 422) {
|
|
serverError = t(T.contact_server_error_validation);
|
|
} else if (res.status === 413) {
|
|
serverError = t(T.mitmachen_error_files_size, { mb: LIMITS.MAX_FILE_MB });
|
|
} else 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";
|
|
resetTurnstile();
|
|
} catch {
|
|
serverError = t(T.contact_server_error_network);
|
|
formState = "error";
|
|
resetTurnstile();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if formState === "success"}
|
|
<div class="rounded-sm border border-wald-300 bg-wald-50 px-4 py-5 text-center shadow-sm">
|
|
<p class="text-base font-semibold text-wald-700">{t(T.mitmachen_success_title)}</p>
|
|
<p class="mt-1 text-sm text-stein-600">{t(T.mitmachen_success_body)}</p>
|
|
</div>
|
|
{:else}
|
|
<form
|
|
onsubmit={handleSubmit}
|
|
novalidate
|
|
class="space-y-3"
|
|
aria-busy={formState === "pending"}
|
|
>
|
|
<!-- Honeypots: visually hidden, off the keyboard, off the a11y tree -->
|
|
<div aria-hidden="true" class="pointer-events-none absolute left-[-9999px] top-auto h-px w-px overflow-hidden opacity-0">
|
|
<label for="mitmachen-hp-1">leer lassen</label>
|
|
<input id="mitmachen-hp-1" name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
|
<label for="mitmachen-hp-2">Website</label>
|
|
<input id="mitmachen-hp-2" name="website" type="url" tabindex="-1" autocomplete="off" bind:value={website} />
|
|
<label for="mitmachen-hp-3">Firma</label>
|
|
<input id="mitmachen-hp-3" name="company" type="text" tabindex="-1" autocomplete="off" bind:value={company} />
|
|
</div>
|
|
|
|
<div data-field="title">
|
|
<TextInput
|
|
size="sm"
|
|
name="title"
|
|
label={t(T.mitmachen_label_title)}
|
|
placeholder={t(T.mitmachen_placeholder_title)}
|
|
required
|
|
bind:value={title}
|
|
error={showError("title")}
|
|
/>
|
|
</div>
|
|
|
|
<div data-field="description">
|
|
<Textarea
|
|
size="sm"
|
|
name="description"
|
|
label={t(T.mitmachen_label_description)}
|
|
placeholder={t(T.mitmachen_placeholder_description)}
|
|
required
|
|
rows={5}
|
|
bind:value={description}
|
|
error={showError("description")}
|
|
helpText={descriptionHelp}
|
|
/>
|
|
</div>
|
|
|
|
<div data-field="contactName">
|
|
<TextInput
|
|
size="sm"
|
|
name="contactName"
|
|
label={t(T.mitmachen_label_contact_name)}
|
|
placeholder={t(T.mitmachen_placeholder_contact_name)}
|
|
bind:value={contactName}
|
|
error={showError("contactName")}
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<div data-field="phone">
|
|
<TextInput
|
|
size="sm"
|
|
name="phone"
|
|
type="tel"
|
|
label={t(T.mitmachen_label_phone)}
|
|
placeholder={t(T.mitmachen_placeholder_phone)}
|
|
bind:value={phone}
|
|
error={showError("phone")}
|
|
/>
|
|
</div>
|
|
<div data-field="email">
|
|
<TextInput
|
|
size="sm"
|
|
name="email"
|
|
type="email"
|
|
label={t(T.mitmachen_label_email)}
|
|
placeholder={t(T.mitmachen_placeholder_email)}
|
|
bind:value={email}
|
|
error={showError("email")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div data-field="files">
|
|
<span class="mb-1 block text-sm font-medium text-stein-700">{t(T.mitmachen_label_files)}</span>
|
|
<input
|
|
bind:this={fileInputEl}
|
|
type="file"
|
|
multiple
|
|
accept={FILE_ACCEPT}
|
|
class="sr-only"
|
|
onchange={onFilesChosen}
|
|
tabindex="-1"
|
|
/>
|
|
<!-- Nativer Button: ui/Button forwarded kein onclick. Klassen = Button
|
|
secondary/sm. -->
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-9 items-center justify-center gap-2 rounded-xs border-[1.5px] border-wald-500 bg-transparent px-4 py-2 text-sm font-semibold text-wald-500 transition-colors duration-150 hover:bg-wald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 active:bg-wald-100"
|
|
onclick={() => fileInputEl?.click()}
|
|
>{t(T.mitmachen_files_add)}</button>
|
|
<p class="mt-1 text-[0.7rem] text-stein-500">
|
|
{t(T.mitmachen_files_help, { n: LIMITS.MAX_FILES, mb: LIMITS.MAX_FILE_MB })}
|
|
</p>
|
|
{#if files.length > 0}
|
|
<ul class="mt-2 space-y-1">
|
|
{#each files as f, idx}
|
|
<li class="flex items-center gap-2 rounded-sm border border-stein-200 bg-stein-50 px-2 py-1 text-xs">
|
|
<span class="min-w-0 flex-1 truncate">{f.name}</span>
|
|
<span class="shrink-0 text-stein-500">{fmtSize(f.size)}</span>
|
|
<button
|
|
type="button"
|
|
class="shrink-0 text-stein-500 underline hover:text-stein-700"
|
|
onclick={() => removeFile(idx)}
|
|
>{t(T.mitmachen_files_remove)}</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
{#if submitAttempted && fileError}
|
|
<p class="mt-1 text-[0.7rem] text-error" role="alert">{t(fileError.key, fileError.params)}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div data-field="consent" class="pt-1">
|
|
<Checkbox
|
|
size="sm"
|
|
name="consent"
|
|
bind:checked={consent}
|
|
label={t(T.mitmachen_label_consent)}
|
|
/>
|
|
{#if showError("consent")}
|
|
<p class="mt-1 text-[0.7rem] text-error" role="alert">{showError("consent")}</p>
|
|
{/if}
|
|
<p class="mt-1 text-[0.7rem] leading-snug text-stein-500">{t(T.mitmachen_privacy_note)}</p>
|
|
</div>
|
|
|
|
{#if TURNSTILE_ENABLED}
|
|
<div bind:this={turnstileEl} class="pt-1"></div>
|
|
{/if}
|
|
|
|
{#if serverError}
|
|
<div class="rounded-sm border border-error bg-error-subtle px-3 py-2 text-sm text-error shadow-sm" role="alert">
|
|
{serverError}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex items-center gap-3 pt-2">
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="sm"
|
|
loading={formState === "pending"}
|
|
disabled={!isValid && submitAttempted}
|
|
label={formState === "pending" ? t(T.contact_submitting) : t(T.mitmachen_submit)}
|
|
/>
|
|
{#if !isValid && submitAttempted && !serverError}
|
|
<span class="text-xs text-error">{t(T.contact_fix_errors)}</span>
|
|
{/if}
|
|
</div>
|
|
</form>
|
|
{/if}
|