Migrate from Vite to Next.js 15 with SSR

- Replace Vite + React Router with Next.js 15 App Router
- Implement i18n with next-intl (URL-based: /de, /en, /sr)
- Add SSR/SSG for all pages (48 static pages generated)
- Setup Supabase SSR client for auth
- Migrate all pages: Home, About, Portfolio, Blog, Contact, Login, Dashboard, Imprint, Privacy, Terms
- Add Docker support with standalone output
- Replace i18next with next-intl JSON translations
- Use next/image for optimized images

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 01:00:33 +01:00
co-authored by Claude Opus 4.5
parent a66f51b9a2
commit b1ec7b4d61
281 changed files with 8024 additions and 8901 deletions
@@ -0,0 +1,235 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '../../../components/Card';
import { Input } from '../../../components/Input';
import { Textarea } from '../../../components/TextArea';
import { Label } from '../../../components/Label';
import { Button } from '../../../components/Button';
import { Alert, AlertDescription } from '../../../components/Alert';
import { Loader2, Send, CheckCircle2, Mail } from 'lucide-react';
import { trackContactFormSubmit } from '../../../services/gtm';
interface FormData {
name: string;
email: string;
message: string;
}
const ContactForm = () => {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: ''
});
const [errors, setErrors] = useState<Partial<FormData>>({});
const validateForm = () => {
const newErrors: Partial<FormData> = {};
if (!formData.name.trim()) {
newErrors.name = t('pages.contact.contactForm.errors.nameRequired');
}
if (!formData.email.trim()) {
newErrors.email = t('pages.contact.contactForm.errors.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t('pages.contact.contactForm.errors.emailInvalid');
}
if (!formData.message.trim()) {
newErrors.message = t('pages.contact.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');
try {
// Track form submission
trackContactFormSubmit({
name: formData.name,
email: formData.email,
subject: 'Contact Form'
});
// Simuliere einen API-Aufruf
await new Promise(resolve => setTimeout(resolve, 1500));
setSubmitStatus('success');
setFormData({ name: '', email: '', message: '' });
} catch {
setSubmitStatus('error');
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="h-full bg-zinc-800/30 border border-zinc-800">
<CardHeader>
<div className="flex items-center gap-2 mb-2">
<Mail className="h-5 w-5 text-zinc-400" />
<CardTitle className="text-xl font-semibold text-white">
{t('pages.contact.contactForm.title')}
</CardTitle>
</div>
<CardDescription className="text-zinc-400">
{t('pages.contact.contactForm.description')}
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit}
className="space-y-6"
aria-label={t('pages.contact.contactForm.aria.form')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-zinc-300">
{t('pages.contact.contactForm.name.label')}
</Label>
<Input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
placeholder={t('pages.contact.contactForm.name.placeholder')}
className="h-12 bg-zinc-900/50 border-zinc-800 text-white placeholder:text-zinc-500"
aria-invalid={!!errors.name}
disabled={isSubmitting}
/>
{errors.name && (
<p className="text-sm text-red-400 mt-1">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-zinc-300">
{t('pages.contact.contactForm.email.label')}
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder={t('pages.contact.contactForm.email.placeholder')}
className="h-12 bg-zinc-900/50 border-zinc-800 text-white placeholder:text-zinc-500"
aria-invalid={!!errors.email}
disabled={isSubmitting}
/>
{errors.email && (
<p className="text-sm text-red-400 mt-1">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="message" className="text-zinc-300">
{t('pages.contact.contactForm.message.label')}
</Label>
<Textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder={t('pages.contact.contactForm.message.placeholder')}
rows={6}
className="bg-zinc-900/50 border-zinc-800 text-white placeholder:text-zinc-500"
aria-invalid={!!errors.message}
disabled={isSubmitting}
/>
{errors.message && (
<p className="text-sm text-red-400 mt-1">{errors.message}</p>
)}
</div>
</div>
{submitStatus === 'success' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
role="alert"
aria-label={t('pages.contact.contactForm.aria.successAlert')}
>
<div className="bg-zinc-900/50 border-zinc-700 text-zinc-300 p-3 rounded">
<Alert>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<AlertDescription>
{t('pages.contact.contactForm.successMessage')}
</AlertDescription>
</Alert>
</div>
</motion.div>
)}
{submitStatus === 'error' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
role="alert"
aria-label={t('pages.contact.contactForm.aria.errorAlert')}
>
<div className="bg-red-900/20 border-red-900/50 text-red-400 p-3 rounded">
<Alert>
<AlertDescription>
{t('pages.contact.contactForm.errorMessage')}
</AlertDescription>
</Alert>
</div>
</motion.div>
)}
<Button
type="submit"
className="w-full h-12 bg-zinc-100 hover:bg-white text-zinc-900 font-medium transition-colors duration-300"
disabled={isSubmitting}
aria-label={
isSubmitting
? t('pages.contact.contactForm.aria.submitting')
: undefined
}
>
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<Send className="h-5 w-5 mr-2" />
{t('pages.contact.contactForm.submit')}
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
};
export default ContactForm;
@@ -0,0 +1,137 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Phone, Mail, MapPin, ExternalLink, Building } from 'lucide-react';
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../components/Card';
interface ContactMethod {
icon: React.ReactNode;
label: string;
value: string;
href: string;
type: 'address' | 'phone' | 'email';
ariaLabel: string;
}
const ContactInfo = () => {
const { t } = useTranslation();
const contactMethods: ContactMethod[] = [
{
icon: <MapPin className="h-5 w-5" />,
label: t('pages.home.contactinfo.addressLabel'),
value: t('pages.home.contactinfo.address'),
href: '',
type: 'address',
ariaLabel: t('pages.home.contactinfo.aria.addressLink')
},
{
icon: <Phone className="h-5 w-5" />,
label: t('pages.home.contactinfo.phoneLabel'),
value: t('pages.home.contactinfo.phone'),
href: 'tel:+49 175 695 0979',
type: 'phone',
ariaLabel: t('pages.home.contactinfo.aria.phoneLink')
},
{
icon: <Mail className="h-5 w-5" />,
label: t('pages.home.contactinfo.emailLabel'),
value: t('pages.home.contactinfo.email'),
href: 'mailto:info@damjan-savic.com',
type: 'email',
ariaLabel: t('pages.home.contactinfo.aria.emailLink')
}
];
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
};
return (
<Card
className="h-full bg-zinc-800/30 border border-zinc-800"
aria-label={t('pages.home.contactinfo.aria.contactCard')}
>
<CardHeader>
<div className="flex items-center gap-2 mb-2">
<Building className="h-5 w-5 text-zinc-400" />
<CardTitle className="text-xl font-semibold text-white">
{t('pages.home.contactinfo.title')}
</CardTitle>
</div>
<CardDescription className="text-zinc-400">
{t('pages.home.contactinfo.description')}
</CardDescription>
</CardHeader>
<CardContent>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-4"
>
{contactMethods.map((method) => (
<motion.div key={method.type} variants={item}>
<Card className="group bg-zinc-900/50 border border-zinc-800
hover:bg-zinc-900/70 hover:border-zinc-700
transition-all duration-300">
<CardContent className="p-4">
<a
href={method.href}
target={method.type === 'address' ? '_blank' : undefined}
rel={method.type === 'address' ? 'noopener noreferrer' : undefined}
className="flex items-center gap-4"
aria-label={method.ariaLabel}
>
<div className="rounded-full p-2.5 bg-zinc-800/50 text-zinc-400
group-hover:bg-zinc-800 group-hover:text-white
transition-colors duration-300">
{method.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-400 mb-1">
{method.label}
</p>
<p className="text-base font-medium text-white truncate
group-hover:text-zinc-100">
{method.value}
</p>
</div>
<div className="self-center opacity-0 group-hover:opacity-100
transition-opacity duration-300">
<ExternalLink
className="h-4 w-4 text-zinc-400 group-hover:text-white"
aria-label={method.type === 'address' ? t('pages.home.contactinfo.aria.externalLink') : undefined}
/>
</div>
</a>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
<div className="mt-8 pt-6 border-t border-zinc-800 text-center">
<p className="text-sm text-zinc-400">
{t('pages.home.contactinfo.availabilityNote')}
</p>
</div>
</CardContent>
</Card>
);
};
export default ContactInfo;
+109
View File
@@ -0,0 +1,109 @@
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { MapPin, Phone, Mail } from 'lucide-react';
import PageTransition from '../../components/PageTransition';
import ContactForm from './components/ContactForm';
const ContactPage = () => {
const { t } = useTranslation();
return (
<PageTransition>
<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('pages.contact.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"
>
{t('pages.contact.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"
>
<div>
<h2 className="text-2xl font-bold text-white mb-4">
{t('pages.contact.contactInfo.title')}
</h2>
<p className="text-zinc-400 mb-8">
{t('pages.contact.contactInfo.description')}
</p>
</div>
<div className="space-y-6">
<div className="flex items-start gap-4">
<MapPin className="h-6 w-6 text-zinc-400 mt-1" />
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('pages.contact.contactInfo.address.label')}
</h3>
<p className="text-zinc-400">
{t('pages.contact.contactInfo.address.value')}
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Phone className="h-6 w-6 text-zinc-400 mt-1" />
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('pages.contact.contactInfo.phone.label')}
</h3>
<p className="text-zinc-400">
{t('pages.contact.contactInfo.phone.value')}
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Mail className="h-6 w-6 text-zinc-400 mt-1" />
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('pages.contact.contactInfo.email.label')}
</h3>
<p className="text-zinc-400">
{t('pages.contact.contactInfo.email.value')}
</p>
</div>
</div>
</div>
<p className="text-sm text-zinc-500">
{t('pages.contact.contactInfo.availabilityNote')}
</p>
</motion.div>
{/* Contact Form */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<ContactForm />
</motion.div>
</div>
</div>
</div>
</PageTransition>
);
};
export default ContactPage;