front: poprawny adres kontaktowy + walidacja formularza
- zamiana wszystkich adresów e-mail @gethumanai.com -> @gethumanai.pl (CTASection, Footer, i18n pl/en) - walidacja po stronie klienta przed wysłaniem (name niepuste, email zawiera "@", message >= 5 znaków — zgodne z mailer.mjs) - mapowanie kodów błędów mailera na czytelne komunikaty w i18n (pl/en), pokazywane zamiast generycznego błędu; honeypot company bez zmian Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2067c79c67
commit
09b9bf90bd
|
|
@ -12,9 +12,46 @@ const CTASection = () => {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [company, setCompany] = useState(''); // honeypot
|
const [company, setCompany] = useState(''); // honeypot
|
||||||
const [status, setStatus] = useState<Status>('idle');
|
const [status, setStatus] = useState<Status>('idle');
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
message?: string;
|
||||||
|
}>({});
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
// Map mailer error codes (mailer.mjs) to human-readable, localized text.
|
||||||
|
const mapBackendError = (code: string) => {
|
||||||
|
switch (code) {
|
||||||
|
case 'name required':
|
||||||
|
return t.contact.errors.nameRequired;
|
||||||
|
case 'valid email required':
|
||||||
|
return t.contact.errors.emailInvalid;
|
||||||
|
case 'message too short':
|
||||||
|
return t.contact.errors.messageTooShort;
|
||||||
|
case 'Too many requests':
|
||||||
|
return t.contact.errors.rateLimited;
|
||||||
|
case 'mail delivery failed':
|
||||||
|
return t.contact.errors.mailFailed;
|
||||||
|
default:
|
||||||
|
return t.contact.errors.generic;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Client-side validation — kept in sync with mailer.mjs thresholds.
|
||||||
|
const errs: { name?: string; email?: string; message?: string } = {};
|
||||||
|
if (!name.trim()) errs.name = t.contact.errors.nameRequired;
|
||||||
|
if (!email.includes('@')) errs.email = t.contact.errors.emailInvalid;
|
||||||
|
if (message.trim().length < 5) errs.message = t.contact.errors.messageTooShort;
|
||||||
|
setFieldErrors(errs);
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
if (status !== 'idle') setStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMsg('');
|
||||||
setStatus('sending');
|
setStatus('sending');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/contact', {
|
const res = await fetch('/api/contact', {
|
||||||
|
|
@ -22,9 +59,21 @@ const CTASection = () => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, email, message, company }),
|
body: JSON.stringify({ name, email, message, company }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) {
|
||||||
|
let code = '';
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
code = typeof data?.error === 'string' ? data.error : '';
|
||||||
|
} catch {
|
||||||
|
/* response had no JSON body */
|
||||||
|
}
|
||||||
|
setErrorMsg(mapBackendError(code));
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
} catch {
|
} catch {
|
||||||
|
setErrorMsg(t.contact.errors.generic);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -50,8 +99,8 @@ const CTASection = () => {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 text-primary">
|
<div className="flex items-center gap-3 text-primary">
|
||||||
<Mail size={20} />
|
<Mail size={20} />
|
||||||
<a href="mailto:hello@gethumanai.com" className="font-semibold hover:underline">
|
<a href="mailto:hello@gethumanai.pl" className="font-semibold hover:underline">
|
||||||
hello@gethumanai.com
|
hello@gethumanai.pl
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted italic">
|
<p className="text-sm text-muted italic">
|
||||||
|
|
@ -97,8 +146,12 @@ const CTASection = () => {
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
|
aria-invalid={!!fieldErrors.name}
|
||||||
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
||||||
/>
|
/>
|
||||||
|
{fieldErrors.name && (
|
||||||
|
<p className="text-sm text-red-400">{fieldErrors.name}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">{t.contact.labelEmail}</label>
|
<label className="text-sm font-medium">{t.contact.labelEmail}</label>
|
||||||
|
|
@ -108,8 +161,12 @@ const CTASection = () => {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
aria-invalid={!!fieldErrors.email}
|
||||||
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
||||||
/>
|
/>
|
||||||
|
{fieldErrors.email && (
|
||||||
|
<p className="text-sm text-red-400">{fieldErrors.email}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -120,14 +177,18 @@ const CTASection = () => {
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
required
|
required
|
||||||
|
aria-invalid={!!fieldErrors.message}
|
||||||
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors resize-none"
|
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors resize-none"
|
||||||
/>
|
/>
|
||||||
|
{fieldErrors.message && (
|
||||||
|
<p className="text-sm text-red-400">{fieldErrors.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<p className="text-sm text-red-400">
|
<p className="text-sm text-red-400">
|
||||||
<span className="font-semibold">{t.contact.errorTitle}</span>{' '}
|
<span className="font-semibold">{t.contact.errorTitle}</span>{' '}
|
||||||
{t.contact.errorBody}
|
{errorMsg || t.contact.errorBody}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ const Footer = () => {
|
||||||
<p className="text-muted max-w-xs mb-6">
|
<p className="text-muted max-w-xs mb-6">
|
||||||
ludzka strona AI — budujemy praktyczne systemy AI dla ludzi i organizacji.
|
ludzka strona AI — budujemy praktyczne systemy AI dla ludzi i organizacji.
|
||||||
</p>
|
</p>
|
||||||
<a href="mailto:hello@gethumanai.com" className="text-foreground/80 hover:text-primary transition-colors">
|
<a href="mailto:hello@gethumanai.pl" className="text-foreground/80 hover:text-primary transition-colors">
|
||||||
hello@gethumanai.com
|
hello@gethumanai.pl
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ export const en = {
|
||||||
successTitle: 'Message sent!',
|
successTitle: 'Message sent!',
|
||||||
successBody: "We'll get back to you shortly.",
|
successBody: "We'll get back to you shortly.",
|
||||||
errorTitle: 'Something went wrong.',
|
errorTitle: 'Something went wrong.',
|
||||||
errorBody: 'Please try again or email us directly at hello@gethumanai.com.',
|
errorBody: 'Please try again or email us directly at hello@gethumanai.pl.',
|
||||||
|
errors: {
|
||||||
|
nameRequired: 'Please enter your name.',
|
||||||
|
emailInvalid: 'Please enter a valid email address.',
|
||||||
|
messageTooShort: 'Your message must be at least 5 characters long.',
|
||||||
|
rateLimited: 'Too many attempts. Please wait a moment and try again.',
|
||||||
|
mailFailed: 'We could not send your message. Please try again or email us directly at hello@gethumanai.pl.',
|
||||||
|
generic: 'Please try again or email us directly at hello@gethumanai.pl.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ export const pl = {
|
||||||
successTitle: 'Wiadomość wysłana!',
|
successTitle: 'Wiadomość wysłana!',
|
||||||
successBody: 'Odezwiemy się wkrótce.',
|
successBody: 'Odezwiemy się wkrótce.',
|
||||||
errorTitle: 'Coś poszło nie tak.',
|
errorTitle: 'Coś poszło nie tak.',
|
||||||
errorBody: 'Spróbuj ponownie lub napisz bezpośrednio na hello@gethumanai.com.',
|
errorBody: 'Spróbuj ponownie lub napisz bezpośrednio na hello@gethumanai.pl.',
|
||||||
|
errors: {
|
||||||
|
nameRequired: 'Podaj imię.',
|
||||||
|
emailInvalid: 'Podaj poprawny adres e-mail.',
|
||||||
|
messageTooShort: 'Wiadomość musi mieć co najmniej 5 znaków.',
|
||||||
|
rateLimited: 'Zbyt wiele prób. Odczekaj chwilę i spróbuj ponownie.',
|
||||||
|
mailFailed: 'Nie udało się wysłać wiadomości. Spróbuj ponownie lub napisz bezpośrednio na hello@gethumanai.pl.',
|
||||||
|
generic: 'Spróbuj ponownie lub napisz bezpośrednio na hello@gethumanai.pl.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue