Compare commits
12 Commits
fix/analyt
...
feature/em
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6caefb420a | ||
|
|
cbbcaace22 | ||
|
|
eb711fbf1a | ||
|
|
4e5481af1a | ||
|
|
618298b1b1 | ||
|
|
d999d739d5 | ||
|
|
0f00aa8a47 | ||
|
|
93b239bc5a | ||
|
|
1ed6cac647 | ||
|
|
e476bc9fc4 | ||
|
|
f4f23aa7f3 | ||
|
|
9124eeedc1 |
189
.opencode/PROJECT_MEMORY.md
Normal file
189
.opencode/PROJECT_MEMORY.md
Normal 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
51
CONTRIBUTING.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
9
k8s/middleware.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-https
|
||||
namespace: manoonoils
|
||||
spec:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
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: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
288
src/components/home/EmailCapturePopup.tsx
Normal file
288
src/components/home/EmailCapturePopup.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { motion } from "framer-motion";
|
||||
import { X, Sparkles, ArrowRight, Check, Loader2 } from "lucide-react";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
interface EmailCapturePopupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubscribe?: () => void;
|
||||
trigger: "scroll" | "exit";
|
||||
locale: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
function getUtmParams() {
|
||||
if (typeof window === "undefined") return {};
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
utmSource: params.get("utm_source") || "",
|
||||
utmMedium: params.get("utm_medium") || "",
|
||||
utmCampaign: params.get("utm_campaign") || "",
|
||||
utmContent: params.get("utm_content") || "",
|
||||
fbclid: params.get("fbclid") || "",
|
||||
};
|
||||
}
|
||||
|
||||
function getDeviceInfo() {
|
||||
if (typeof window === "undefined") return { deviceName: "", deviceOS: "", userAgent: "" };
|
||||
const userAgent = navigator.userAgent;
|
||||
let deviceName = "Unknown";
|
||||
let deviceOS = "Unknown";
|
||||
|
||||
if (userAgent.match(/Windows/i)) deviceOS = "Windows";
|
||||
else if (userAgent.match(/Mac/i)) deviceOS = "MacOS";
|
||||
else if (userAgent.match(/Linux/i)) deviceOS = "Linux";
|
||||
else if (userAgent.match(/Android/i)) deviceOS = "Android";
|
||||
else if (userAgent.match(/iPhone|iPad|iPod/i)) deviceOS = "iOS";
|
||||
|
||||
if (userAgent.match(/Mobile/i)) deviceName = "Mobile";
|
||||
else if (userAgent.match(/Tablet/i)) deviceName = "Tablet";
|
||||
else deviceName = "Desktop";
|
||||
|
||||
return { deviceName, deviceOS, userAgent };
|
||||
}
|
||||
|
||||
export default function EmailCapturePopup({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubscribe,
|
||||
trigger,
|
||||
locale,
|
||||
country,
|
||||
countryCode,
|
||||
}: EmailCapturePopupProps) {
|
||||
const t = useTranslations("Popup");
|
||||
const { trackPopupSubmit, trackPopupCtaClick } = useAnalytics();
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "success" | "alreadySubscribed" | "error">("idle");
|
||||
const [pageLoadTime] = useState(() => Date.now());
|
||||
|
||||
const handleCTAClick = () => {
|
||||
trackPopupCtaClick({ locale });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !email.includes("@")) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
trackPopupSubmit({ trigger, locale, country: countryCode });
|
||||
|
||||
const timeOnPage = Math.floor((Date.now() - pageLoadTime) / 1000);
|
||||
const utmParams = getUtmParams();
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/email-capture", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
firstName: firstName.trim(),
|
||||
email,
|
||||
locale,
|
||||
country,
|
||||
countryCode,
|
||||
source: "popup",
|
||||
trigger,
|
||||
timeOnPage,
|
||||
referrer: document.referrer || "",
|
||||
pageUrl: window.location.href,
|
||||
pageLanguage: navigator.language || "",
|
||||
preferredLocale: locale,
|
||||
...deviceInfo,
|
||||
...utmParams,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.alreadySubscribed) {
|
||||
setStatus("alreadySubscribed");
|
||||
} else {
|
||||
setStatus("success");
|
||||
}
|
||||
onSubscribe?.();
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-white/80 hover:bg-white transition-colors shadow-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="p-8 pt-10">
|
||||
{status === "idle" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<span className="inline-block px-3 py-1 text-xs font-semibold tracking-wider text-[#c9a962] bg-[#c9a962]/10 rounded-full mb-4">
|
||||
{t("badge")}
|
||||
</span>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2 leading-tight">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{t.raw("bullets").map((bullet: string, index: number) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 + index * 0.1 }}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-[#c9a962]/20 flex items-center justify-center mt-0.5">
|
||||
<Check className="w-3 h-3 text-[#c9a962]" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{bullet}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder={t("firstNamePlaceholder")}
|
||||
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#c9a962]/50 focus:border-[#c9a962] transition-all text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#c9a962]/50 focus:border-[#c9a962] transition-all text-gray-900 placeholder:text-gray-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleCTAClick}
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-4 bg-gradient-to-r from-[#c9a962] to-[#e8c547] text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-[#c9a962]/25 transition-all disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t("ctaButton")}
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-4">
|
||||
{t("privacyNote")}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Check className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{t("successTitle")}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t("successMessage")}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === "alreadySubscribed" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-[#c9a962]/20 rounded-full flex items-center justify-center">
|
||||
<Sparkles className="w-8 h-8 text-[#c9a962]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{t("alreadySubscribedTitle")}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t("alreadySubscribed")}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-8"
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<X className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{t("errorTitle")}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">{t("errorMessage")}</p>
|
||||
<button
|
||||
onClick={() => setStatus("idle")}
|
||||
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
{t("tryAgain")}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/home/ExitIntentDetector.tsx
Normal file
102
src/components/home/ExitIntentDetector.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useScrollDepth } from "@/hooks/useScrollDepth";
|
||||
import { useExitIntent } from "@/hooks/useExitIntent";
|
||||
import { useVisitorStore } from "@/hooks/useVisitorStore";
|
||||
import EmailCapturePopup from "./EmailCapturePopup";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
const SCROLL_POPUP_DELAY_MS = 5000;
|
||||
|
||||
export default function ExitIntentDetector() {
|
||||
const params = useParams();
|
||||
const locale = (params.locale as string) || "en";
|
||||
const { trackPopupView } = useAnalytics();
|
||||
|
||||
const scrollTriggered = useScrollDepth(10);
|
||||
const exitTriggered = useExitIntent();
|
||||
const { canShowPopup, markPopupShown, markSubscribed } = useVisitorStore();
|
||||
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const [trigger, setTrigger] = useState<"scroll" | "exit">("scroll");
|
||||
const [country, setCountry] = useState("Unknown");
|
||||
const [countryCode, setCountryCode] = useState("XX");
|
||||
const [city, setCity] = useState("");
|
||||
const [region, setRegion] = useState("");
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCountry = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/geoip");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCountry(data.country);
|
||||
setCountryCode(data.countryCode);
|
||||
setCity(data.city || "");
|
||||
setRegion(data.region || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get country:", error);
|
||||
}
|
||||
setIsReady(true);
|
||||
};
|
||||
fetchCountry();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[ExitIntent] Scroll triggered:", scrollTriggered);
|
||||
console.log("[ExitIntent] Exit triggered:", exitTriggered);
|
||||
console.log("[ExitIntent] isReady:", isReady);
|
||||
console.log("[ExitIntent] canShowPopup:", canShowPopup());
|
||||
|
||||
if (!isReady || !canShowPopup()) return;
|
||||
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (scrollTriggered || exitTriggered) {
|
||||
const newTrigger = exitTriggered ? "exit" : "scroll";
|
||||
console.log("[ExitIntent] Trigger activated:", newTrigger);
|
||||
setTrigger(newTrigger);
|
||||
|
||||
// Exit intent shows immediately, scroll has a delay
|
||||
const delay = exitTriggered ? 0 : SCROLL_POPUP_DELAY_MS;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
console.log("[ExitIntent] Timer fired, checking canShowPopup again");
|
||||
if (canShowPopup()) {
|
||||
console.log("[ExitIntent] Showing popup!");
|
||||
setShowPopup(true);
|
||||
markPopupShown(newTrigger);
|
||||
trackPopupView({ trigger: newTrigger, locale, country: countryCode });
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [scrollTriggered, exitTriggered, isReady, canShowPopup, markPopupShown, trackPopupView, locale, countryCode]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
markSubscribed();
|
||||
};
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<EmailCapturePopup
|
||||
isOpen={showPopup}
|
||||
onClose={handleClose}
|
||||
onSubscribe={handleSubscribe}
|
||||
trigger={trigger}
|
||||
locale={locale}
|
||||
country={country}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,5 +9,5 @@ interface AnalyticsProviderProps {
|
||||
|
||||
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
|
||||
// No-op component - Rybbit is loaded via next/script in layout.tsx
|
||||
return null;
|
||||
return <></>;
|
||||
}
|
||||
|
||||
62
src/components/ui/Drawer.tsx
Normal file
62
src/components/ui/Drawer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface DrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
side?: "left" | "right";
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export default function Drawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
side = "left",
|
||||
width = "max-w-[420px]",
|
||||
}: DrawerProps) {
|
||||
const slideAnimation = {
|
||||
initial: { x: side === "left" ? "-100%" : "100%" },
|
||||
animate: { x: 0 },
|
||||
exit: { x: side === "left" ? "-100%" : "100%" },
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className={`fixed top-0 ${side}-0 bottom-0 ${width} w-full bg-white z-50 shadow-2xl`}
|
||||
initial={slideAnimation.initial}
|
||||
animate={slideAnimation.animate}
|
||||
exit={slideAnimation.exit}
|
||||
transition={{ type: "tween", duration: 0.3 }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 rounded-full hover:bg-gray-100 transition-colors z-10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
|
||||
<div className="h-full overflow-y-auto">{children}</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
21
src/hooks/useExitIntent.ts
Normal file
21
src/hooks/useExitIntent.ts
Normal 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;
|
||||
}
|
||||
28
src/hooks/useScrollDepth.ts
Normal file
28
src/hooks/useScrollDepth.ts
Normal 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;
|
||||
}
|
||||
100
src/hooks/useVisitorStore.ts
Normal file
100
src/hooks/useVisitorStore.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
19
src/lib/geoip.ts
Normal 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
120
src/lib/mautic.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user