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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<State>("idle");
|
||||
let errorMessage = $state("");
|
||||
let website = $state("");
|
||||
let company = $state("");
|
||||
|
||||
const pageLoadedAt = Date.now();
|
||||
|
||||
let formState = $state<FormState>("idle");
|
||||
let serverError = $state("");
|
||||
let submitAttempted = $state(false);
|
||||
|
||||
const fields = $derived<ContactFields>({ name, email, subject, message, consent });
|
||||
const errors = $derived<ContactErrors>(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<HTMLElement>(`[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";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if formState === "success"}
|
||||
<div class="rounded-lg border border-wald-300 bg-wald-50 px-6 py-8 text-center">
|
||||
<p class="text-lg font-semibold text-wald-700">Nachricht gesendet!</p>
|
||||
<p class="mt-1 text-sm text-stein-600">Wir melden uns so schnell wie möglich.</p>
|
||||
<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.contact_success_title)}</p>
|
||||
<p class="mt-1 text-sm text-stein-600">{t(T.contact_success_body)}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit} novalidate class="space-y-4">
|
||||
<!-- Honeypot – per CSS versteckt, von Bots ausgefüllt -->
|
||||
<div aria-hidden="true" style="position:absolute;left:-9999px;opacity:0;pointer-events:none;tab-index:-1;">
|
||||
<label for="contact-hp">Website</label>
|
||||
<input id="contact-hp" name="_honeypot" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
||||
<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="contact-hp-1">leer lassen</label>
|
||||
<input id="contact-hp-1" name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
||||
<label for="contact-hp-2">Website</label>
|
||||
<input id="contact-hp-2" name="website" type="url" tabindex="-1" autocomplete="off" bind:value={website} />
|
||||
<label for="contact-hp-3">Firma</label>
|
||||
<input id="contact-hp-3" name="company" type="text" tabindex="-1" autocomplete="off" bind:value={company} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="Dein Name"
|
||||
required
|
||||
bind:value={name}
|
||||
/>
|
||||
<TextInput
|
||||
name="email"
|
||||
label="E-Mail"
|
||||
placeholder="deine@email.de"
|
||||
required
|
||||
bind:value={email}
|
||||
/>
|
||||
<Textarea
|
||||
name="message"
|
||||
label="Nachricht"
|
||||
placeholder="Deine Nachricht …"
|
||||
required
|
||||
rows={5}
|
||||
bind:value={message}
|
||||
/>
|
||||
<div data-field="name">
|
||||
<TextInput
|
||||
size="sm"
|
||||
name="name"
|
||||
label={t(T.contact_label_name)}
|
||||
placeholder={t(T.contact_placeholder_name)}
|
||||
required
|
||||
bind:value={name}
|
||||
error={showError("name")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if formState === "error"}
|
||||
<p class="text-sm text-error" role="alert">{errorMessage}</p>
|
||||
<div data-field="email">
|
||||
<TextInput
|
||||
size="sm"
|
||||
name="email"
|
||||
type="email"
|
||||
label={t(T.contact_label_email)}
|
||||
placeholder={t(T.contact_placeholder_email)}
|
||||
required
|
||||
bind:value={email}
|
||||
error={showError("email")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-field="subject">
|
||||
<TextInput
|
||||
size="sm"
|
||||
name="subject"
|
||||
label={t(T.contact_label_subject)}
|
||||
placeholder={t(T.contact_placeholder_subject)}
|
||||
bind:value={subject}
|
||||
error={showError("subject")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-field="message">
|
||||
<Textarea
|
||||
size="sm"
|
||||
name="message"
|
||||
label={t(T.contact_label_message)}
|
||||
placeholder={t(T.contact_placeholder_message)}
|
||||
required
|
||||
rows={5}
|
||||
bind:value={message}
|
||||
error={showError("message")}
|
||||
helpText={messageHelp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-field="consent" class="pt-1">
|
||||
<Checkbox
|
||||
name="consent"
|
||||
bind:checked={consent}
|
||||
label={t(T.contact_label_consent)}
|
||||
/>
|
||||
{#if showError("consent")}
|
||||
<p class="mt-1 text-[0.75rem] text-error" role="alert">{showError("consent")}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-stein-500">{t(T.contact_privacy_note)}</p>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
|
||||
<Button type="submit" variant="primary" loading={formState === "pending"} label="Senden" />
|
||||
<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.contact_submit)}
|
||||
/>
|
||||
{#if !isValid && submitAttempted && !serverError}
|
||||
<span class="text-xs text-error">{t(T.contact_fix_errors)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user