From 94a8feeb67ad5ec1152ce747640c047151ee6000 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Thu, 21 May 2026 11:28:23 +0200 Subject: [PATCH] feat(contact): full validation, CMS-translated strings, smaller UI - contact-form-validation.ts: shared client+server validator returning {key, params} so messages can be translated via the CMS bundle. - ContactFormComponent: 3 honeypots, subject field, GDPR consent checkbox, character counter, inline per-field errors after submit, uses useTranslate() everywhere. - /api/contact: origin allowlist, multiple honeypot check, client-age gate, server-side re-validation, forwards X-Forwarded-For + UA to CMS forms plugin with consent timestamp in payload. - TextInput/Textarea: new size="sm" variant (rounded-sm, shadow-sm, h-10, smaller padding/text). Default "md" unchanged. - translations.ts: 37 new contact_* keys. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/block-types.ts | 1 - .../internal/ContactFormComponent.svelte | 204 ++++++++++++++---- .../internal/contact-form-validation.ts | 92 ++++++++ src/lib/translations.ts | 38 ++++ src/lib/ui/TextInput.svelte | 15 +- src/lib/ui/Textarea.svelte | 15 +- src/routes/api/contact/+server.ts | 114 ++++++++-- 7 files changed, 409 insertions(+), 70 deletions(-) create mode 100644 src/lib/components/internal/contact-form-validation.ts diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index 505ea59..71b2bc0 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -428,7 +428,6 @@ export type InternalComponentId = "form-contact"; export interface InternalComponentBlockData { _type?: "internal_component"; _slug?: string; - id?: string; component?: InternalComponentId; layout?: BlockLayout; } diff --git a/src/lib/components/internal/ContactFormComponent.svelte b/src/lib/components/internal/ContactFormComponent.svelte index b2b3fac..426cd58 100644 --- a/src/lib/components/internal/ContactFormComponent.svelte +++ b/src/lib/components/internal/ContactFormComponent.svelte @@ -2,23 +2,66 @@ 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 { + LIMITS, + validateContact, + type ContactErrors, + type ContactFields, + } from "./contact-form-validation"; - type State = "idle" | "pending" | "success" | "error"; + type FormState = "idle" | "pending" | "success" | "error"; + + const t = useTranslate(); let name = $state(""); let email = $state(""); + let subject = $state(""); let message = $state(""); + let consent = $state(false); + + // Honeypots — three with realistic names. Real users never see them. let honeypot = $state(""); - let pageLoadedAt = Date.now(); - let formState = $state("idle"); - let errorMessage = $state(""); + let website = $state(""); + let company = $state(""); + + const pageLoadedAt = Date.now(); + + let formState = $state("idle"); + let serverError = $state(""); + let submitAttempted = $state(false); + + const fields = $derived({ name, email, subject, message, consent }); + const errors = $derived(validateContact(fields)); + const isValid = $derived(Object.keys(errors).length === 0); + + function showError(field: keyof ContactFields): string { + if (!submitAttempted) return ""; + const e = errors[field]; + return e ? t(e.key, e.params) : ""; + } + + const messageRemaining = $derived(LIMITS.MESSAGE_MAX - message.length); + const messageHelp = $derived( + messageRemaining >= 0 + ? t(T.contact_message_remaining, { n: messageRemaining }) + : t(T.contact_message_overflow, { n: -messageRemaining }), + ); 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(`[data-field="${first}"]`); + el?.focus(); + return; + } formState = "pending"; - errorMessage = ""; + serverError = ""; try { const res = await fetch("/api/contact", { @@ -27,66 +70,141 @@ body: JSON.stringify({ name, email, + subject, message, + consent, _honeypot: honeypot, + website, + company, _client_age_ms: Date.now() - pageLoadedAt, }), }); if (res.ok) { formState = "success"; - } else { - const body = await res.json().catch(() => ({})); - errorMessage = body?.error || "Unbekannter Fehler"; - formState = "error"; + return; } + + const body = await res.json().catch(() => ({})); + if (res.status === 422) { + serverError = t(T.contact_server_error_validation); + } else if (res.status === 429) { + serverError = t(T.contact_server_error_throttle); + } else if (res.status === 403) { + serverError = t(T.contact_server_error_blocked); + } else { + serverError = body?.error || t(T.contact_server_error_generic); + } + formState = "error"; } catch { - errorMessage = "Netzwerkfehler – bitte erneut versuchen."; + serverError = t(T.contact_server_error_network); formState = "error"; } } {#if formState === "success"} -
-

Nachricht gesendet!

-

Wir melden uns so schnell wie möglich.

+
+

{t(T.contact_success_title)}

+

{t(T.contact_success_body)}

{:else} -
- -