From eb711fbf1a7d79c633d72b76d48dd99600cd957c Mon Sep 17 00:00:00 2001 From: Unchained Date: Fri, 3 Apr 2026 20:44:15 +0200 Subject: [PATCH] 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 --- src/app/api/email-capture/route.ts | 101 ++++++ src/app/api/geoip/route.ts | 67 ++++ src/components/home/EmailCapturePopup.tsx | 288 ++++++++++++++++++ src/components/home/ExitIntentDetector.tsx | 102 +++++++ .../providers/AnalyticsProvider.tsx | 2 +- src/components/ui/Drawer.tsx | 62 ++++ src/hooks/useExitIntent.ts | 21 ++ src/hooks/useScrollDepth.ts | 28 ++ src/hooks/useVisitorStore.ts | 100 ++++++ src/i18n/messages/de.json | 22 ++ src/i18n/messages/en.json | 22 ++ src/i18n/messages/fr.json | 22 ++ src/i18n/messages/sr.json | 22 ++ src/lib/analytics.ts | 22 ++ src/lib/geoip.ts | 19 ++ src/lib/mautic.ts | 120 ++++++++ 16 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 src/app/api/email-capture/route.ts create mode 100644 src/app/api/geoip/route.ts create mode 100644 src/components/home/EmailCapturePopup.tsx create mode 100644 src/components/home/ExitIntentDetector.tsx create mode 100644 src/components/ui/Drawer.tsx create mode 100644 src/hooks/useExitIntent.ts create mode 100644 src/hooks/useScrollDepth.ts create mode 100644 src/hooks/useVisitorStore.ts create mode 100644 src/lib/geoip.ts create mode 100644 src/lib/mautic.ts diff --git a/src/app/api/email-capture/route.ts b/src/app/api/email-capture/route.ts new file mode 100644 index 0000000..b94649d --- /dev/null +++ b/src/app/api/email-capture/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createMauticContact } from "@/lib/mautic"; + +const requestCache = new Map(); +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 } + ); + } +} diff --git a/src/app/api/geoip/route.ts b/src/app/api/geoip/route.ts new file mode 100644 index 0000000..f89dfcf --- /dev/null +++ b/src/app/api/geoip/route.ts @@ -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: "", + }); + } +} diff --git a/src/components/home/EmailCapturePopup.tsx b/src/components/home/EmailCapturePopup.tsx new file mode 100644 index 0000000..2df00ad --- /dev/null +++ b/src/components/home/EmailCapturePopup.tsx @@ -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 ( +
+ + + + + +
+
+ {status === "idle" && ( + +
+ + {t("badge")} + +

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ +
+ {t.raw("bullets").map((bullet: string, index: number) => ( + +
+ +
+

{bullet}

+
+ ))} +
+ +
+
+ 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" + /> +
+
+ 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 + /> +
+ +
+ +

+ {t("privacyNote")} +

+
+ )} + + {status === "success" && ( + +
+ +
+

+ {t("successTitle")} +

+

{t("successMessage")}

+
+ )} + + {status === "alreadySubscribed" && ( + +
+ +
+

+ {t("alreadySubscribedTitle")} +

+

{t("alreadySubscribed")}

+
+ )} + + {status === "error" && ( + +
+ +
+

+ {t("errorTitle")} +

+

{t("errorMessage")}

+ +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/home/ExitIntentDetector.tsx b/src/components/home/ExitIntentDetector.tsx new file mode 100644 index 0000000..e8b2330 --- /dev/null +++ b/src/components/home/ExitIntentDetector.tsx @@ -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 ( + + ); +} diff --git a/src/components/providers/AnalyticsProvider.tsx b/src/components/providers/AnalyticsProvider.tsx index 33bd6a9..0a97b57 100644 --- a/src/components/providers/AnalyticsProvider.tsx +++ b/src/components/providers/AnalyticsProvider.tsx @@ -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 <>; } diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx new file mode 100644 index 0000000..663d92b --- /dev/null +++ b/src/components/ui/Drawer.tsx @@ -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 ( + + {isOpen && ( + <> + + + + + +
{children}
+
+ + )} +
+ ); +} diff --git a/src/hooks/useExitIntent.ts b/src/hooks/useExitIntent.ts new file mode 100644 index 0000000..2e19dfb --- /dev/null +++ b/src/hooks/useExitIntent.ts @@ -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; +} diff --git a/src/hooks/useScrollDepth.ts b/src/hooks/useScrollDepth.ts new file mode 100644 index 0000000..148dab6 --- /dev/null +++ b/src/hooks/useScrollDepth.ts @@ -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; +} diff --git a/src/hooks/useVisitorStore.ts b/src/hooks/useVisitorStore.ts new file mode 100644 index 0000000..bcb0a6d --- /dev/null +++ b/src/hooks/useVisitorStore.ts @@ -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({ + 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)}`; +} diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b4a5b16..c85f11a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -44,6 +44,28 @@ "sustainable": "Nachhaltig", "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": { "collection": "Unsere Kollektion", "allProducts": "Alle Produkte", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 775607b..78a60c5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -44,6 +44,28 @@ "sustainable": "Sustainable", "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": { "collection": "Our Collection", "allProducts": "All Products", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 926d26c..c11002b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -44,6 +44,28 @@ "sustainable": "Durable", "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": { "collection": "Notre Collection", "allProducts": "Tous Les Produits", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index ae813bd..fdde90e 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -44,6 +44,28 @@ "sustainable": "Održivo", "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": { "collection": "Naša kolekcija", "allProducts": "Svi proizvodi", diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 3cc0b76..dba604b 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -15,6 +15,7 @@ import { trackRybbitUserLogin, trackRybbitUserRegister, trackRybbitNewsletterSignup, + trackRybbitEvent, } from "@/lib/services/RybbitService"; export function useAnalytics() { @@ -178,6 +179,23 @@ export function useAnalytics() { 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) const identifyUser = useCallback((_user: { profileId: string; @@ -203,6 +221,10 @@ export function useAnalytics() { trackUserLogin, trackUserRegister, trackNewsletterSignup, + trackPopupView, + trackPopupSubmit, + trackPopupCtaClick, + trackPopupDismiss, identifyUser, }; } diff --git a/src/lib/geoip.ts b/src/lib/geoip.ts new file mode 100644 index 0000000..e5531ef --- /dev/null +++ b/src/lib/geoip.ts @@ -0,0 +1,19 @@ +interface GeoIPResponse { + country: string; + countryCode: string; +} + +export async function getCountryFromIP(): Promise { + 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", + }; + } +} diff --git a/src/lib/mautic.ts b/src/lib/mautic.ts new file mode 100644 index 0000000..971a74a --- /dev/null +++ b/src/lib/mautic.ts @@ -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 { + 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; + } +}