feat(contact): full validation, CMS-translated strings, smaller UI
Deploy / verify (push) Failing after 47s
Deploy / deploy (push) Has been skipped

- 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:
Peter Meier
2026-05-21 11:28:23 +02:00
parent 8575572183
commit 94a8feeb67
7 changed files with 409 additions and 70 deletions
+11 -4
View File
@@ -11,18 +11,25 @@
error = '',
helpText = '',
invalid = false,
size = 'md' as 'sm' | 'md',
} = $props();
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
const sizeClasses = $derived(
size === 'sm'
? 'h-10 rounded-sm px-3 py-2 text-sm shadow-sm'
: 'h-12 rounded-lg px-4 py-3 text-base'
);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
class="mb-1.5 block {size === 'sm' ? 'text-xs' : 'text-sm'} font-medium text-stein-700"
>
{label}
{#if required}
@@ -41,16 +48,16 @@
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
class="{sizeClasses} w-full border bg-white text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
<p id={errorId} class="mt-1 text-[0.75rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
<p id={helpId} class="mt-1 text-[0.75rem] text-stein-500">
{helpText}
</p>
{/if}
+11 -4
View File
@@ -11,18 +11,25 @@
helpText = '',
invalid = false,
rows = 4,
size = 'md' as 'sm' | 'md',
} = $props();
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
const sizeClasses = $derived(
size === 'sm'
? 'min-h-[80px] rounded-sm px-3 py-2 text-sm shadow-sm'
: 'min-h-[120px] rounded-lg px-4 py-3 text-base'
);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
class="mb-1.5 block {size === 'sm' ? 'text-xs' : 'text-sm'} font-medium text-stein-700"
>
{label}
{#if required}
@@ -41,16 +48,16 @@
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
class="{sizeClasses} w-full resize-y border bg-white text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
></textarea>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
<p id={errorId} class="mt-1 text-[0.75rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
<p id={helpId} class="mt-1 text-[0.75rem] text-stein-500">
{helpText}
</p>
{/if}