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 SocialLinks from "./Social";
|
||||
import { useState } from "react";
|
||||
import CvRequestModal from "./CvRequestModal";
|
||||
|
||||
/**
|
||||
* 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 tHero = useTranslations("Hero");
|
||||
|
||||
const [isCvModalOpen, setIsCvModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -42,14 +46,13 @@ export default function Hero() {
|
|||
>
|
||||
{tCommon("contact")}
|
||||
</a>
|
||||
<a
|
||||
href="/assets/downloads/Andre_Kempf_Lebenslauf.pdf"
|
||||
target="_blank"
|
||||
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"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCvModalOpen(true)}
|
||||
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"
|
||||
>
|
||||
{tHero("downloadCv")}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -69,6 +72,12 @@ export default function Hero() {
|
|||
|
||||
{/* Social Media Links Bar */}
|
||||
<SocialLinks />
|
||||
|
||||
{/* CV Request Modal */}
|
||||
<CvRequestModal
|
||||
isOpen={isCvModalOpen}
|
||||
onClose={() => setIsCvModalOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,16 @@
|
|||
"navHint1": "Mit",
|
||||
"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": {
|
||||
"backHome": "Zurück zur Startseite",
|
||||
"section1Title": "Angaben gemäß § 5 DDG",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,16 @@
|
|||
"navHint1": "Use",
|
||||
"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": {
|
||||
"section1Title": "Information Pursuant to § 5 DDG",
|
||||
"section1Sub": "Also responsible for content pursuant to § 18 Abs. 2 MStV.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue