TipTap (ProseMirror) was causing: - chunks 1007 (85 KiB) and 3207 (58 KiB) in the initial bundle - Array.prototype.at/flat/flatMap, Object.fromEntries/hasOwn polyfills (ProseMirror bundles core-js for these — the 12 KiB legacy JS flag) - 2+ seconds of main thread blocking on mobile Fix: move HTML conversion to the server (API route) and pass the resulting HTML string to the client, eliminating the need to import richTextToSafeHtml (and transitively TipTap) in any client component. Changes: - app/api/content/page/route.ts: call richTextToSafeHtml server-side, add html: string to response alongside existing content - app/components/RichTextClient.tsx: accept html string, remove all TipTap imports — TipTap/ProseMirror now has zero client bundle cost - app/components/About.tsx, Contact.tsx: use cmsHtml from API - app/legal-notice/page.tsx, privacy-policy/page.tsx: same - app/components/ClientWrappers.tsx: change static imports of About, Projects, Contact, Footer to next/dynamic so their JS is in separate lazy-loaded chunks, not in the initial bundle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
|
import { useToast } from "@/components/Toast";
|
|
import { useLocale, useTranslations } from "next-intl";
|
|
import dynamic from "next/dynamic";
|
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
|
|
|
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 [cmsHtml, setCmsHtml] = useState<string | 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?.html && data?.content?.locale === locale) {
|
|
setCmsHtml(data.content.html as string);
|
|
} else {
|
|
setCmsHtml(null);
|
|
}
|
|
} catch {
|
|
// ignore; fallback to static
|
|
setCmsHtml(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();
|
|
};
|
|
|
|
return (
|
|
<section
|
|
id="contact"
|
|
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
|
|
>
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
|
|
|
{/* Header Card */}
|
|
<motion.div
|
|
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
|
>
|
|
<div className="max-w-3xl">
|
|
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
|
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
|
</h2>
|
|
{cmsHtml ? (
|
|
<RichTextClient html={cmsHtml} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
|
) : (
|
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
|
{t("subtitle")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Info Side (Unified Connect Box) */}
|
|
<motion.div
|
|
transition={{ delay: 0.1 }}
|
|
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
|
|
>
|
|
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
|
|
<div className="relative z-10">
|
|
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
|
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
|
|
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
|
{/* Email */}
|
|
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
|
|
<div className="flex flex-col">
|
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
|
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
|
|
</div>
|
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
|
<Mail size={16} />
|
|
</div>
|
|
</a>
|
|
|
|
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
|
|
|
{/* GitHub */}
|
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
|
<div className="flex flex-col">
|
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
|
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
|
|
</div>
|
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
|
<Github size={16} />
|
|
</div>
|
|
</a>
|
|
|
|
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
|
|
|
{/* LinkedIn */}
|
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
|
<div className="flex flex-col">
|
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
|
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
|
|
</div>
|
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
|
<Linkedin size={16} />
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
|
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
|
|
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
|
|
<MapPin size={14} className="text-liquid-mint" />
|
|
<span className="font-bold">{tInfo("locationValue")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Form Side */}
|
|
<motion.div
|
|
transition={{ delay: 0.2 }}
|
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
|
>
|
|
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
|
|
{tForm("title")}
|
|
</h3>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
|
|
<div className="space-y-2">
|
|
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
{tForm("labels.name")}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="name"
|
|
name="name"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
|
placeholder={tForm("placeholders.name")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
{tForm("labels.email")}
|
|
</label>
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
value={formData.email}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
|
placeholder={tForm("placeholders.email")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
{tForm("labels.subject")}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="subject"
|
|
name="subject"
|
|
value={formData.subject}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
|
placeholder={tForm("placeholders.subject")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
|
{tForm("labels.message")}
|
|
</label>
|
|
<textarea
|
|
id="message"
|
|
name="message"
|
|
value={formData.message}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
required
|
|
rows={5}
|
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
|
|
placeholder={tForm("placeholders.message")}
|
|
/>
|
|
</div>
|
|
|
|
<motion.button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
whileHover={{ scale: 1.01 }}
|
|
whileTap={{ scale: 0.99 }}
|
|
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
|
|
>
|
|
{isSubmitting ? (
|
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
) : (
|
|
<>
|
|
<Send size={16} />
|
|
{tForm("send")}
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
</form>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default Contact;
|