feat: add CV request modal for privacy-friendly resume download
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 53s

This commit is contained in:
Chneemann 2026-10-03 13:34:34 +02:00
parent 8c98df1ea1
commit 359cdddaf6
No known key found for this signature in database
4 changed files with 259 additions and 6 deletions

View file

@ -0,0 +1,224 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
interface CvRequestModalProps {
isOpen: boolean;
onClose: () => void;
}
/**
* Interactive modal dialog for requesting CV via email with anti-spam protection
*/
export default function CvRequestModal({
isOpen,
onClose,
}: CvRequestModalProps) {
const tCommon = useTranslations("Common");
const tContact = useTranslations("Contact");
const tCvModal = useTranslations("CvModal");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [honeypot, setHoneypot] = useState("");
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState(false);
const [formLoadTime] = useState(Date.now());
const modalRef = useRef<HTMLDivElement>(null);
// Reset the modal state when closing
const handleClose = () => {
setSubmitted(false);
setError(false);
setName("");
setEmail("");
onClose();
};
// Close via Escape key & Click Outside
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousedown", handleClickOutside);
return () => {
window.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
// Anti-Spam checks
if ((Date.now() - formLoadTime) / 1000 < 1 || honeypot !== "") {
setSubmitted(true);
return;
}
setLoading(true);
setError(false);
try {
const response = await fetch("https://formspree.io/f/mojgbbzp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
name,
email,
_subject: `CV Request from ${name}`,
}),
});
if (response.ok) {
setSubmitted(true);
} else {
setError(true);
}
} catch (err) {
console.error("Mail send error:", err);
setError(true);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm transition-opacity">
{/* Modal Container with ref */}
<div
ref={modalRef}
className="relative w-full max-w-lg p-6 sm:p-8 rounded-2xl border border-slate-700/60 bg-slate-900 shadow-2xl shadow-black/80 overflow-hidden"
>
{/* Close Button */}
<button
onClick={handleClose}
className="absolute top-4 right-4 text-slate-400 hover:text-white transition-colors text-xl cursor-pointer p-1"
aria-label="Close modal"
>
✕
</button>
{submitted ? (
<div className="py-8 text-center space-y-4">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-blue-500/10 text-blue-400 font-mono text-3xl border border-blue-500/20">
✓
</div>
<h3 className="text-2xl font-bold text-white">
{tCvModal("successTitle")}
</h3>
<p className="text-sm sm:text-base text-slate-300 max-w-sm mx-auto leading-relaxed">
{tCvModal("successText")}
</p>
<button
onClick={handleClose}
className="mt-4 px-6 py-2.5 text-sm font-medium bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg transition-colors cursor-pointer"
>
{tCvModal("close")}
</button>
</div>
) : (
<div className="space-y-5">
<div>
<span className="text-blue-500 font-mono text-sm tracking-wider uppercase">
{tCvModal("badge")}
</span>
<h3 className="text-2xl font-bold text-white mt-1">
{tCvModal("title")}
</h3>
<p className="text-sm sm:text-base text-slate-300 mt-2 leading-relaxed">
{tCvModal("privacyNotice")}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Honeypot field for bot detection */}
<div className="hidden" aria-hidden="true">
<input
type="text"
tabIndex={-1}
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
/>
</div>
{/* Name Input */}
<div>
<label
className="block text-sm font-medium mb-1.5 text-slate-200"
htmlFor="cv-name"
>
{tContact("formName")}
</label>
<input
type="text"
id="cv-name"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
className="w-full px-4 py-2.5 rounded-xl bg-slate-950/60 border border-slate-800 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-blue-500/80 text-sm sm:text-base"
/>
</div>
{/* Email Input */}
<div>
<label
className="block text-sm font-medium mb-1.5 text-slate-200"
htmlFor="cv-email"
>
{tCommon("emailLabel")}
</label>
<input
type="email"
id="cv-email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950/60 border border-slate-800 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-blue-500/80 text-sm sm:text-base"
/>
</div>
{error && (
<p className="text-red-400 text-sm font-mono">
{tContact("errorMessage")}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-colors text-sm sm:text-base disabled:opacity-50 mt-2 cursor-pointer shadow-md shadow-blue-950/50"
>
{loading ? tCvModal("submitting") : tCvModal("submit")}
</button>
</form>
</div>
)}
</div>
</div>
);
}

View file

@ -2,6 +2,8 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import SocialLinks from "./Social"; import SocialLinks from "./Social";
import { useState } from "react";
import CvRequestModal from "./CvRequestModal";
/** /**
* Hero section showcasing intro title, call-to-action links, and personal portrait * Hero section showcasing intro title, call-to-action links, and personal portrait
@ -10,6 +12,8 @@ export default function Hero() {
const tCommon = useTranslations("Common"); const tCommon = useTranslations("Common");
const tHero = useTranslations("Hero"); const tHero = useTranslations("Hero");
const [isCvModalOpen, setIsCvModalOpen] = useState(false);
return ( return (
<section className="relative pt-18 py-6 px-4 md:px-6 md:pt-24 max-w-5xl mx-auto"> <section className="relative pt-18 py-6 px-4 md:px-6 md:pt-24 max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-center z-10 w-full"> <div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-center z-10 w-full">
@ -42,14 +46,13 @@ export default function Hero() {
> >
{tCommon("contact")} {tCommon("contact")}
</a> </a>
<a <button
href="/assets/downloads/Andre_Kempf_Lebenslauf.pdf" type="button"
target="_blank" onClick={() => setIsCvModalOpen(true)}
rel="noopener noreferrer" className="px-6 py-3 bg-slate-900 border border-slate-800 hover:border-slate-700 hover:bg-slate-850 font-semibold rounded-lg transition-all hover:-translate-y-0.5 duration-200 inline-block text-center text-white cursor-pointer"
className="px-6 py-3 bg-slate-900 border border-slate-800 hover:border-slate-700 hover:bg-slate-850 font-semibold rounded-lg transition-all hover:-translate-y-0.5 duration-200 inline-block text-center"
> >
{tHero("downloadCv")} {tHero("downloadCv")}
</a> </button>
</div> </div>
</div> </div>
@ -69,6 +72,12 @@ export default function Hero() {
{/* Social Media Links Bar */} {/* Social Media Links Bar */}
<SocialLinks /> <SocialLinks />
{/* CV Request Modal */}
<CvRequestModal
isOpen={isCvModalOpen}
onClose={() => setIsCvModalOpen(false)}
/>
</section> </section>
); );
} }

View file

@ -112,6 +112,16 @@
"navHint1": "Mit", "navHint1": "Mit",
"navHint2": "navigieren" "navHint2": "navigieren"
}, },
"CvModal": {
"badge": "// direkt ins postfach",
"title": "Lebenslauf anfordern",
"privacyNotice": "Kurzer Check gegen Datenkraken: Hinterlass mir deine E-Mail und ich schicke dir meinen CV direkt zu.",
"submit": "CV anfordern",
"submitting": "Wird gesendet...",
"successTitle": "Anfrage gesendet!",
"successText": "Vielen Dank für dein Interesse! Ich schicke dir den Lebenslauf in Kürze per E-Mail.",
"close": "Schließen"
},
"Imprint": { "Imprint": {
"backHome": "Zurück zur Startseite", "backHome": "Zurück zur Startseite",
"section1Title": "Angaben gemäß § 5 DDG", "section1Title": "Angaben gemäß § 5 DDG",

View file

@ -113,6 +113,16 @@
"navHint1": "Use", "navHint1": "Use",
"navHint2": "to navigate" "navHint2": "to navigate"
}, },
"CvModal": {
"badge": "// straight to your inbox",
"title": "Request Resume",
"privacyNotice": "A quick check against data scrapers: Leave your email and I’ll send my CV straight to your inbox.",
"submit": "Request CV",
"submitting": "Sending...",
"successTitle": "Request Sent!",
"successText": "Thanks for reaching out! I will send you my resume via email shortly.",
"close": "Close"
},
"Imprint": { "Imprint": {
"section1Title": "Information Pursuant to § 5 DDG", "section1Title": "Information Pursuant to § 5 DDG",
"section1Sub": "Also responsible for content pursuant to § 18 Abs. 2 MStV.", "section1Sub": "Also responsible for content pursuant to § 18 Abs. 2 MStV.",