Compare commits

..

12 Commits

Author SHA1 Message Date
Unchained
6caefb420a docs: add OpenCode project memory for git workflow
Some checks are pending
Build and Deploy / build (push) Waiting to run
2026-04-03 21:06:15 +02:00
Unchained
cbbcaace22 docs: add git workflow guidelines
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-03 21:03:04 +02:00
Unchained
eb711fbf1a 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
2026-04-03 20:44:15 +02:00
Unchained
4e5481af1a fix(layout): restore ExitIntentDetector and Mautic tracking
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-03 20:27:25 +02:00
Unchained
618298b1b1 fix(layout): restore original structure, keep only Rybbit direct URL fix
Some checks failed
Build and Deploy / build (push) Has been cancelled
Revert unintended changes from previous commit:
- Restore AnalyticsProvider (was accidentally removed)
- Remove ExitIntentDetector (feature branch code)
- Remove Mautic tracking script (feature branch code)

Keep only the intended Rybbit fix:
- Direct connection to Rybbit instead of server-side proxy
2026-04-03 20:14:07 +02:00
Unchained
d999d739d5 fix(analytics): connect directly to Rybbit to preserve real visitor IP
Some checks failed
Build and Deploy / build (push) Has been cancelled
Changed Rybbit script loading from server-side rewrite to client-side direct
connection. This prevents Next.js from proxying the request, which was causing
all visitor IPs to show as the Hetzner server IP (138.201.11.251).

Before:
- Browser → Next.js → Rybbit (server-side proxy, loses client IP)

After:
- Browser → Rybbit (direct connection, real IP preserved)

Changes:
- layout.tsx: Use direct Rybbit URL for script src
- next.config.ts: Remove /api/script.js rewrite
2026-04-03 20:10:59 +02:00
Unchained
0f00aa8a47 Add Mautic environment variables to deployment
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-03 16:31:40 +02:00
Unchained
93b239bc5a Merge branch 'dev'
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-03 16:12:02 +02:00
Unchained
1ed6cac647 fix(k8s): use NodePort with externalTrafficPolicy Local to preserve client IP
Some checks failed
Build and Deploy / build (push) Has been cancelled
Change storefront service from ClusterIP to NodePort with externalTrafficPolicy: Local.
This preserves the real client source IP instead of NATing to the node IP.

Fixes analytics tracking showing Hetzner IP (138.201.11.251) instead of real visitor IPs.
Same fix previously applied to Rybbit backend service.

Note: On single-node clusters, this works seamlessly. Traefik routes directly
to the node where the pod is running, preserving the original source IP.
2026-04-03 06:55:42 +02:00
Unchained
e476bc9fc4 fix(k8s): add HTTP to HTTPS redirect for manoonoils.com
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Create redirect-https middleware for permanent redirect (301)
- Split IngressRoute: HTTP route redirects to HTTPS, HTTPS route serves app
- Fixes Google Search Console 404 error on HTTP version
- No application code changes, only routing configuration
2026-04-02 22:50:43 +02:00
Unchained
f4f23aa7f3 fix(k8s): add HTTP to HTTPS redirect for manoonoils.com
- Create redirect-https middleware for permanent redirect (301)
- Split IngressRoute: HTTP route redirects to HTTPS, HTTPS route serves app
- Fixes Google Search Console 404 error on HTTP version
- No application code changes, only routing configuration
2026-04-02 22:49:26 +02:00
Unchained
9124eeedc1 fix: add ts-ignore for request.ip runtime property
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 10:47:09 +02:00
26 changed files with 1331 additions and 18 deletions

189
.opencode/PROJECT_MEMORY.md Normal file
View File

@@ -0,0 +1,189 @@
# ManoonOils Project Memory
## Project Overview
- **Name:** ManoonOils Headless Storefront
- **Type:** Next.js 16 + Saleor e-commerce
- **URL:** https://manoonoils.com
- **Tech Stack:** React 19, TypeScript, Tailwind CSS v4, GraphQL/Apollo
## Git Workflow (CRITICAL)
```
feature/* → dev → master
```
### Rules (MUST FOLLOW)
1. **All work starts on feature branch** - Never commit to dev/master directly
2. **Commit working code immediately** - No uncommitted files in working directory
3. **Clean working directory before switching branches** - Run `git status` first
4. **Flow forward only** - feature → dev → master, never skip
5. **Reset feature branches after merge** - Keep synchronized with master
### Workflow Steps
```bash
# 1. Create feature branch
git checkout -b feature/description
# 2. Work and commit WORKING code
git add .
git commit -m "type: description"
git push origin feature/description
# 3. Merge to dev for testing
git checkout dev
git merge feature/description
git push origin dev
# 4. Merge to master for production
git checkout master
git merge dev
git push origin master
# 5. Reset feature branch to match master
git checkout feature/description
git reset --hard master
git push origin feature/description --force
```
### Commit Types
- `feat:` - New feature
- `fix:` - Bug fix
- `docs:` - Documentation
- `style:` - Formatting
- `refactor:` - Code restructuring
- `test:` - Tests
- `chore:` - Build/process
## Project Structure
### Key Directories
```
src/
├── app/[locale]/ # i18n routes
├── components/
│ ├── home/ # Homepage sections
│ ├── layout/ # Header, Footer
│ ├── providers/ # Context providers
│ └── ui/ # Reusable UI
├── hooks/ # Custom hooks
├── lib/
│ ├── mautic.ts # Mautic API client
│ ├── geoip.ts # GeoIP service
│ └── analytics.ts # Analytics tracking
├── i18n/messages/ # Translations (sr, en, de, fr)
k8s/ # Kubernetes manifests
```
### Important Files
- `k8s/deployment.yaml` - Production deployment config
- `src/app/[locale]/layout.tsx` - Root layout with ExitIntentDetector
- `src/lib/mautic.ts` - Mautic integration
- `.env.local` - Environment variables
## Environment Variables
### Required for Production
```bash
# Saleor
NEXT_PUBLIC_SALEOR_API_URL=https://api.manoonoils.com/graphql/
# Mautic
MAUTIC_CLIENT_ID=2_23cgmaqef8kgg8oo4kggc0w4wccwoss8o8w48o8sc40cowgkkg
MAUTIC_CLIENT_SECRET=4k8367ab306co48c4c8g8sco8cgcwwww044gwccs0o0c8w4gco
MAUTIC_API_URL=https://mautic.nodecrew.me
# Analytics
NEXT_PUBLIC_RYBBIT_HOST=https://rybbit.nodecrew.me
NEXT_PUBLIC_RYBBIT_SITE_ID=1
RYBBIT_API_KEY=...
# Email
RESEND_API_KEY=...
```
## Current Features
### Email Capture Popup
- **Location:** `src/components/home/EmailCapturePopup.tsx`
- **Trigger:** `src/components/home/ExitIntentDetector.tsx`
- **Triggers:** Scroll 10% OR exit intent (mouse leaving viewport)
- **Delay:** Scroll has 5s delay, exit intent shows immediately
- **Fields:** First name (optional), Email (required)
- **Tracking:** UTM params, device info, time on page, referrer
- **Integration:** Creates contact in Mautic with tags
### API Routes
- `/api/email-capture` - Handles form submission to Mautic
- `/api/geoip` - Returns country/region from IP
### i18n Support
- **Locales:** sr (default), en, de, fr
- **Translation files:** `src/i18n/messages/*.json`
## Common Commands
### Development
```bash
npm run dev # Start dev server
npm run build # Production build
npm run test # Run tests
```
### Kubernetes (doorwaysftw server)
```bash
# Check pods
ssh doorwaysftw "kubectl get pods -n manoonoils"
# Restart storefront
ssh doorwaysftw "kubectl delete pod -n manoonoils -l app=storefront"
# Check logs
ssh doorwaysftw "kubectl logs -n manoonoils deployment/storefront"
# Verify env vars
ssh doorwaysftw "kubectl exec -n manoonoils deployment/storefront -- env | grep MAUTIC"
```
## Known Issues & Solutions
### Hydration Errors
- **Cause:** `AnalyticsProvider` returning `null`
- **Solution:** Return `<></>` instead, or remove component
### Popup Not Showing
- Check `ExitIntentDetector` is in `layout.tsx`
- Verify `useVisitorStore` isn't showing popup already shown
- Check browser console for errors
### Mautic API Failures
- Verify env vars in k8s deployment
- Check Mautic credentials haven't expired
- Ensure country code isn't "Local" (use "XX" instead)
## Deployment Checklist
Before deploying to production:
- [ ] All tests pass (`npm run test`)
- [ ] Build succeeds (`npm run build`)
- [ ] No uncommitted changes (`git status`)
- [ ] Merged to dev and tested
- [ ] Merged to master
- [ ] K8s deployment.yaml has correct env vars
- [ ] Pod restarted to pick up new code
- [ ] Smoke test on production URL
## Architecture Decisions
### Why No AnalyticsProvider?
Removed because it returns `null` causing hydration mismatches. Analytics scripts loaded directly in layout.
### Why Direct Rybbit URL?
Using `https://rybbit.nodecrew.me/api/script.js` instead of `/api/script.js` preserves real visitor IP.
### Why Exit Intent + Scroll?
Exit intent catches leaving users immediately. Scroll trigger catches engaged users after delay.
## Contact
- **Maintainer:** User
- **K8s Server:** doorwaysftw (100.109.29.45)
- **Mautic:** https://mautic.nodecrew.me

51
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,51 @@
# Git Workflow
## Branch Strategy
```
feature/* → dev → master
```
| Branch | Purpose |
|--------|---------|
| `master` | Production only |
| `dev` | Integration/testing |
| `feature/*` | All new work |
## Rules
1. **All work starts on a feature branch** - Never commit to dev/master directly
2. **Commit early and often** - Working code = committed code
3. **No uncommitted files** - Working directory must be clean before switching branches
4. **Always flow forward** - feature → dev → master, never skip
5. **Reset feature branches after merge** - Keep them synchronized with master
## Workflow
```bash
# Start work
git checkout -b feature/name
# Commit working code immediately
git add .
git commit -m "feat: description"
# Test on dev
git checkout dev
git merge feature/name
# Deploy to production
git checkout master
git merge dev
# Clean up
git checkout feature/name
git reset --hard master
```
## Pre-Flight Check
Before switching branches:
```bash
git status # Must be clean
```

View File

@@ -84,6 +84,12 @@ spec:
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
- name: OPENPANEL_API_URL
value: "https://op.nodecrew.me/api"
- name: MAUTIC_CLIENT_ID
value: "2_23cgmaqef8kgg8oo4kggc0w4wccwoss8o8w48o8sc40cowgkkg"
- name: MAUTIC_CLIENT_SECRET
value: "4k8367ab306co48c4c8g8sco8cgcwwww044gwccs0o0c8w4gco"
- name: MAUTIC_API_URL
value: "https://mautic.nodecrew.me"
volumeMounts:
- name: workspace
mountPath: /workspace
@@ -132,6 +138,12 @@ spec:
value: "1"
- name: RYBBIT_API_KEY
value: "rb_NgFoMtHeohWoJULLiKqSEJmdghSrhJajgseSWQLjfxyeUJcFfQvUrfYwdllSTsLx"
- name: MAUTIC_CLIENT_ID
value: "2_23cgmaqef8kgg8oo4kggc0w4wccwoss8o8w48o8sc40cowgkkg"
- name: MAUTIC_CLIENT_SECRET
value: "4k8367ab306co48c4c8g8sco8cgcwwww044gwccs0o0c8w4gco"
- name: MAUTIC_API_URL
value: "https://mautic.nodecrew.me"
resources:
limits:
cpu: 500m

View File

@@ -5,13 +5,29 @@ metadata:
namespace: manoonoils
spec:
entryPoints:
- web
- websecure
- web
routes:
- match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
kind: Rule
services:
- name: storefront
port: 3000
- kind: Rule
match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
middlewares:
- name: redirect-https
services:
- name: storefront
port: 3000
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: storefront-secure
namespace: manoonoils
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
services:
- name: storefront
port: 3000
tls:
certResolver: letsencrypt

View File

@@ -3,6 +3,7 @@ kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- middleware.yaml
- ingress.yaml
images:
- name: ghcr.io/unchainedio/manoon-headless

9
k8s/middleware.yaml Normal file
View File

@@ -0,0 +1,9 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
namespace: manoonoils
spec:
redirectScheme:
scheme: https
permanent: true

View File

@@ -4,9 +4,13 @@ metadata:
name: storefront
namespace: manoonoils
spec:
# Use NodePort with externalTrafficPolicy: Local to preserve client source IP
# This is required for proper client IP detection in analytics (Rybbit, etc.)
type: NodePort
externalTrafficPolicy: Local
selector:
app: storefront
ports:
- port: 3000
targetPort: 3000
type: ClusterIP
# Let Kubernetes assign a NodePort automatically

View File

@@ -34,10 +34,8 @@ const nextConfig: NextConfig = {
async rewrites() {
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
return [
{
source: "/api/script.js",
destination: `${rybbitHost}/api/script.js`,
},
// Note: /api/script.js now connects directly to Rybbit (client-side)
// to preserve real visitor IP instead of proxying through Next.js
{
source: "/api/track",
destination: "/api/rybbit/track",

View File

@@ -3,9 +3,10 @@ import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
import Script from "next/script";
import AnalyticsProvider from "@/components/providers/AnalyticsProvider";
import ExitIntentDetector from "@/components/home/ExitIntentDetector";
const RYBBIT_SITE_ID = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID || "1";
const RYBBIT_HOST = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -20,7 +21,7 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { locale } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${locale}`;
const languages: Record<string, string> = {};
for (const loc of SUPPORTED_LOCALES) {
@@ -49,14 +50,27 @@ export default async function LocaleLayout({
return (
<>
<AnalyticsProvider clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""} />
<Script
src="/api/script.js"
id="mautic-tracking"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(w,d,t,u,n,a,m){w['MauticTrackingObject']=n;
w[n]=w[n]||function(){(w[n].q=w[n].q||[]).push(arguments)},a=d.createElement(t),
m=d.getElementsByTagName(t)[0];a.async=1;a.src=u;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://mautic.nodecrew.me/mtc.js','mt');
mt('send', 'pageview');
`,
}}
/>
<Script
src={`${RYBBIT_HOST}/api/script.js`}
data-site-id={RYBBIT_SITE_ID}
strategy="afterInteractive"
/>
<NextIntlClientProvider messages={messages}>
{children}
<ExitIntentDetector />
</NextIntlClientProvider>
</>
);

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

@@ -10,7 +10,8 @@ export async function POST(request: NextRequest) {
const cfConnectingIp = request.headers.get("cf-connecting-ip");
const xForwardedFor = request.headers.get("x-forwarded-for");
const xRealIp = request.headers.get("x-real-ip");
const nextJsIp = request.ip;
// @ts-ignore - ip exists at runtime but not in types
const nextJsIp = (request as any).ip;
// Use the first available IP in priority order
const clientIp =

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) {
// 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",
"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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
};
}

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