feat: add CV request modal for privacy-friendly resume download
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 53s
All checks were successful
Deploy Portfolio to VPS / deploy (push) Successful in 53s
This commit is contained in:
parent
8c98df1ea1
commit
359cdddaf6
4 changed files with 259 additions and 6 deletions
224
src/components/CvRequestModal.tsx
Normal file
224
src/components/CvRequestModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue