Files
manoon-headless/src/components/home/EmailCapturePopup.tsx
T
Unchained eb711fbf1a
Build and Deploy / build (push) Has been cancelled
feat(popup): add email capture popup with Mautic integration
- Email capture popup with scroll (10%) and exit intent triggers
- First name field and full tracking (UTM, device, time on page)
- Mautic API integration for contact creation
- GeoIP detection for country/region
- 4 locale support (sr, en, de, fr)
- Mautic tracking script in layout
2026-04-03 20:44:15 +02:00

289 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import { X, Sparkles, ArrowRight, Check, Loader2 } from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
interface EmailCapturePopupProps {
isOpen: boolean;
onClose: () => void;
onSubscribe?: () => void;
trigger: "scroll" | "exit";
locale: string;
country: string;
countryCode: string;
}
function getUtmParams() {
if (typeof window === "undefined") return {};
const params = new URLSearchParams(window.location.search);
return {
utmSource: params.get("utm_source") || "",
utmMedium: params.get("utm_medium") || "",
utmCampaign: params.get("utm_campaign") || "",
utmContent: params.get("utm_content") || "",
fbclid: params.get("fbclid") || "",
};
}
function getDeviceInfo() {
if (typeof window === "undefined") return { deviceName: "", deviceOS: "", userAgent: "" };
const userAgent = navigator.userAgent;
let deviceName = "Unknown";
let deviceOS = "Unknown";
if (userAgent.match(/Windows/i)) deviceOS = "Windows";
else if (userAgent.match(/Mac/i)) deviceOS = "MacOS";
else if (userAgent.match(/Linux/i)) deviceOS = "Linux";
else if (userAgent.match(/Android/i)) deviceOS = "Android";
else if (userAgent.match(/iPhone|iPad|iPod/i)) deviceOS = "iOS";
if (userAgent.match(/Mobile/i)) deviceName = "Mobile";
else if (userAgent.match(/Tablet/i)) deviceName = "Tablet";
else deviceName = "Desktop";
return { deviceName, deviceOS, userAgent };
}
export default function EmailCapturePopup({
isOpen,
onClose,
onSubscribe,
trigger,
locale,
country,
countryCode,
}: EmailCapturePopupProps) {
const t = useTranslations("Popup");
const { trackPopupSubmit, trackPopupCtaClick } = useAnalytics();
const [firstName, setFirstName] = useState("");
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "alreadySubscribed" | "error">("idle");
const [pageLoadTime] = useState(() => Date.now());
const handleCTAClick = () => {
trackPopupCtaClick({ locale });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !email.includes("@")) return;
setIsSubmitting(true);
trackPopupSubmit({ trigger, locale, country: countryCode });
const timeOnPage = Math.floor((Date.now() - pageLoadTime) / 1000);
const utmParams = getUtmParams();
const deviceInfo = getDeviceInfo();
try {
const response = await fetch("/api/email-capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: firstName.trim(),
email,
locale,
country,
countryCode,
source: "popup",
trigger,
timeOnPage,
referrer: document.referrer || "",
pageUrl: window.location.href,
pageLanguage: navigator.language || "",
preferredLocale: locale,
...deviceInfo,
...utmParams,
}),
});
if (response.ok) {
const data = await response.json();
if (data.alreadySubscribed) {
setStatus("alreadySubscribed");
} else {
setStatus("success");
}
onSubscribe?.();
} else {
setStatus("error");
}
} catch (error) {
setStatus("error");
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
>
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-white/80 hover:bg-white transition-colors shadow-sm"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-500" />
</button>
<div className="flex flex-col">
<div className="p-8 pt-10">
{status === "idle" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="text-center mb-6">
<span className="inline-block px-3 py-1 text-xs font-semibold tracking-wider text-[#c9a962] bg-[#c9a962]/10 rounded-full mb-4">
{t("badge")}
</span>
<h2 className="text-2xl font-bold text-gray-900 mb-2 leading-tight">
{t("title")}
</h2>
<p className="text-gray-600 text-sm leading-relaxed">
{t("subtitle")}
</p>
</div>
<div className="space-y-4 mb-6">
{t.raw("bullets").map((bullet: string, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-[#c9a962]/20 flex items-center justify-center mt-0.5">
<Check className="w-3 h-3 text-[#c9a962]" />
</div>
<p className="text-sm text-gray-700">{bullet}</p>
</motion.div>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="relative">
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={t("firstNamePlaceholder")}
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#c9a962]/50 focus:border-[#c9a962] transition-all text-gray-900 placeholder:text-gray-400"
/>
</div>
<div className="relative">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("emailPlaceholder")}
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#c9a962]/50 focus:border-[#c9a962] transition-all text-gray-900 placeholder:text-gray-400"
required
/>
</div>
<button
type="submit"
onClick={handleCTAClick}
disabled={isSubmitting}
className="w-full py-4 bg-gradient-to-r from-[#c9a962] to-[#e8c547] text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-[#c9a962]/25 transition-all disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
{t("ctaButton")}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</form>
<p className="text-center text-xs text-gray-400 mt-4">
{t("privacyNote")}
</p>
</motion.div>
)}
{status === "success" && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-8"
>
<div className="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{t("successTitle")}
</h3>
<p className="text-gray-600">{t("successMessage")}</p>
</motion.div>
)}
{status === "alreadySubscribed" && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-8"
>
<div className="w-16 h-16 mx-auto mb-4 bg-[#c9a962]/20 rounded-full flex items-center justify-center">
<Sparkles className="w-8 h-8 text-[#c9a962]" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{t("alreadySubscribedTitle")}
</h3>
<p className="text-gray-600">{t("alreadySubscribed")}</p>
</motion.div>
)}
{status === "error" && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-8"
>
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
<X className="w-8 h-8 text-red-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{t("errorTitle")}
</h3>
<p className="text-gray-600 mb-4">{t("errorMessage")}</p>
<button
onClick={() => setStatus("idle")}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm font-medium"
>
{t("tryAgain")}
</button>
</motion.div>
)}
</div>
</div>
</motion.div>
</div>
);
}