diff --git a/src/components/CvRequestModal.tsx b/src/components/CvRequestModal.tsx new file mode 100644 index 0000000..21a8a41 --- /dev/null +++ b/src/components/CvRequestModal.tsx @@ -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(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) => { + 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 ( +
+ {/* Modal Container with ref */} +
+ {/* Close Button */} + + + {submitted ? ( +
+
+ ✓ +
+

+ {tCvModal("successTitle")} +

+

+ {tCvModal("successText")} +

+ +
+ ) : ( +
+
+ + {tCvModal("badge")} + +

+ {tCvModal("title")} +

+

+ {tCvModal("privacyNotice")} +

+
+ +
+ {/* Honeypot field for bot detection */} + + + {/* Name Input */} +
+ + 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" + /> +
+ + {/* Email Input */} +
+ + 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" + /> +
+ + {error && ( +

+ {tContact("errorMessage")} +

+ )} + + +
+
+ )} +
+
+ ); +} diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index a411f9b..511b825 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -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 (
@@ -42,14 +46,13 @@ export default function Hero() { > {tCommon("contact")} - 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")} - +
@@ -69,6 +72,12 @@ export default function Hero() { {/* Social Media Links Bar */} + + {/* CV Request Modal */} + setIsCvModalOpen(false)} + />
); } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c432d84..29a32e8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6b34bbf..397fa1c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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.",