9e6ae866d0
- AI chat moves into a window/sheet, opened from a new speech-bubble card on the desk; it now respects the chatbot consent and asks before sending anything to n8n (previously it ignored the consent) - Contact as red poster section with big mail address and the form on a paper card; validation errors are finally shown inline (aria-invalid, role=alert) - Footer as ink strip with dk0 wordmark, legal links and a button to reopen the privacy settings - "About me" in the header opens the about window on the home page - Remove the old About section and its parts (BentoChat, ActivityFeed, CurrentlyReading, ReadBooks), the unused ChatWidget, Projects and BentoGrid, their tests, wrappers, loaders, types and texts - scripts/directus-cleanup-redesign.mjs: dry run by default, --apply archives hobbies, tech stack categories and old content pages and deletes four unused message overrides after a full JSON backup; run only after the redesign is live on production Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { 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();
|
|
setTouched({ name: true, email: true, subject: true, message: true });
|
|
|
|
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') {
|
|
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 fields: { name: keyof typeof formData; type: string; multiline?: boolean; autoComplete?: string }[] = [
|
|
{ name: "name", type: "text", autoComplete: "name" },
|
|
{ name: "email", type: "email", autoComplete: "email" },
|
|
{ name: "subject", type: "text" },
|
|
{ name: "message", type: "text", multiline: true },
|
|
];
|
|
|
|
const pill =
|
|
"inline-flex items-center gap-2 rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-signal active:scale-[0.97]";
|
|
|
|
return (
|
|
<section id="contact" className="border-t-2 border-ink bg-signal px-4 pb-16 pt-14 text-ink sm:px-6 md:pb-24 md:pt-20 lg:px-10">
|
|
<div className="grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:items-start">
|
|
<div className="min-w-0">
|
|
<p className="font-mono text-[13px]">{tInfo("locationValue")}</p>
|
|
<h2 className="mt-3 font-display text-[clamp(3rem,9vw,7.5rem)] font-extrabold leading-[0.86] tracking-[-0.05em] [font-variation-settings:'wdth'_80]">
|
|
{t("title")}
|
|
</h2>
|
|
{cmsHtml ? (
|
|
<RichTextClient html={cmsHtml} className="prose mt-6 max-w-[46ch] text-lg leading-relaxed text-ink [&_*]:text-inherit" />
|
|
) : (
|
|
<p className="mt-6 max-w-[46ch] text-lg leading-relaxed">{t("subtitle")}</p>
|
|
)}
|
|
<a
|
|
href="mailto:contact@dk0.dev"
|
|
className="mt-8 block w-fit font-display text-[clamp(2rem,6vw,4.4rem)] font-extrabold leading-none tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_80] hover:underline hover:decoration-[3px] hover:underline-offset-[6px]"
|
|
>
|
|
contact<span className="text-paper">@</span>dk0.dev
|
|
</a>
|
|
<div className="mt-6 flex flex-wrap gap-2">
|
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
|
|
<Github size={16} aria-hidden="true" /> GitHub
|
|
</a>
|
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
|
|
<Linkedin size={16} aria-hidden="true" /> LinkedIn
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
noValidate
|
|
className="grid gap-4 rounded-[18px] border-2 border-ink bg-paper p-5 shadow-[8px_8px_0_rgb(var(--plakat-ink))] md:p-7"
|
|
>
|
|
<h3 className="font-display text-2xl font-extrabold tracking-[-0.03em]">{tForm("title")}</h3>
|
|
{fields.map((f) => {
|
|
const showError = touched[f.name] && errors[f.name];
|
|
const common = {
|
|
id: f.name,
|
|
name: f.name,
|
|
value: formData[f.name],
|
|
onChange: handleChange,
|
|
onBlur: handleBlur,
|
|
required: true,
|
|
placeholder: tForm(`placeholders.${f.name}`),
|
|
"aria-invalid": showError ? true : undefined,
|
|
"aria-describedby": showError ? `${f.name}-error` : undefined,
|
|
className: `w-full rounded-xl border-2 bg-paper px-4 py-3 text-base text-ink placeholder:text-ink/40 transition-[border-color,box-shadow] duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-signal ${
|
|
showError ? "border-signal" : "border-ink"
|
|
}`,
|
|
};
|
|
return (
|
|
<div key={f.name} className="grid gap-1.5">
|
|
<label htmlFor={f.name} className="font-mono text-[13px]">
|
|
{tForm(`labels.${f.name}`)}
|
|
</label>
|
|
{f.multiline ? (
|
|
<textarea {...common} rows={5} className={`${common.className} resize-none`} />
|
|
) : (
|
|
<input {...common} type={f.type} autoComplete={f.autoComplete} />
|
|
)}
|
|
{showError && (
|
|
<p id={`${f.name}-error`} className="text-sm font-medium text-signal" role="alert">
|
|
{errors[f.name]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="mt-1 inline-flex items-center justify-center gap-2.5 rounded-full bg-ink px-5 py-3.5 text-[15px] font-semibold text-paper transition-[opacity,transform] duration-150 ease-out active:scale-[0.97] disabled:opacity-50"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-paper/30 border-t-paper" aria-hidden="true" />
|
|
{tForm("sending")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send size={16} aria-hidden="true" />
|
|
{tForm("send")}
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export default Contact;
|