feat(popup): add email capture popup with Mautic integration
Some checks failed
Build and Deploy / build (push) Has been cancelled
Some checks failed
Build and Deploy / build (push) Has been cancelled
- 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
This commit is contained in:
288
src/components/home/EmailCapturePopup.tsx
Normal file
288
src/components/home/EmailCapturePopup.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
102
src/components/home/ExitIntentDetector.tsx
Normal file
102
src/components/home/ExitIntentDetector.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useScrollDepth } from "@/hooks/useScrollDepth";
|
||||
import { useExitIntent } from "@/hooks/useExitIntent";
|
||||
import { useVisitorStore } from "@/hooks/useVisitorStore";
|
||||
import EmailCapturePopup from "./EmailCapturePopup";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
const SCROLL_POPUP_DELAY_MS = 5000;
|
||||
|
||||
export default function ExitIntentDetector() {
|
||||
const params = useParams();
|
||||
const locale = (params.locale as string) || "en";
|
||||
const { trackPopupView } = useAnalytics();
|
||||
|
||||
const scrollTriggered = useScrollDepth(10);
|
||||
const exitTriggered = useExitIntent();
|
||||
const { canShowPopup, markPopupShown, markSubscribed } = useVisitorStore();
|
||||
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const [trigger, setTrigger] = useState<"scroll" | "exit">("scroll");
|
||||
const [country, setCountry] = useState("Unknown");
|
||||
const [countryCode, setCountryCode] = useState("XX");
|
||||
const [city, setCity] = useState("");
|
||||
const [region, setRegion] = useState("");
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCountry = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/geoip");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCountry(data.country);
|
||||
setCountryCode(data.countryCode);
|
||||
setCity(data.city || "");
|
||||
setRegion(data.region || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get country:", error);
|
||||
}
|
||||
setIsReady(true);
|
||||
};
|
||||
fetchCountry();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[ExitIntent] Scroll triggered:", scrollTriggered);
|
||||
console.log("[ExitIntent] Exit triggered:", exitTriggered);
|
||||
console.log("[ExitIntent] isReady:", isReady);
|
||||
console.log("[ExitIntent] canShowPopup:", canShowPopup());
|
||||
|
||||
if (!isReady || !canShowPopup()) return;
|
||||
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (scrollTriggered || exitTriggered) {
|
||||
const newTrigger = exitTriggered ? "exit" : "scroll";
|
||||
console.log("[ExitIntent] Trigger activated:", newTrigger);
|
||||
setTrigger(newTrigger);
|
||||
|
||||
// Exit intent shows immediately, scroll has a delay
|
||||
const delay = exitTriggered ? 0 : SCROLL_POPUP_DELAY_MS;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
console.log("[ExitIntent] Timer fired, checking canShowPopup again");
|
||||
if (canShowPopup()) {
|
||||
console.log("[ExitIntent] Showing popup!");
|
||||
setShowPopup(true);
|
||||
markPopupShown(newTrigger);
|
||||
trackPopupView({ trigger: newTrigger, locale, country: countryCode });
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [scrollTriggered, exitTriggered, isReady, canShowPopup, markPopupShown, trackPopupView, locale, countryCode]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
markSubscribed();
|
||||
};
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<EmailCapturePopup
|
||||
isOpen={showPopup}
|
||||
onClose={handleClose}
|
||||
onSubscribe={handleSubscribe}
|
||||
trigger={trigger}
|
||||
locale={locale}
|
||||
country={country}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,5 +9,5 @@ interface AnalyticsProviderProps {
|
||||
|
||||
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
|
||||
// No-op component - Rybbit is loaded via next/script in layout.tsx
|
||||
return null;
|
||||
return <></>;
|
||||
}
|
||||
|
||||
62
src/components/ui/Drawer.tsx
Normal file
62
src/components/ui/Drawer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface DrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
side?: "left" | "right";
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export default function Drawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
side = "left",
|
||||
width = "max-w-[420px]",
|
||||
}: DrawerProps) {
|
||||
const slideAnimation = {
|
||||
initial: { x: side === "left" ? "-100%" : "100%" },
|
||||
animate: { x: 0 },
|
||||
exit: { x: side === "left" ? "-100%" : "100%" },
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className={`fixed top-0 ${side}-0 bottom-0 ${width} w-full bg-white z-50 shadow-2xl`}
|
||||
initial={slideAnimation.initial}
|
||||
animate={slideAnimation.animate}
|
||||
exit={slideAnimation.exit}
|
||||
transition={{ type: "tween", duration: 0.3 }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 rounded-full hover:bg-gray-100 transition-colors z-10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
|
||||
<div className="h-full overflow-y-auto">{children}</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user