feat(popup): add email capture popup with Mautic integration
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:
Unchained
2026-04-03 20:44:15 +02:00
parent 4e5481af1a
commit eb711fbf1a
16 changed files with 1019 additions and 1 deletions

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { createMauticContact } from "@/lib/mautic";
const requestCache = new Map<string, number>();
const DEBOUNCE_MS = 5000;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
email,
locale,
country,
countryCode,
source,
trigger,
firstName,
lastName,
timeOnPage,
referrer,
pageUrl,
pageLanguage,
preferredLocale,
deviceName,
deviceOS,
userAgent,
utmSource,
utmMedium,
utmCampaign,
utmContent,
fbclid,
} = body;
if (!email || !email.includes("@")) {
return NextResponse.json(
{ error: "Invalid email" },
{ status: 400 }
);
}
const cacheKey = `${email}:${Date.now()}`;
const lastRequest = requestCache.get(cacheKey);
if (lastRequest && Date.now() - lastRequest < DEBOUNCE_MS) {
return NextResponse.json(
{ error: "Please wait before submitting again" },
{ status: 429 }
);
}
requestCache.set(cacheKey, Date.now());
const tags = [
"source:popup",
`locale:${locale || "en"}`,
`country:${countryCode || "XX"}`,
`popup_${trigger || "unknown"}`,
"lead:warm",
...(utmSource ? [`utm:${utmSource}`] : []),
...(deviceName ? [`device:${deviceName}`] : []),
];
const forwardedFor = request.headers.get("x-forwarded-for");
const realIP = request.headers.get("x-real-ip");
const ipAddress = forwardedFor?.split(",")[0]?.trim() || realIP || "unknown";
const result = await createMauticContact(email, tags, {
firstName: firstName || "",
lastName: lastName || "",
country: country || "",
preferredLocale: preferredLocale || locale || "en",
ipAddress,
utmSource: utmSource || "",
utmMedium: utmMedium || "",
utmCampaign: utmCampaign || "",
utmContent: utmContent || "",
pageUrl: pageUrl || request.headers.get("referer") || "",
});
console.log("Email capture success:", {
email,
firstName,
timeOnPage,
deviceName,
deviceOS,
utmSource,
utmMedium,
result
});
return NextResponse.json({
success: true,
alreadySubscribed: result.alreadyExists,
contactId: result.contactId,
});
} catch (error) {
console.error("Email capture error:", error);
return NextResponse.json(
{ error: "Failed to process subscription", details: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
// Check for Cloudflare's IP header first (production)
const cfConnectingIp = request.headers.get("cf-connecting-ip");
const forwardedFor = request.headers.get("x-forwarded-for");
const realIP = request.headers.get("x-real-ip");
// Use Cloudflare IP first, then fall back to other headers
let ip = cfConnectingIp || forwardedFor?.split(",")[0]?.trim() || realIP || "127.0.0.1";
// For local development, return XX as country code (Mautic accepts this)
if (ip === "127.0.0.1" || ip === "::1" || ip.startsWith("192.168.") || ip.startsWith("10.")) {
console.log("[GeoIP] Local/private IP detected:", ip);
return NextResponse.json({
country: "Unknown",
countryCode: "XX",
region: "",
city: "",
timezone: "",
});
}
const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,timezone`, {
headers: {
"Accept": "application/json",
},
});
if (!response.ok) {
throw new Error("GeoIP lookup failed");
}
const data = await response.json();
if (data.status !== "success") {
console.error("[GeoIP] API error:", data.message, "for IP:", ip);
return NextResponse.json({
country: "Unknown",
countryCode: "XX",
region: "",
city: "",
timezone: "",
});
}
console.log("[GeoIP] Success:", data.country, "(" + data.countryCode + ")");
return NextResponse.json({
country: data.country,
countryCode: data.countryCode,
region: data.regionName,
city: data.city,
timezone: data.timezone,
});
} catch (error) {
console.error("[GeoIP] Error:", error);
return NextResponse.json({
country: "Unknown",
countryCode: "XX",
region: "",
city: "",
timezone: "",
});
}
}

View 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>
);
}

View 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}
/>
);
}

View File

@@ -9,5 +9,5 @@ interface AnalyticsProviderProps {
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) { export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
// No-op component - Rybbit is loaded via next/script in layout.tsx // No-op component - Rybbit is loaded via next/script in layout.tsx
return null; return <></>;
} }

View 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>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
export function useExitIntent(): boolean {
const [showExitIntent, setShowExitIntent] = useState(false);
useEffect(() => {
const handleMouseLeave = (e: MouseEvent) => {
if (e.clientY <= 0) {
setShowExitIntent(true);
}
};
document.addEventListener("mouseleave", handleMouseLeave);
return () => document.removeEventListener("mouseleave", handleMouseLeave);
}, []);
return showExitIntent;
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
export function useScrollDepth(threshold: number = 20): boolean {
const [hasReachedThreshold, setHasReachedThreshold] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (hasReachedThreshold) return;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollPercent = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
if (scrollPercent >= threshold) {
setHasReachedThreshold(true);
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}, [threshold, hasReachedThreshold]);
return hasReachedThreshold;
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState, useCallback } from "react";
const STORAGE_KEY = "manoonoils-visitor";
const SESSION_DURATION_HOURS = 24;
interface VisitorState {
visitorId: string;
popupShown: boolean;
popupShownAt: number | null;
popupTrigger: "scroll" | "exit" | null;
subscribed: boolean;
}
export function useVisitorStore() {
const [state, setState] = useState<VisitorState>({
visitorId: "",
popupShown: false,
popupShownAt: null,
popupTrigger: null,
subscribed: false,
});
useEffect(() => {
// Check for reset flag in URL
if (typeof window !== 'undefined' && window.location.search.includes('reset-popup=true')) {
localStorage.removeItem(STORAGE_KEY);
console.log("[VisitorStore] Reset popup tracking");
}
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setState(parsed);
console.log("[VisitorStore] Loaded state:", parsed);
} else {
const newState: VisitorState = {
visitorId: generateVisitorId(),
popupShown: false,
popupShownAt: null,
popupTrigger: null,
subscribed: false,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
setState(newState);
console.log("[VisitorStore] Created new state:", newState);
}
}, []);
const canShowPopup = useCallback((): boolean => {
if (state.subscribed) {
console.log("[VisitorStore] canShowPopup: false (already subscribed)");
return false;
}
if (!state.popupShown || !state.popupShownAt) {
console.log("[VisitorStore] canShowPopup: true (never shown)");
return true;
}
const hoursPassed = (Date.now() - state.popupShownAt) / (1000 * 60 * 60);
const canShow = hoursPassed >= SESSION_DURATION_HOURS;
console.log("[VisitorStore] canShowPopup:", canShow, "hours passed:", hoursPassed);
return canShow;
}, [state.popupShown, state.popupShownAt, state.subscribed]);
const markPopupShown = useCallback((trigger: "scroll" | "exit") => {
const newState: VisitorState = {
...state,
popupShown: true,
popupShownAt: Date.now(),
popupTrigger: trigger,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
setState(newState);
}, [state]);
const markSubscribed = useCallback(() => {
const newState: VisitorState = {
...state,
subscribed: true,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
setState(newState);
console.log("[VisitorStore] Marked as subscribed");
}, [state]);
return {
visitorId: state.visitorId,
canShowPopup,
markPopupShown,
markSubscribed,
popupTrigger: state.popupTrigger,
};
}
function generateVisitorId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

View File

@@ -44,6 +44,28 @@
"sustainable": "Nachhaltig", "sustainable": "Nachhaltig",
"sustainableDesc": "Ethnisch beschaffte Zutaten und umweltfreundliche Verpackungen für einen besseren Planeten." "sustainableDesc": "Ethnisch beschaffte Zutaten und umweltfreundliche Verpackungen für einen besseren Planeten."
}, },
"Popup": {
"badge": "KOSTENLOSER LEITFADEN",
"title": "Schließen Sie sich 15.000+ Frauen an, die Ihre Haut transformiert haben",
"subtitle": "Holen Sie sich unseren kostenlosen Leitfaden: Die Natürlichen Öl-Geheimnisse der Top-Experten",
"bullets": [
"Der Öl-Fehler Nr. 1, der Haare beschädigt (und die einfache Lösung)",
"3 Öle, die die Haut in 30 Tagen verjüngen",
"Die 'Morning Glow'-Routine, die Promis täglich nutzen",
"Die schwarze Liste der Inhaltsstoffe, die Sie NIE verwenden sollten"
],
"firstNamePlaceholder": "Geben Sie Ihren Vornamen ein",
"emailPlaceholder": "Ihre beste E-Mail-Adresse",
"ctaButton": "Senden Sie Mir Den Leitfaden »",
"privacyNote": "Kein Spam. Jederzeit abmelden.",
"successTitle": "Erfolg! Prüfen Sie jetzt Ihren Posteingang!",
"successMessage": "Der Leitfaden wurde gesendet! Prüfen Sie Ihre E-Mails (und Spam-Ordner).",
"alreadySubscribedTitle": "Sie sind bereits dabei!",
"alreadySubscribed": "Sie sind bereits dabei! Prüfen Sie Ihre E-Mails für den Leitfaden.",
"errorTitle": "Etwas ist schief gelaufen",
"errorMessage": "Wir konnten den Leitfaden nicht senden. Bitte versuchen Sie es erneut.",
"tryAgain": "Erneut versuchen"
},
"Products": { "Products": {
"collection": "Unsere Kollektion", "collection": "Unsere Kollektion",
"allProducts": "Alle Produkte", "allProducts": "Alle Produkte",

View File

@@ -44,6 +44,28 @@
"sustainable": "Sustainable", "sustainable": "Sustainable",
"sustainableDesc": "Ethically sourced ingredients and eco-friendly packaging for a better planet." "sustainableDesc": "Ethically sourced ingredients and eco-friendly packaging for a better planet."
}, },
"Popup": {
"badge": "FREE GUIDE",
"title": "Join 15,000+ Women Who Transformed Their Skin",
"subtitle": "Get Our Free Guide: The Natural Oil Secrets Top Beauty Experts Swear By",
"bullets": [
"The #1 oil mistake that damages hair (and the simple fix)",
"3 oils that reverse aging skin in 30 days",
"The 'morning glow' routine celebrities use daily",
"The ingredient blacklist you should NEVER use"
],
"firstNamePlaceholder": "Enter your first name",
"emailPlaceholder": "Enter your email",
"ctaButton": "Send Me The Free Guide »",
"privacyNote": "No spam. Unsubscribe anytime.",
"successTitle": "Success! Check your inbox now!",
"successMessage": "The guide has been sent! Check your email (and spam folder) for your free guide.",
"alreadySubscribedTitle": "You're already a member!",
"alreadySubscribed": "You're already in! Check your email for the guide.",
"errorTitle": "Something went wrong",
"errorMessage": "We couldn't send the guide. Please try again.",
"tryAgain": "Try again"
},
"Products": { "Products": {
"collection": "Our Collection", "collection": "Our Collection",
"allProducts": "All Products", "allProducts": "All Products",

View File

@@ -44,6 +44,28 @@
"sustainable": "Durable", "sustainable": "Durable",
"sustainableDesc": "Ingrédients sourcés éthiquement et emballage écologique pour une meilleure planète." "sustainableDesc": "Ingrédients sourcés éthiquement et emballage écologique pour une meilleure planète."
}, },
"Popup": {
"badge": "GUIDE GRATUIT",
"title": "Rejoignez 15 000+ femmes qui ont transformé leur peau",
"subtitle": "Téléchargez notre guide gratuit: Les Secrets des Huiles Naturelles des Meilleurs Experts",
"bullets": [
"L'erreur huile n°1 qui abîme les cheveux (et la solution simple)",
"3 huiles qui rajeunissent la peau en 30 jours",
"La routine 'éclat du matin' utilisée par les célébrités",
"La liste noire des ingrédients que vous ne devez JAMAIS utiliser"
],
"firstNamePlaceholder": "Entrez votre prénom",
"emailPlaceholder": "Votre meilleure adresse email",
"ctaButton": "Envoyez-Moi Le Guide Gratuit »",
"privacyNote": "Pas de spam. Désabonnez-vous à tout moment.",
"successTitle": "Succès! Vérifiez votre boîte de réception maintenant!",
"successMessage": "Le guide a été envoyé! Vérifiez vos emails (et dossier spam).",
"alreadySubscribedTitle": "Vous êtes déjà inscrit!",
"alreadySubscribed": "Vous êtes déjà inscrit! Vérifiez vos emails pour le guide.",
"errorTitle": "Quelque chose s'est mal passé",
"errorMessage": "Nous n'avons pas pu envoyer le guide. Veuillez réessayer.",
"tryAgain": "Réessayer"
},
"Products": { "Products": {
"collection": "Notre Collection", "collection": "Notre Collection",
"allProducts": "Tous Les Produits", "allProducts": "Tous Les Produits",

View File

@@ -44,6 +44,28 @@
"sustainable": "Održivo", "sustainable": "Održivo",
"sustainableDesc": "Etički nabavljeni sastojci i ekološka ambalaža za bolju planetu." "sustainableDesc": "Etički nabavljeni sastojci i ekološka ambalaža za bolju planetu."
}, },
"Popup": {
"badge": "BESPLATAN VODIČ",
"title": "Pridružite se 15.000+ žena koje su transformisale svoju kožu",
"subtitle": "Preuzmite besplatan vodič: Tajne prirodnih ulja koje koriste najbolji eksperti",
"bullets": [
"Greška br. 1 sa uljima koja uništava kosu (i jednostavno rešenje)",
"3 ulja koja podmlađuju kožu za 30 dana",
"Rutinu 'jutarnjeg sjaja' koju koriste poznati",
"Listu sastojaka koje NIKADA ne smete koristiti"
],
"firstNamePlaceholder": "Unesite vaše ime",
"emailPlaceholder": "Unesite vaš email",
"ctaButton": "Pošaljite Mi Vodič »",
"privacyNote": "Bez spama. Odjavite se bilo kada.",
"successTitle": "Uspeh! Proverite vaš inbox!",
"successMessage": "Vodič je poslat! Proverite vaš email (i spam folder).",
"alreadySubscribedTitle": "Već ste član!",
"alreadySubscribed": "Već ste u bazi! Proverite email za vodič.",
"errorTitle": "Došlo je do greške",
"errorMessage": "Nismo mogli da pošaljemo vodič. Molimo pokušajte ponovo.",
"tryAgain": "Pokušajte ponovo"
},
"Products": { "Products": {
"collection": "Naša kolekcija", "collection": "Naša kolekcija",
"allProducts": "Svi proizvodi", "allProducts": "Svi proizvodi",

View File

@@ -15,6 +15,7 @@ import {
trackRybbitUserLogin, trackRybbitUserLogin,
trackRybbitUserRegister, trackRybbitUserRegister,
trackRybbitNewsletterSignup, trackRybbitNewsletterSignup,
trackRybbitEvent,
} from "@/lib/services/RybbitService"; } from "@/lib/services/RybbitService";
export function useAnalytics() { export function useAnalytics() {
@@ -178,6 +179,23 @@ export function useAnalytics() {
trackRybbitNewsletterSignup(email, source); trackRybbitNewsletterSignup(email, source);
}, []); }, []);
// Popup tracking functions
const trackPopupView = useCallback((data: { trigger: string; locale: string; country?: string }) => {
trackRybbitEvent("popup_view", data);
}, []);
const trackPopupSubmit = useCallback((data: { trigger: string; locale: string; country?: string }) => {
trackRybbitEvent("popup_submit", data);
}, []);
const trackPopupCtaClick = useCallback((data: { locale: string }) => {
trackRybbitEvent("popup_cta_click", data);
}, []);
const trackPopupDismiss = useCallback((data: { trigger: string; locale: string }) => {
trackRybbitEvent("popup_dismiss", data);
}, []);
// No-op placeholder for identifyUser (OpenPanel removed) // No-op placeholder for identifyUser (OpenPanel removed)
const identifyUser = useCallback((_user: { const identifyUser = useCallback((_user: {
profileId: string; profileId: string;
@@ -203,6 +221,10 @@ export function useAnalytics() {
trackUserLogin, trackUserLogin,
trackUserRegister, trackUserRegister,
trackNewsletterSignup, trackNewsletterSignup,
trackPopupView,
trackPopupSubmit,
trackPopupCtaClick,
trackPopupDismiss,
identifyUser, identifyUser,
}; };
} }

19
src/lib/geoip.ts Normal file
View File

@@ -0,0 +1,19 @@
interface GeoIPResponse {
country: string;
countryCode: string;
}
export async function getCountryFromIP(): Promise<GeoIPResponse> {
try {
const response = await fetch("/api/geoip");
if (!response.ok) {
throw new Error("Failed to get country");
}
return await response.json();
} catch (error) {
return {
country: "Unknown",
countryCode: "XX",
};
}
}

120
src/lib/mautic.ts Normal file
View File

@@ -0,0 +1,120 @@
interface MauticToken {
access_token: string;
expires_in: number;
token_type: string;
}
let cachedToken: MauticToken | null = null;
let tokenExpiresAt: number = 0;
async function getMauticToken(): Promise<string> {
if (cachedToken && Date.now() < tokenExpiresAt - 60000) {
return cachedToken.access_token;
}
const clientId = process.env.MAUTIC_CLIENT_ID;
const clientSecret = process.env.MAUTIC_CLIENT_SECRET;
const apiUrl = process.env.MAUTIC_API_URL || "https://mautic.nodecrew.me";
if (!clientId || !clientSecret) {
throw new Error("Mautic credentials not configured");
}
const response = await fetch(`${apiUrl}/oauth/v2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Mautic token error:", response.status, errorText);
throw new Error(`Failed to get Mautic token: ${response.status} - ${errorText}`);
}
const token: MauticToken = await response.json();
cachedToken = token;
tokenExpiresAt = Date.now() + token.expires_in * 1000;
return token.access_token;
}
export async function createMauticContact(
email: string,
tags: string[],
additionalData?: {
firstName?: string;
lastName?: string;
country?: string;
city?: string;
phone?: string;
website?: string;
preferredLocale?: string;
ipAddress?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmContent?: string;
pageUrl?: string;
}
): Promise<{ success: boolean; alreadyExists?: boolean; contactId?: number }> {
try {
const token = await getMauticToken();
const apiUrl = process.env.MAUTIC_API_URL || "https://mautic.nodecrew.me";
const payload: any = {
email,
tags: tags.join(","),
};
if (additionalData) {
if (additionalData.firstName) payload.firstname = additionalData.firstName;
if (additionalData.lastName) payload.lastname = additionalData.lastName;
if (additionalData.country) payload.country = additionalData.country;
if (additionalData.city) payload.city = additionalData.city;
if (additionalData.phone) payload.phone = additionalData.phone;
if (additionalData.preferredLocale) payload.preferred_locale = additionalData.preferredLocale;
if (additionalData.utmSource) payload.utm_source = additionalData.utmSource;
if (additionalData.utmMedium) payload.utm_medium = additionalData.utmMedium;
if (additionalData.utmCampaign) payload.utm_campaign = additionalData.utmCampaign;
if (additionalData.utmContent) payload.utm_content = additionalData.utmContent;
if (additionalData.pageUrl) payload.page_url = additionalData.pageUrl;
}
const response = await fetch(`${apiUrl}/api/contacts/new`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (response.status === 409) {
return { success: true, alreadyExists: true };
}
if (!response.ok) {
const errorText = await response.text();
console.error("Mautic API error:", response.status, errorText);
throw new Error(`Mautic API error: ${response.status} - ${errorText}`);
}
const responseData = await response.json();
console.log("Mautic API success:", responseData);
return {
success: true,
contactId: responseData.contact?.id
};
} catch (error) {
console.error("Mautic contact creation failed:", error);
throw error;
}
}