Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { motion } from 'framer-motion';
|
|
import { MapPin, Phone, Mail, Send, Loader2, CheckCircle2, AlertTriangle } from 'lucide-react';
|
|
import Image from 'next/image';
|
|
|
|
interface FormData {
|
|
name: string;
|
|
email: string;
|
|
message: string;
|
|
}
|
|
|
|
export function ContactForm() {
|
|
const t = useTranslations('pages.contact');
|
|
|
|
const MAX_MESSAGE_LENGTH = 1000;
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
|
const [formData, setFormData] = useState<FormData>({
|
|
name: '',
|
|
email: '',
|
|
message: ''
|
|
});
|
|
const [errors, setErrors] = useState<Partial<FormData>>({});
|
|
const [remainingAttempts, setRemainingAttempts] = useState<number | null>(null);
|
|
|
|
const messageLength = formData.message.length;
|
|
|
|
const validateForm = () => {
|
|
const newErrors: Partial<FormData> = {};
|
|
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = t('contactForm.errors.nameRequired');
|
|
}
|
|
|
|
if (!formData.email.trim()) {
|
|
newErrors.email = t('contactForm.errors.emailRequired');
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
newErrors.email = t('contactForm.errors.emailInvalid');
|
|
}
|
|
|
|
if (!formData.message.trim()) {
|
|
newErrors.message = t('contactForm.errors.messageRequired');
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
const handleChange = (
|
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({ ...prev, [name]: value }));
|
|
if (errors[name as keyof FormData]) {
|
|
setErrors(prev => ({ ...prev, [name]: undefined }));
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!validateForm()) return;
|
|
|
|
setIsSubmitting(true);
|
|
setSubmitStatus('idle');
|
|
setErrorMessage('');
|
|
|
|
try {
|
|
const response = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
// Parse rate limit headers
|
|
const rateLimitRemaining = response.headers.get('X-RateLimit-Remaining');
|
|
if (rateLimitRemaining !== null) {
|
|
setRemainingAttempts(parseInt(rateLimitRemaining, 10));
|
|
}
|
|
|
|
if (response.ok) {
|
|
setSubmitStatus('success');
|
|
setFormData({ name: '', email: '', message: '' });
|
|
} else if (response.status === 429) {
|
|
// Rate limit exceeded
|
|
setSubmitStatus('error');
|
|
setRemainingAttempts(0);
|
|
const retryAfter = data.retryAfter || 0;
|
|
const minutes = Math.ceil(retryAfter / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
|
|
let timeMessage = '';
|
|
if (hours > 0) {
|
|
timeMessage = `${hours} hour${hours > 1 ? 's' : ''}${remainingMinutes > 0 ? ` and ${remainingMinutes} minute${remainingMinutes > 1 ? 's' : ''}` : ''}`;
|
|
} else {
|
|
timeMessage = `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
setErrorMessage(
|
|
t('contactForm.rateLimit.error', { time: timeMessage })
|
|
);
|
|
} else {
|
|
// Other errors (400, 500, etc.)
|
|
setSubmitStatus('error');
|
|
setErrorMessage(data.error || t('contactForm.errorMessage'));
|
|
}
|
|
} catch {
|
|
setSubmitStatus('error');
|
|
setErrorMessage(t('contactForm.errorMessage'));
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
{/* Hero Section */}
|
|
<div className="py-24 text-center">
|
|
<motion.h1
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="text-4xl md:text-5xl font-bold text-white mb-4"
|
|
>
|
|
{t('hero.title')}
|
|
</motion.h1>
|
|
<motion.p
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.1 }}
|
|
className="text-lg text-zinc-400 max-w-2xl mx-auto px-4"
|
|
>
|
|
{t('hero.subtitle')}
|
|
</motion.p>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-24">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
|
{/* Contact Info */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
className="space-y-8"
|
|
>
|
|
{/* Headshot */}
|
|
<div className="flex items-center gap-6">
|
|
<div className="relative w-24 h-24 rounded-full overflow-hidden border-2 border-zinc-700 flex-shrink-0">
|
|
<Image
|
|
src="/images/headshot.avif"
|
|
alt="Damjan Savić - Full-Stack Web Developer"
|
|
fill
|
|
sizes="96px"
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-white mb-1">
|
|
{t('contactInfo.title')}
|
|
</h2>
|
|
<p className="text-zinc-400">
|
|
{t('contactInfo.description')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3 }}
|
|
className="flex items-start gap-4"
|
|
>
|
|
<div className="p-3 bg-zinc-800/50 rounded-lg">
|
|
<MapPin className="h-6 w-6 text-zinc-400" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-medium text-zinc-300 mb-1">
|
|
{t('contactInfo.address.label')}
|
|
</h3>
|
|
<p className="text-zinc-400">
|
|
{t('contactInfo.address.value')}
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.4 }}
|
|
className="flex items-start gap-4"
|
|
>
|
|
<div className="p-3 bg-zinc-800/50 rounded-lg">
|
|
<Phone className="h-6 w-6 text-zinc-400" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-medium text-zinc-300 mb-1">
|
|
{t('contactInfo.phone.label')}
|
|
</h3>
|
|
<a
|
|
href="tel:+491756950979"
|
|
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
|
>
|
|
{t('contactInfo.phone.value')}
|
|
</a>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.5 }}
|
|
className="flex items-start gap-4"
|
|
>
|
|
<div className="p-3 bg-zinc-800/50 rounded-lg">
|
|
<Mail className="h-6 w-6 text-zinc-400" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-medium text-zinc-300 mb-1">
|
|
{t('contactInfo.email.label')}
|
|
</h3>
|
|
<a
|
|
href="mailto:info@damjan-savic.com"
|
|
className="text-zinc-400 hover:text-white transition-colors duration-200"
|
|
>
|
|
{t('contactInfo.email.value')}
|
|
</a>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
<motion.p
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.6 }}
|
|
className="text-sm text-zinc-500"
|
|
>
|
|
{t('contactInfo.availabilityNote')}
|
|
</motion.p>
|
|
</motion.div>
|
|
|
|
{/* Contact Form */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.3 }}
|
|
>
|
|
<div className="bg-zinc-800/30 border border-zinc-800 rounded-xl p-6 md:p-8">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Mail className="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
|
<h2 className="text-xl font-semibold text-white">
|
|
{t('contactForm.title')}
|
|
</h2>
|
|
</div>
|
|
<p className="text-zinc-400 mb-6">
|
|
{t('contactForm.description')}
|
|
</p>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label htmlFor="name" className="block text-sm text-zinc-300">
|
|
{t('contactForm.name.label')}
|
|
</label>
|
|
<input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
placeholder={t('contactForm.name.placeholder')}
|
|
disabled={isSubmitting}
|
|
aria-required="true"
|
|
aria-invalid={!!errors.name}
|
|
aria-describedby={errors.name ? 'name-error' : undefined}
|
|
className="w-full h-12 px-4 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50"
|
|
/>
|
|
{errors.name && (
|
|
<p id="name-error" role="alert" className="text-sm text-red-400">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="email" className="block text-sm text-zinc-300">
|
|
{t('contactForm.email.label')}
|
|
</label>
|
|
<input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={handleChange}
|
|
placeholder={t('contactForm.email.placeholder')}
|
|
disabled={isSubmitting}
|
|
aria-required="true"
|
|
aria-invalid={!!errors.email}
|
|
aria-describedby={errors.email ? 'email-error' : undefined}
|
|
className="w-full h-12 px-4 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50"
|
|
/>
|
|
{errors.email && (
|
|
<p id="email-error" role="alert" className="text-sm text-red-400">{errors.email}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="message" className="block text-sm text-zinc-300">
|
|
{t('contactForm.message.label')}
|
|
</label>
|
|
<textarea
|
|
id="message"
|
|
name="message"
|
|
value={formData.message}
|
|
onChange={handleChange}
|
|
placeholder={t('contactForm.message.placeholder')}
|
|
rows={6}
|
|
maxLength={MAX_MESSAGE_LENGTH}
|
|
disabled={isSubmitting}
|
|
aria-required="true"
|
|
aria-invalid={!!errors.message}
|
|
aria-describedby={errors.message ? 'message-error' : undefined}
|
|
className="w-full px-4 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50 resize-none"
|
|
/>
|
|
{errors.message && (
|
|
<p id="message-error" role="alert" className="text-sm text-red-400">{errors.message}</p>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<p
|
|
className={`text-xs transition-colors duration-200 ${
|
|
messageLength > MAX_MESSAGE_LENGTH
|
|
? 'text-red-500'
|
|
: messageLength >= MAX_MESSAGE_LENGTH * 0.8
|
|
? 'text-yellow-500'
|
|
: 'text-zinc-500'
|
|
}`}
|
|
>
|
|
{t('contactForm.characterCounter', { current: messageLength, max: MAX_MESSAGE_LENGTH })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rate limit warning - show when attempts are low */}
|
|
{remainingAttempts !== null && remainingAttempts > 0 && remainingAttempts <= 2 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="flex items-center gap-2 p-3 bg-yellow-900/20 border border-yellow-900/50 rounded-lg"
|
|
>
|
|
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
|
<p className="text-yellow-400 text-sm">
|
|
{t('contactForm.rateLimit.warning', { count: remainingAttempts })}
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
|
|
{submitStatus === 'success' && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
role="alert"
|
|
aria-live="polite"
|
|
className="flex items-center gap-2 p-3 bg-green-900/20 border border-green-900/50 rounded-lg"
|
|
>
|
|
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
|
|
<p className="text-green-400 text-sm">
|
|
{t('contactForm.successMessage')}
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
|
|
{submitStatus === 'error' && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
role="alert"
|
|
aria-live="assertive"
|
|
className="p-3 bg-red-900/20 border border-red-900/50 rounded-lg"
|
|
>
|
|
<p className="text-red-400 text-sm">
|
|
{errorMessage || t('contactForm.errorMessage')}
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
aria-busy={isSubmitting}
|
|
aria-label={isSubmitting ? t('contactForm.submitting') : undefined}
|
|
className="w-full h-12 bg-zinc-100 hover:bg-white text-zinc-900 font-medium rounded-lg transition-colors duration-200 duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isSubmitting ? (
|
|
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<>
|
|
<Send className="h-5 w-5" aria-hidden="true" />
|
|
{t('contactForm.submit')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|