- mailer/: standalone Express+nodemailer service on port 3000 with
validation, honeypot (company field), per-IP rate limiting (5/min),
Fastmail SMTP. Returns 400 on bad input, 429 on rate limit, 502 on
SMTP failure, 200 {ok:true} on success or honeypot hit.
- Dockerfile: multi-stage nginx build for humanai-landing container.
- mailer/Dockerfile: node:22-alpine, no exposed host ports.
- deploy.sh: idempotent deploy of both containers to piha.local via
Docker context 'piha', with smoke tests (400 on empty body, 200 on
honeypot fill).
- src/i18n/: minimal pl/en translation files; useT() hook picks
language from navigator.language.
- CTASection: replaced dead onSubmit with fetch POST /api/contact,
added hidden honeypot field, success/error states using i18n strings.
- README: Mailer section with two manual steps (credentials + NPM proxy).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
151 lines
6.1 KiB
TypeScript
151 lines
6.1 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Mail, MessageSquare } from 'lucide-react';
|
|
import { useT } from '../i18n';
|
|
|
|
type Status = 'idle' | 'sending' | 'success' | 'error';
|
|
|
|
const CTASection = () => {
|
|
const t = useT();
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [company, setCompany] = useState(''); // honeypot
|
|
const [status, setStatus] = useState<Status>('idle');
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setStatus('sending');
|
|
try {
|
|
const res = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, email, message, company }),
|
|
});
|
|
if (!res.ok) throw new Error(`${res.status}`);
|
|
setStatus('success');
|
|
} catch {
|
|
setStatus('error');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section id="contact" className="py-24 relative overflow-hidden">
|
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
|
|
|
|
<div className="container mx-auto px-6">
|
|
<div className="max-w-5xl mx-auto glass-card p-12 relative overflow-hidden">
|
|
<div className="absolute top-0 right-0 p-12 opacity-10 pointer-events-none">
|
|
<MessageSquare size={200} className="text-primary" />
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-2 gap-12 relative z-10">
|
|
<div>
|
|
<h2 className="text-4xl font-bold mb-6">
|
|
Masz proces, który powinien już pracować sam?
|
|
</h2>
|
|
<p className="text-xl text-muted mb-8">
|
|
Opowiedz, co robisz ręcznie, powtarzalnie albo zbyt wolno. Sprawdzimy, czy AI może to uprościć.
|
|
</p>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 text-primary">
|
|
<Mail size={20} />
|
|
<a href="mailto:hello@gethumanai.com" className="font-semibold hover:underline">
|
|
hello@gethumanai.com
|
|
</a>
|
|
</div>
|
|
<p className="text-sm text-muted italic">
|
|
"Na start wystarczy jeden proces, jeden problem albo jeden pomysł."
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{status === 'success' ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="flex flex-col justify-center items-center text-center gap-4"
|
|
>
|
|
<p className="text-2xl font-bold text-primary">{t.contact.successTitle}</p>
|
|
<p className="text-muted">{t.contact.successBody}</p>
|
|
</motion.div>
|
|
) : (
|
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
|
{/* Honeypot — hidden from real users */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}
|
|
>
|
|
<label htmlFor="company">Company</label>
|
|
<input
|
|
id="company"
|
|
type="text"
|
|
name="company"
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
value={company}
|
|
onChange={(e) => setCompany(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t.contact.labelName}</label>
|
|
<input
|
|
type="text"
|
|
placeholder={t.contact.placeholderName}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t.contact.labelEmail}</label>
|
|
<input
|
|
type="email"
|
|
placeholder={t.contact.placeholderEmail}
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t.contact.labelMessage}</label>
|
|
<textarea
|
|
rows={4}
|
|
placeholder={t.contact.placeholderMessage}
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
required
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{status === 'error' && (
|
|
<p className="text-sm text-red-400">
|
|
<span className="font-semibold">{t.contact.errorTitle}</span>{' '}
|
|
{t.contact.errorBody}
|
|
</p>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={status === 'sending'}
|
|
className="btn-primary w-full py-4 text-lg disabled:opacity-60"
|
|
>
|
|
{status === 'sending' ? t.contact.sending : t.contact.submit}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default CTASection;
|