Merge origin/master

This commit is contained in:
2026-01-25 19:42:00 +01:00
419 changed files with 76479 additions and 36825 deletions
+89 -17
View File
@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { MapPin, Phone, Mail, Send, Loader2, CheckCircle2 } from 'lucide-react';
import { MapPin, Phone, Mail, Send, Loader2, CheckCircle2, AlertTriangle } from 'lucide-react';
import Image from 'next/image';
interface FormData {
@@ -19,12 +19,14 @@ export function ContactForm() {
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;
@@ -66,14 +68,55 @@ export function ContactForm() {
setIsSubmitting(true);
setSubmitStatus('idle');
setErrorMessage('');
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
setSubmitStatus('success');
setFormData({ name: '', email: '', message: '' });
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);
}
@@ -115,7 +158,7 @@ export function ContactForm() {
<div className="relative w-24 h-24 rounded-full overflow-hidden border-2 border-zinc-700 flex-shrink-0">
<Image
src="/images/headshot.webp"
alt="Damjan Savić"
alt="Damjan Savić - Full-Stack Web Developer"
fill
sizes="96px"
className="object-cover"
@@ -139,7 +182,7 @@ export function ContactForm() {
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" />
<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">
@@ -158,7 +201,7 @@ export function ContactForm() {
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" />
<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">
@@ -180,7 +223,7 @@ export function ContactForm() {
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" />
<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">
@@ -214,7 +257,7 @@ export function ContactForm() {
>
<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" />
<Mail className="h-5 w-5 text-zinc-400" aria-hidden="true" />
<h2 className="text-xl font-semibold text-white">
{t('contactForm.title')}
</h2>
@@ -237,10 +280,13 @@ export function ContactForm() {
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 className="text-sm text-red-400">{errors.name}</p>
<p id="name-error" role="alert" className="text-sm text-red-400">{errors.name}</p>
)}
</div>
@@ -256,10 +302,13 @@ export function ContactForm() {
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 className="text-sm text-red-400">{errors.email}</p>
<p id="email-error" role="alert" className="text-sm text-red-400">{errors.email}</p>
)}
</div>
@@ -276,10 +325,13 @@ export function ContactForm() {
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 className="text-sm text-red-400">{errors.message}</p>
<p id="message-error" role="alert" className="text-sm text-red-400">{errors.message}</p>
)}
<div className="flex justify-end">
<p
@@ -297,13 +349,29 @@ export function ContactForm() {
</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" />
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
<p className="text-green-400 text-sm">
{t('contactForm.successMessage')}
</p>
@@ -314,10 +382,12 @@ export function ContactForm() {
<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">
{t('contactForm.errorMessage')}
{errorMessage || t('contactForm.errorMessage')}
</p>
</motion.div>
)}
@@ -325,13 +395,15 @@ export function ContactForm() {
<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-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
) : (
<>
<Send className="h-5 w-5" />
<Send className="h-5 w-5" aria-hidden="true" />
{t('contactForm.submit')}
</>
)}