Move hardcoded labels/strings in About, Projects, Contact form, Footer and Consent banner into next-intl message files (en/de) so content is maintained in one place. Co-authored-by: dennis <dennis@konkol.net>
442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { Mail, MapPin, Send } from "lucide-react";
|
|
import { useToast } from "@/components/Toast";
|
|
import { useLocale, useTranslations } from "next-intl";
|
|
import type { JSONContent } from "@tiptap/react";
|
|
import RichTextClient from "./RichTextClient";
|
|
|
|
const Contact = () => {
|
|
const { showEmailSent, showEmailError } = useToast();
|
|
const locale = useLocale();
|
|
const t = useTranslations("home.contact");
|
|
const tForm = useTranslations("home.contact.form");
|
|
const tInfo = useTranslations("home.contact.info");
|
|
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/content/page?key=${encodeURIComponent("home-contact")}&locale=${encodeURIComponent(locale)}`,
|
|
);
|
|
const data = await res.json();
|
|
// Only use CMS content if it exists for the active locale.
|
|
if (data?.content?.content && data?.content?.locale === locale) {
|
|
setCmsDoc(data.content.content as JSONContent);
|
|
} else {
|
|
setCmsDoc(null);
|
|
}
|
|
} catch {
|
|
// ignore; fallback to static
|
|
setCmsDoc(null);
|
|
}
|
|
})();
|
|
}, [locale]);
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: "",
|
|
email: "",
|
|
subject: "",
|
|
message: "",
|
|
});
|
|
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
|
|
const validateForm = () => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = tForm("errors.nameRequired");
|
|
} else if (formData.name.trim().length < 2) {
|
|
newErrors.name = tForm("errors.nameMin");
|
|
}
|
|
|
|
if (!formData.email.trim()) {
|
|
newErrors.email = tForm("errors.emailRequired");
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
newErrors.email = tForm("errors.emailInvalid");
|
|
}
|
|
|
|
if (!formData.subject.trim()) {
|
|
newErrors.subject = tForm("errors.subjectRequired");
|
|
} else if (formData.subject.trim().length < 3) {
|
|
newErrors.subject = tForm("errors.subjectMin");
|
|
}
|
|
|
|
if (!formData.message.trim()) {
|
|
newErrors.message = tForm("errors.messageRequired");
|
|
} else if (formData.message.trim().length < 10) {
|
|
newErrors.message = tForm("errors.messageMin");
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!validateForm()) {
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
const response = await fetch("/api/email", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name: formData.name,
|
|
email: formData.email,
|
|
subject: formData.subject,
|
|
message: formData.message,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
showEmailSent(formData.email);
|
|
setFormData({ name: "", email: "", subject: "", message: "" });
|
|
setTouched({});
|
|
setErrors({});
|
|
} else {
|
|
const errorData = await response.json();
|
|
showEmailError(
|
|
errorData.error || "Failed to send message. Please try again.",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error("Error sending email:", error);
|
|
}
|
|
showEmailError(
|
|
"Network error. Please check your connection and try again.",
|
|
);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleChange = (
|
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
) => {
|
|
const { name, value } = e.target;
|
|
setFormData({
|
|
...formData,
|
|
[name]: value,
|
|
});
|
|
|
|
// Clear error when user starts typing
|
|
if (errors[name]) {
|
|
setErrors({
|
|
...errors,
|
|
[name]: "",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleBlur = (
|
|
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
) => {
|
|
setTouched({
|
|
...touched,
|
|
[e.target.name]: true,
|
|
});
|
|
validateForm();
|
|
};
|
|
|
|
const contactInfo = [
|
|
{
|
|
icon: Mail,
|
|
title: tInfo("email"),
|
|
value: "contact@dk0.dev",
|
|
href: "mailto:contact@dk0.dev",
|
|
},
|
|
{
|
|
icon: MapPin,
|
|
title: tInfo("location"),
|
|
value: tInfo("locationValue"),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<section
|
|
id="contact"
|
|
className="py-24 px-4 relative bg-gradient-to-br from-liquid-teal/15 via-liquid-mint/10 to-liquid-lime/15"
|
|
>
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Section Header */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: "-50px" }}
|
|
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
|
className="text-center mb-16"
|
|
>
|
|
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
|
|
{t("title")}
|
|
</h2>
|
|
{cmsDoc ? (
|
|
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" />
|
|
) : (
|
|
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
|
|
{t("subtitle")}
|
|
</p>
|
|
)}
|
|
</motion.div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
{/* Contact Information */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -20 }}
|
|
whileInView={{ opacity: 1, x: 0 }}
|
|
viewport={{ once: true, margin: "-50px" }}
|
|
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
|
className="space-y-8"
|
|
>
|
|
<div>
|
|
<h3 className="text-2xl font-bold text-stone-900 mb-6">
|
|
{t("getInTouch")}
|
|
</h3>
|
|
<p className="text-stone-700 leading-relaxed">
|
|
{t("getInTouchBody")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Contact Details */}
|
|
<div className="space-y-4">
|
|
{contactInfo.map((info, index) => (
|
|
<motion.a
|
|
key={info.title}
|
|
href={info.href}
|
|
initial={{ opacity: 0, x: -10 }}
|
|
whileInView={{ opacity: 1, x: 0 }}
|
|
viewport={{ once: true, margin: "-50px" }}
|
|
transition={{
|
|
duration: 0.5,
|
|
delay: index * 0.1,
|
|
ease: [0.25, 0.1, 0.25, 1],
|
|
}}
|
|
whileHover={{
|
|
x: 8,
|
|
transition: { duration: 0.4, ease: "easeOut" },
|
|
}}
|
|
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/80 transition-all duration-500 ease-out group border-transparent hover:border-white/70"
|
|
>
|
|
<div className="p-3 bg-white rounded-xl shadow-sm group-hover:shadow-md transition-all">
|
|
<info.icon className="w-6 h-6 text-stone-700" />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold text-stone-800">
|
|
{info.title}
|
|
</h4>
|
|
<p className="text-stone-500">{info.value}</p>
|
|
</div>
|
|
</motion.a>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Contact Form */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 20 }}
|
|
whileInView={{ opacity: 1, x: 0 }}
|
|
viewport={{ once: true, margin: "-50px" }}
|
|
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
|
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
|
|
>
|
|
<h3 className="text-2xl font-bold text-gray-800 mb-6">
|
|
{tForm("title")}
|
|
</h3>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label
|
|
htmlFor="name"
|
|
className="block text-sm font-medium text-stone-600 mb-2"
|
|
>
|
|
Name <span className="text-liquid-rose">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="name"
|
|
name="name"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
|
errors.name && touched.name
|
|
? "border-red-400 focus:ring-red-400"
|
|
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
}`}
|
|
placeholder={tForm("placeholders.name")}
|
|
aria-invalid={
|
|
errors.name && touched.name ? "true" : "false"
|
|
}
|
|
aria-describedby={
|
|
errors.name && touched.name ? "name-error" : undefined
|
|
}
|
|
/>
|
|
{errors.name && touched.name && (
|
|
<p id="name-error" className="mt-1 text-sm text-red-500">
|
|
{errors.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="email"
|
|
className="block text-sm font-medium text-stone-600 mb-2"
|
|
>
|
|
Email <span className="text-liquid-rose">*</span>
|
|
</label>
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
value={formData.email}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
|
errors.email && touched.email
|
|
? "border-red-400 focus:ring-red-400"
|
|
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
}`}
|
|
placeholder={tForm("placeholders.email")}
|
|
aria-invalid={
|
|
errors.email && touched.email ? "true" : "false"
|
|
}
|
|
aria-describedby={
|
|
errors.email && touched.email ? "email-error" : undefined
|
|
}
|
|
/>
|
|
{errors.email && touched.email && (
|
|
<p id="email-error" className="mt-1 text-sm text-red-500">
|
|
{errors.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="subject"
|
|
className="block text-sm font-medium text-stone-600 mb-2"
|
|
>
|
|
Subject <span className="text-liquid-rose">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="subject"
|
|
name="subject"
|
|
value={formData.subject}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
|
errors.subject && touched.subject
|
|
? "border-red-400 focus:ring-red-400"
|
|
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
}`}
|
|
placeholder={tForm("placeholders.subject")}
|
|
aria-invalid={
|
|
errors.subject && touched.subject ? "true" : "false"
|
|
}
|
|
aria-describedby={
|
|
errors.subject && touched.subject
|
|
? "subject-error"
|
|
: undefined
|
|
}
|
|
/>
|
|
{errors.subject && touched.subject && (
|
|
<p id="subject-error" className="mt-1 text-sm text-red-500">
|
|
{errors.subject}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="message"
|
|
className="block text-sm font-medium text-stone-600 mb-2"
|
|
>
|
|
Message <span className="text-liquid-rose">*</span>
|
|
</label>
|
|
<textarea
|
|
id="message"
|
|
name="message"
|
|
value={formData.message}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
rows={6}
|
|
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all resize-none ${
|
|
errors.message && touched.message
|
|
? "border-red-400 focus:ring-red-400"
|
|
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
}`}
|
|
placeholder={tForm("placeholders.message")}
|
|
aria-invalid={
|
|
errors.message && touched.message ? "true" : "false"
|
|
}
|
|
aria-describedby={
|
|
errors.message && touched.message
|
|
? "message-error"
|
|
: undefined
|
|
}
|
|
/>
|
|
<div className="flex justify-between items-center mt-1">
|
|
{errors.message && touched.message ? (
|
|
<p id="message-error" className="text-sm text-red-500">
|
|
{errors.message}
|
|
</p>
|
|
) : (
|
|
<span></span>
|
|
)}
|
|
<span className="text-xs text-stone-400">
|
|
{tForm("characters", { count: formData.message.length })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<motion.button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}}
|
|
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
|
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
className="w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 disabled:hover:scale-100 disabled:hover:translate-y-0 bg-stone-900 text-white rounded-xl hover:bg-stone-950 transition-all duration-500 ease-out shadow-lg hover:shadow-xl"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
|
<span>{tForm("sending")}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send size={20} />
|
|
<span className="text-cream">{tForm("send")}</span>
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
</form>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default Contact;
|