Add honeypot protection to contact forms
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -6,15 +6,40 @@ import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
interface HoneypotPayload {
|
||||
enabled: boolean;
|
||||
nameFieldName: string;
|
||||
validFromFieldName: string;
|
||||
encryptedValidFrom: string;
|
||||
}
|
||||
|
||||
type ContactFormData = {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
const Kontakt: React.FC = () => {
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
const { honeypot, flash } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
|
||||
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 { flash } = usePage<{ flash?: { success?: string } }>().props;
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
@@ -51,16 +76,26 @@ const Kontakt: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
name="nickname"
|
||||
value={data.nickname}
|
||||
onChange={(e) => setData('nickname', e.target.value)}
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden
|
||||
/>
|
||||
{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>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.name')}</label>
|
||||
<input
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock('@inertiajs/react', () => ({
|
||||
Head: () => null,
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
useForm: () => ({
|
||||
data: { name: '', email: '', message: '', nickname: '' },
|
||||
data: { name: '', email: '', message: '' },
|
||||
setData: vi.fn(),
|
||||
post: vi.fn(),
|
||||
processing: false,
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock('@inertiajs/react', () => ({
|
||||
Head: () => null,
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
useForm: () => ({
|
||||
data: { name: '', email: '', message: '', nickname: '' },
|
||||
data: { name: '', email: '', message: '' },
|
||||
setData: vi.fn(),
|
||||
post: vi.fn(),
|
||||
processing: false,
|
||||
|
||||
Reference in New Issue
Block a user