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:
101
src/app/api/email-capture/route.ts
Normal file
101
src/app/api/email-capture/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/app/api/geoip/route.ts
Normal file
67
src/app/api/geoip/route.ts
Normal 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: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user