Add honeypot protection to contact forms

This commit is contained in:
Codex Agent
2026-01-23 15:38:34 +01:00
parent 531c666cf0
commit f19a83d4ee
10 changed files with 312 additions and 106 deletions

View File

@@ -25,6 +25,20 @@ interface Props {
packages: Package[];
}
interface HoneypotPayload {
enabled: boolean;
nameFieldName: string;
validFromFieldName: string;
encryptedValidFrom: string;
}
type ContactFormData = {
name: string;
email: string;
message: string;
[key: string]: string;
};
const heroBulletIcons = [Sparkles, ShieldCheck, Camera];
const howStepIcons = [QrCode, Smartphone, ShieldCheck];
@@ -36,13 +50,24 @@ const Home: React.FC<Props> = ({ packages }) => {
variant: heroCtaVariant,
trackClick: trackHeroCtaClick,
} = useCtaExperiment('home_hero_cta');
const { flash } = usePage<{ flash?: { success?: string } }>().props;
const { flash, honeypot } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
const shouldReduceMotion = useReducedMotion();
const { data, setData, post, processing, errors, reset } = useForm({
const honeypotDefaults = React.useMemo(() => {
if (!honeypot?.enabled) {
return {};
}
return {
[honeypot.nameFieldName]: '',
[honeypot.validFromFieldName]: honeypot.encryptedValidFrom,
};
}, [honeypot?.enabled, honeypot?.encryptedValidFrom, honeypot?.nameFieldName, honeypot?.validFromFieldName]);
const { data, setData, post, processing, errors, reset } = useForm<ContactFormData>({
name: '',
email: '',
message: '',
nickname: '',
...honeypotDefaults,
});
const viewportOnce = { once: true, amount: 0.25 };
@@ -619,91 +644,101 @@ const Home: React.FC<Props> = ({ packages }) => {
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<input
type="text"
name="nickname"
value={data.nickname}
onChange={(event) => setData('nickname', event.target.value)}
className="hidden"
tabIndex={-1}
autoComplete="off"
aria-hidden
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.name_label')} *
</label>
<Input
id="name"
value={data.name}
onChange={(event) => setData('name', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="name"
required
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'contact-name-error' : undefined}
/>
{errors.name && (
<p id="contact-name-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.name}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.email_label')} *
</label>
<Input
id="email"
type="email"
value={data.email}
onChange={(event) => setData('email', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="email"
required
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'contact-email-error' : undefined}
/>
{errors.email && (
<p id="contact-email-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.email}</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="message" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.message_label')} *
</label>
<Textarea
id="message"
rows={5}
value={data.message}
onChange={(event) => setData('message', event.target.value)}
className="rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
required
aria-invalid={Boolean(errors.message)}
aria-describedby={errors.message ? 'contact-message-error' : undefined}
/>
{errors.message && (
<p id="contact-message-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.message}</p>
)}
</div>
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('home.contact_privacy')}</p>
<Button
type="submit"
disabled={processing}
className="h-12 w-full rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t('home.sending')}
</span>
) : (
t('home.send')
{honeypot?.enabled ? (
<div className="hidden" aria-hidden>
<input
type="text"
name={honeypot.nameFieldName}
id={honeypot.nameFieldName}
value={data[honeypot.nameFieldName] ?? ''}
onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
autoComplete="off"
tabIndex={-1}
/>
<input
type="text"
name={honeypot.validFromFieldName}
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
readOnly
tabIndex={-1}
/>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.name_label')} *
</label>
<Input
id="name"
value={data.name}
onChange={(event) => setData('name', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="name"
required
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'contact-name-error' : undefined}
/>
{errors.name && (
<p id="contact-name-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.name}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.email_label')} *
</label>
<Input
id="email"
type="email"
value={data.email}
onChange={(event) => setData('email', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="email"
required
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'contact-email-error' : undefined}
/>
{errors.email && (
<p id="contact-email-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.email}</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="message" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.message_label')} *
</label>
<Textarea
id="message"
rows={5}
value={data.message}
onChange={(event) => setData('message', event.target.value)}
className="rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
required
aria-invalid={Boolean(errors.message)}
aria-describedby={errors.message ? 'contact-message-error' : undefined}
/>
{errors.message && (
<p id="contact-message-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.message}</p>
)}
</Button>
</div>
</form>
</div>
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('home.contact_privacy')}</p>
<Button
type="submit"
disabled={processing}
className="h-12 w-full rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t('home.sending')}
</span>
) : (
t('home.send')
)}
</Button>
</div>
</form>
</div>
</CardContent>
</Card>