Compare commits
1 Commits
feature/em
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b9d8fa7d5 |
@@ -1,189 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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,12 +84,6 @@ spec:
|
|||||||
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||||
- name: OPENPANEL_API_URL
|
- name: OPENPANEL_API_URL
|
||||||
value: "https://op.nodecrew.me/api"
|
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:
|
volumeMounts:
|
||||||
- name: workspace
|
- name: workspace
|
||||||
mountPath: /workspace
|
mountPath: /workspace
|
||||||
@@ -136,14 +130,6 @@ spec:
|
|||||||
value: "https://rybbit.nodecrew.me"
|
value: "https://rybbit.nodecrew.me"
|
||||||
- name: NEXT_PUBLIC_RYBBIT_SITE_ID
|
- name: NEXT_PUBLIC_RYBBIT_SITE_ID
|
||||||
value: "1"
|
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:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
|
|||||||
@@ -5,29 +5,13 @@ metadata:
|
|||||||
namespace: manoonoils
|
namespace: manoonoils
|
||||||
spec:
|
spec:
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- web
|
- web
|
||||||
|
- websecure
|
||||||
routes:
|
routes:
|
||||||
- kind: Rule
|
- match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
|
||||||
match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
|
kind: Rule
|
||||||
middlewares:
|
services:
|
||||||
- name: redirect-https
|
- name: storefront
|
||||||
services:
|
port: 3000
|
||||||
- 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:
|
tls:
|
||||||
certResolver: letsencrypt
|
certResolver: letsencrypt
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ kind: Kustomization
|
|||||||
resources:
|
resources:
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- middleware.yaml
|
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
images:
|
images:
|
||||||
- name: ghcr.io/unchainedio/manoon-headless
|
- name: ghcr.io/unchainedio/manoon-headless
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: redirect-https
|
|
||||||
namespace: manoonoils
|
|
||||||
spec:
|
|
||||||
redirectScheme:
|
|
||||||
scheme: https
|
|
||||||
permanent: true
|
|
||||||
@@ -4,13 +4,9 @@ metadata:
|
|||||||
name: storefront
|
name: storefront
|
||||||
namespace: manoonoils
|
namespace: manoonoils
|
||||||
spec:
|
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:
|
selector:
|
||||||
app: storefront
|
app: storefront
|
||||||
ports:
|
ports:
|
||||||
- port: 3000
|
- port: 3000
|
||||||
targetPort: 3000
|
targetPort: 3000
|
||||||
# Let Kubernetes assign a NodePort automatically
|
type: ClusterIP
|
||||||
|
|||||||
@@ -5,40 +5,16 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
async redirects() {
|
|
||||||
return [
|
|
||||||
// Fix malformed URLs with /contact appended to product slugs
|
|
||||||
{
|
|
||||||
source: '/:locale(en|sr)/products/:slug*/contact',
|
|
||||||
destination: '/:locale/products/:slug*',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/products/:slug*/contact',
|
|
||||||
destination: '/products/:slug*',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
// Redirect old/removed product "manoon" to products listing
|
|
||||||
{
|
|
||||||
source: '/:locale(en|sr)/products/manoon',
|
|
||||||
destination: '/:locale/products',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/products/manoon',
|
|
||||||
destination: '/products',
|
|
||||||
permanent: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
|
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
|
||||||
return [
|
return [
|
||||||
// Note: /api/script.js now connects directly to Rybbit (client-side)
|
{
|
||||||
// to preserve real visitor IP instead of proxying through Next.js
|
source: "/api/script.js",
|
||||||
|
destination: `${rybbitHost}/api/script.js`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: "/api/track",
|
source: "/api/track",
|
||||||
destination: "/api/rybbit/track",
|
destination: `${rybbitHost}/api/track`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/api/site/tracking-config/:id",
|
source: "/api/site/tracking-config/:id",
|
||||||
@@ -87,7 +63,7 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["lucide-react", "framer-motion", "clsx", "motion"],
|
optimizePackageImports: ["lucide-react", "framer-motion"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { Metadata } from "next";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||||
|
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import ExitIntentDetector from "@/components/home/ExitIntentDetector";
|
|
||||||
|
|
||||||
|
// Rybbit configuration
|
||||||
const RYBBIT_SITE_ID = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID || "1";
|
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";
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${locale}`;
|
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
|
||||||
|
|
||||||
const languages: Record<string, string> = {};
|
const languages: Record<string, string> = {};
|
||||||
for (const loc of SUPPORTED_LOCALES) {
|
for (const loc of SUPPORTED_LOCALES) {
|
||||||
@@ -50,27 +50,20 @@ export default async function LocaleLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script
|
<OpenPanelComponent
|
||||||
id="mautic-tracking"
|
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||||
strategy="afterInteractive"
|
trackScreenViews={true}
|
||||||
dangerouslySetInnerHTML={{
|
trackOutgoingLinks={true}
|
||||||
__html: `
|
apiUrl="/api/op"
|
||||||
(function(w,d,t,u,n,a,m){w['MauticTrackingObject']=n;
|
scriptUrl="/api/op1"
|
||||||
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
|
<Script
|
||||||
src={`${RYBBIT_HOST}/api/script.js`}
|
src="/api/script.js"
|
||||||
data-site-id={RYBBIT_SITE_ID}
|
data-site-id={RYBBIT_SITE_ID}
|
||||||
strategy="afterInteractive"
|
strategy="lazyOnload"
|
||||||
/>
|
/>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
<ExitIntentDetector />
|
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslations, useLocale } from "next-intl";
|
|
||||||
import Header from "@/components/layout/Header";
|
|
||||||
import Footer from "@/components/layout/Footer";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Home, Search, Package } from "lucide-react";
|
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
|
||||||
const t = useTranslations("NotFound");
|
|
||||||
const locale = useLocale();
|
|
||||||
const basePath = `/${locale}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header locale={locale} />
|
|
||||||
<main className="min-h-screen bg-white">
|
|
||||||
<div className="pt-[180px] lg:pt-[200px] pb-20 px-4">
|
|
||||||
<div className="max-w-2xl mx-auto text-center">
|
|
||||||
{/* 404 Code */}
|
|
||||||
<div className="text-[120px] lg:text-[180px] font-light text-black/5 leading-none select-none mb-4">
|
|
||||||
404
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-2xl lg:text-3xl font-medium mb-4">
|
|
||||||
{t("title")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-[#666666] mb-10 max-w-md mx-auto">
|
|
||||||
{t("description")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
|
|
||||||
<Link
|
|
||||||
href={`${basePath}/products`}
|
|
||||||
className="flex items-center gap-2 px-6 py-3 bg-black text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors w-full sm:w-auto justify-center"
|
|
||||||
>
|
|
||||||
<Package className="w-4 h-4" />
|
|
||||||
{t("browseProducts")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={basePath}
|
|
||||||
className="flex items-center gap-2 px-6 py-3 border border-black text-black text-sm uppercase tracking-[0.1em] hover:bg-black hover:text-white transition-colors w-full sm:w-auto justify-center"
|
|
||||||
>
|
|
||||||
<Home className="w-4 h-4" />
|
|
||||||
{t("goHome")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Suggestion */}
|
|
||||||
<div className="p-6 bg-[#f8f8f8] rounded-sm">
|
|
||||||
<div className="flex items-center gap-3 mb-3 text-[#666666]">
|
|
||||||
<Search className="w-5 h-5" />
|
|
||||||
<span className="text-sm font-medium uppercase tracking-[0.1em]">
|
|
||||||
{t("lookingFor")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[#666666]">
|
|
||||||
{t("searchSuggestion")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<Footer locale={locale} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,6 @@ import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/l
|
|||||||
import { getPageKeywords } from "@/lib/seo/keywords";
|
import { getPageKeywords } from "@/lib/seo/keywords";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
src/app/api/op/track/route.ts
Normal file
22
src/app/api/op/track/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const OPENPANEL_API_URL = "https://op.nodecrew.me/api";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const response = await fetch(`${OPENPANEL_API_URL}/track`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenPanel] Track error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to track event" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/api/op1/route.ts
Normal file
24
src/app/api/op1/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const OPENPANEL_SCRIPT_URL = "https://op.nodecrew.me/op1.js";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const searchParams = url.search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${OPENPANEL_SCRIPT_URL}${searchParams}`);
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
return new NextResponse(content, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/javascript",
|
||||||
|
"Cache-Control": "public, max-age=86400, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenPanel] Failed to fetch script:", error);
|
||||||
|
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const RYBBIT_API_URL = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Get all possible IP sources for debugging
|
|
||||||
const cfConnectingIp = request.headers.get("cf-connecting-ip");
|
|
||||||
const xForwardedFor = request.headers.get("x-forwarded-for");
|
|
||||||
const xRealIp = request.headers.get("x-real-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 =
|
|
||||||
cfConnectingIp || // Cloudflare (most reliable)
|
|
||||||
xForwardedFor?.split(",")[0]?.trim() || // First IP in chain
|
|
||||||
xRealIp || // Nginx/Traefik
|
|
||||||
nextJsIp || // Next.js fallback
|
|
||||||
"unknown";
|
|
||||||
|
|
||||||
const userAgent = request.headers.get("user-agent") || "";
|
|
||||||
|
|
||||||
console.log("[Rybbit Proxy] IP Debug:", {
|
|
||||||
cfConnectingIp,
|
|
||||||
xForwardedFor,
|
|
||||||
xRealIp,
|
|
||||||
nextJsIp,
|
|
||||||
finalIp: clientIp,
|
|
||||||
userAgent: userAgent?.substring(0, 50),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build headers to forward
|
|
||||||
const forwardHeaders: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Forwarded-For": clientIp,
|
|
||||||
"X-Real-IP": clientIp,
|
|
||||||
"User-Agent": userAgent,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Forward original CF headers if present
|
|
||||||
const cfCountry = request.headers.get("cf-ipcountry");
|
|
||||||
const cfRay = request.headers.get("cf-ray");
|
|
||||||
|
|
||||||
if (cfCountry) forwardHeaders["CF-IPCountry"] = cfCountry;
|
|
||||||
if (cfRay) forwardHeaders["CF-Ray"] = cfRay;
|
|
||||||
|
|
||||||
console.log("[Rybbit Proxy] Forwarding to Rybbit with headers:", Object.keys(forwardHeaders));
|
|
||||||
|
|
||||||
const response = await fetch(`${RYBBIT_API_URL}/api/track`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: forwardHeaders,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.text();
|
|
||||||
console.log("[Rybbit Proxy] Response:", response.status, data.substring(0, 100));
|
|
||||||
|
|
||||||
return new NextResponse(data, {
|
|
||||||
status: response.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Rybbit Proxy] Error:", error);
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: "Proxy error" }),
|
|
||||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle CORS preflight
|
|
||||||
export async function OPTIONS() {
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
const mediaLogos = [
|
const mediaLogos = [
|
||||||
@@ -39,9 +40,15 @@ export default function AsSeenIn() {
|
|||||||
return (
|
return (
|
||||||
<section className="py-12 bg-[#1a1a1a] overflow-hidden border-y border-white/10">
|
<section className="py-12 bg-[#1a1a1a] overflow-hidden border-y border-white/10">
|
||||||
<div className="container mx-auto px-4 mb-8">
|
<div className="container mx-auto px-4 mb-8">
|
||||||
<p className="text-center text-[10px] uppercase tracking-[0.4em] text-[#c9a962] font-bold animate-fade-in">
|
<motion.p
|
||||||
|
className="text-center text-[10px] uppercase tracking-[0.4em] text-[#c9a962] font-bold"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -49,30 +56,29 @@ export default function AsSeenIn() {
|
|||||||
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-[#1a1a1a] to-transparent z-10 pointer-events-none" />
|
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-[#1a1a1a] to-transparent z-10 pointer-events-none" />
|
||||||
|
|
||||||
<div className="flex overflow-hidden">
|
<div className="flex overflow-hidden">
|
||||||
<div className="flex items-center gap-16 animate-marquee">
|
<motion.div
|
||||||
{[...mediaLogos, ...mediaLogos].map((logo, index) => (
|
className="flex items-center gap-16"
|
||||||
<LogoItem key={`${logo.name}-${index}`} name={logo.name} />
|
animate={{
|
||||||
|
x: [0, -50 + "%"],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
x: {
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: "loop",
|
||||||
|
duration: 30,
|
||||||
|
ease: "linear",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaLogos.map((logo, index) => (
|
||||||
|
<LogoItem key={`first-${index}`} name={logo.name} />
|
||||||
))}
|
))}
|
||||||
</div>
|
{mediaLogos.map((logo, index) => (
|
||||||
|
<LogoItem key={`second-${index}`} name={logo.name} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes marquee {
|
|
||||||
0% { transform: translateX(0); }
|
|
||||||
100% { transform: translateX(-50%); }
|
|
||||||
}
|
|
||||||
.animate-marquee {
|
|
||||||
animation: marquee 30s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fade-in 0.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export default function ProblemSection() {
|
export default function ProblemSection() {
|
||||||
const t = useTranslations("ProblemSection");
|
const t = useTranslations("ProblemSection");
|
||||||
const problems = t.raw("problems") as Array<{ problem: string; description: string }>;
|
const problems = t.raw("problems") as Array<{ problem: string; description: string }>;
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.classList.add("animate-visible");
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const animatedElements = sectionRef.current?.querySelectorAll(".animate-on-scroll");
|
|
||||||
animatedElements?.forEach((el) => observer.observe(el));
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={sectionRef} className="py-24 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
|
<section className="py-24 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-3xl mx-auto text-center animate-on-scroll">
|
<motion.div
|
||||||
|
className="max-w-3xl mx-auto text-center"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
<span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium">
|
<span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</span>
|
</span>
|
||||||
@@ -41,14 +27,18 @@ export default function ProblemSection() {
|
|||||||
{t("description")}
|
{t("description")}
|
||||||
</p>
|
</p>
|
||||||
<div className="w-16 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-8 rounded-full" />
|
<div className="w-16 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-8 rounded-full" />
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto mt-16">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 max-w-5xl mx-auto mt-16">
|
||||||
{problems.map((item, index) => (
|
{problems.map((item, index) => (
|
||||||
<div
|
<motion.div
|
||||||
key={index}
|
key={index}
|
||||||
className="relative text-center p-8 bg-white rounded-3xl shadow-lg border border-[#f0ede8] hover:shadow-2xl hover:border-[#c9a962]/30 transition-all duration-500 group animate-on-scroll"
|
className="relative text-center p-8 bg-white rounded-3xl shadow-lg border border-[#f0ede8] hover:shadow-2xl hover:border-[#c9a962]/30 transition-all duration-500 group"
|
||||||
style={{ animationDelay: `${index * 100}ms` }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-20 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] rounded-b-full opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-20 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] rounded-b-full opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
@@ -71,29 +61,10 @@ export default function ProblemSection() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-3">{item.problem}</h3>
|
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-3">{item.problem}</h3>
|
||||||
<p className="text-sm text-[#666666] leading-relaxed">{item.description}</p>
|
<p className="text-sm text-[#666666] leading-relaxed">{item.description}</p>
|
||||||
</div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-on-scroll {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.animate-on-scroll.animate-visible {
|
|
||||||
animation: fadeInUp 0.5s ease-out forwards;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function TrustBadges() {
|
export default function TrustBadges() {
|
||||||
@@ -8,8 +9,21 @@ export default function TrustBadges() {
|
|||||||
return (
|
return (
|
||||||
<section className="py-16 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
|
<section className="py-16 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
<motion.div
|
||||||
<div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0s" }}>
|
className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
<svg className="w-6 h-6 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="w-6 h-6 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||||
@@ -24,9 +38,16 @@ export default function TrustBadges() {
|
|||||||
<p className="text-xs text-[#888888] mt-0.5">
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
{t("basedOnReviews")}
|
{t("basedOnReviews")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.1 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#c9a962" strokeWidth="1.5">
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#c9a962" strokeWidth="1.5">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||||
@@ -41,9 +62,16 @@ export default function TrustBadges() {
|
|||||||
<p className="text-xs text-[#888888] mt-0.5">
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
{t("worldwide")}
|
{t("worldwide")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0.2s" }}>
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.2 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#7eb89e" strokeWidth="1.5">
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#7eb89e" strokeWidth="1.5">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
@@ -58,12 +86,19 @@ export default function TrustBadges() {
|
|||||||
<p className="text-xs text-[#888888] mt-0.5">
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
{t("noAdditives")}
|
{t("noAdditives")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300 animate-fadeSlideUp" style={{ animationDelay: "0.3s" }}>
|
<motion.div
|
||||||
|
className="flex flex-col items-center text-center p-5 bg-white rounded-2xl shadow-md border border-[#f0ede8] hover:shadow-xl hover:border-[#c9a962]/30 transition-all duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
>
|
||||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#faf9f7] to-[#f5f0e8] flex items-center justify-center shadow-sm mb-4 border border-[#e8e4dc]">
|
||||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#e8967a" strokeWidth="1.5">
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="#e8967a" strokeWidth="1.5">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0v1.875c0 .621-.504 1.125-1.125 1.125H4.125A1.125 1.125 0 013 16.875v-1.875m12-9.375v-6.75m0 4.5v-4.5m0 0h-12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight">
|
<p className="text-2xl lg:text-3xl font-bold bg-gradient-to-r from-[#1a1a1a] to-[#4a4a4a] bg-clip-text text-transparent tracking-tight">
|
||||||
@@ -75,26 +110,9 @@ export default function TrustBadges() {
|
|||||||
<p className="text-xs text-[#888888] mt-0.5">
|
<p className="text-xs text-[#888888] mt-0.5">
|
||||||
{t("ordersOver")}
|
{t("ordersOver")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-fadeSlideUp {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeInUp 0.5s ease-out forwards;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,6 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
|
|||||||
fill
|
fill
|
||||||
className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
loading={index < 4 ? "eager" : "lazy"}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// AnalyticsProvider - placeholder for future analytics integrations
|
|
||||||
// Currently only Rybbit is used via the script tag in layout.tsx
|
|
||||||
|
|
||||||
interface AnalyticsProviderProps {
|
|
||||||
clientId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
|
|
||||||
// No-op component - Rybbit is loaded via next/script in layout.tsx
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"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;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"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;
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"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,28 +44,6 @@
|
|||||||
"sustainable": "Nachhaltig",
|
"sustainable": "Nachhaltig",
|
||||||
"sustainableDesc": "Ethnisch beschaffte Zutaten und umweltfreundliche Verpackungen für einen besseren Planeten."
|
"sustainableDesc": "Ethnisch beschaffte Zutaten und umweltfreundliche Verpackungen für einen besseren Planeten."
|
||||||
},
|
},
|
||||||
"Popup": {
|
|
||||||
"badge": "KOSTENLOSER LEITFADEN",
|
|
||||||
"title": "Schließen Sie sich 15.000+ Frauen an, die Ihre Haut transformiert haben",
|
|
||||||
"subtitle": "Holen Sie sich unseren kostenlosen Leitfaden: Die Natürlichen Öl-Geheimnisse der Top-Experten",
|
|
||||||
"bullets": [
|
|
||||||
"Der Öl-Fehler Nr. 1, der Haare beschädigt (und die einfache Lösung)",
|
|
||||||
"3 Öle, die die Haut in 30 Tagen verjüngen",
|
|
||||||
"Die 'Morning Glow'-Routine, die Promis täglich nutzen",
|
|
||||||
"Die schwarze Liste der Inhaltsstoffe, die Sie NIE verwenden sollten"
|
|
||||||
],
|
|
||||||
"firstNamePlaceholder": "Geben Sie Ihren Vornamen ein",
|
|
||||||
"emailPlaceholder": "Ihre beste E-Mail-Adresse",
|
|
||||||
"ctaButton": "Senden Sie Mir Den Leitfaden »",
|
|
||||||
"privacyNote": "Kein Spam. Jederzeit abmelden.",
|
|
||||||
"successTitle": "Erfolg! Prüfen Sie jetzt Ihren Posteingang!",
|
|
||||||
"successMessage": "Der Leitfaden wurde gesendet! Prüfen Sie Ihre E-Mails (und Spam-Ordner).",
|
|
||||||
"alreadySubscribedTitle": "Sie sind bereits dabei!",
|
|
||||||
"alreadySubscribed": "Sie sind bereits dabei! Prüfen Sie Ihre E-Mails für den Leitfaden.",
|
|
||||||
"errorTitle": "Etwas ist schief gelaufen",
|
|
||||||
"errorMessage": "Wir konnten den Leitfaden nicht senden. Bitte versuchen Sie es erneut.",
|
|
||||||
"tryAgain": "Erneut versuchen"
|
|
||||||
},
|
|
||||||
"Products": {
|
"Products": {
|
||||||
"collection": "Unsere Kollektion",
|
"collection": "Unsere Kollektion",
|
||||||
"allProducts": "Alle Produkte",
|
"allProducts": "Alle Produkte",
|
||||||
|
|||||||
@@ -44,28 +44,6 @@
|
|||||||
"sustainable": "Sustainable",
|
"sustainable": "Sustainable",
|
||||||
"sustainableDesc": "Ethically sourced ingredients and eco-friendly packaging for a better planet."
|
"sustainableDesc": "Ethically sourced ingredients and eco-friendly packaging for a better planet."
|
||||||
},
|
},
|
||||||
"Popup": {
|
|
||||||
"badge": "FREE GUIDE",
|
|
||||||
"title": "Join 15,000+ Women Who Transformed Their Skin",
|
|
||||||
"subtitle": "Get Our Free Guide: The Natural Oil Secrets Top Beauty Experts Swear By",
|
|
||||||
"bullets": [
|
|
||||||
"The #1 oil mistake that damages hair (and the simple fix)",
|
|
||||||
"3 oils that reverse aging skin in 30 days",
|
|
||||||
"The 'morning glow' routine celebrities use daily",
|
|
||||||
"The ingredient blacklist you should NEVER use"
|
|
||||||
],
|
|
||||||
"firstNamePlaceholder": "Enter your first name",
|
|
||||||
"emailPlaceholder": "Enter your email",
|
|
||||||
"ctaButton": "Send Me The Free Guide »",
|
|
||||||
"privacyNote": "No spam. Unsubscribe anytime.",
|
|
||||||
"successTitle": "Success! Check your inbox now!",
|
|
||||||
"successMessage": "The guide has been sent! Check your email (and spam folder) for your free guide.",
|
|
||||||
"alreadySubscribedTitle": "You're already a member!",
|
|
||||||
"alreadySubscribed": "You're already in! Check your email for the guide.",
|
|
||||||
"errorTitle": "Something went wrong",
|
|
||||||
"errorMessage": "We couldn't send the guide. Please try again.",
|
|
||||||
"tryAgain": "Try again"
|
|
||||||
},
|
|
||||||
"Products": {
|
"Products": {
|
||||||
"collection": "Our Collection",
|
"collection": "Our Collection",
|
||||||
"allProducts": "All Products",
|
"allProducts": "All Products",
|
||||||
@@ -486,13 +464,5 @@
|
|||||||
"description": "Pay via bank transfer",
|
"description": "Pay via bank transfer",
|
||||||
"comingSoon": "Coming soon"
|
"comingSoon": "Coming soon"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"NotFound": {
|
|
||||||
"title": "Page Not Found",
|
|
||||||
"description": "The page you're looking for doesn't exist or has been moved.",
|
|
||||||
"browseProducts": "Browse Products",
|
|
||||||
"goHome": "Go Home",
|
|
||||||
"lookingFor": "Can't find what you're looking for?",
|
|
||||||
"searchSuggestion": "Try browsing our product collection or contact us for assistance."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,28 +44,6 @@
|
|||||||
"sustainable": "Durable",
|
"sustainable": "Durable",
|
||||||
"sustainableDesc": "Ingrédients sourcés éthiquement et emballage écologique pour une meilleure planète."
|
"sustainableDesc": "Ingrédients sourcés éthiquement et emballage écologique pour une meilleure planète."
|
||||||
},
|
},
|
||||||
"Popup": {
|
|
||||||
"badge": "GUIDE GRATUIT",
|
|
||||||
"title": "Rejoignez 15 000+ femmes qui ont transformé leur peau",
|
|
||||||
"subtitle": "Téléchargez notre guide gratuit: Les Secrets des Huiles Naturelles des Meilleurs Experts",
|
|
||||||
"bullets": [
|
|
||||||
"L'erreur huile n°1 qui abîme les cheveux (et la solution simple)",
|
|
||||||
"3 huiles qui rajeunissent la peau en 30 jours",
|
|
||||||
"La routine 'éclat du matin' utilisée par les célébrités",
|
|
||||||
"La liste noire des ingrédients que vous ne devez JAMAIS utiliser"
|
|
||||||
],
|
|
||||||
"firstNamePlaceholder": "Entrez votre prénom",
|
|
||||||
"emailPlaceholder": "Votre meilleure adresse email",
|
|
||||||
"ctaButton": "Envoyez-Moi Le Guide Gratuit »",
|
|
||||||
"privacyNote": "Pas de spam. Désabonnez-vous à tout moment.",
|
|
||||||
"successTitle": "Succès! Vérifiez votre boîte de réception maintenant!",
|
|
||||||
"successMessage": "Le guide a été envoyé! Vérifiez vos emails (et dossier spam).",
|
|
||||||
"alreadySubscribedTitle": "Vous êtes déjà inscrit!",
|
|
||||||
"alreadySubscribed": "Vous êtes déjà inscrit! Vérifiez vos emails pour le guide.",
|
|
||||||
"errorTitle": "Quelque chose s'est mal passé",
|
|
||||||
"errorMessage": "Nous n'avons pas pu envoyer le guide. Veuillez réessayer.",
|
|
||||||
"tryAgain": "Réessayer"
|
|
||||||
},
|
|
||||||
"Products": {
|
"Products": {
|
||||||
"collection": "Notre Collection",
|
"collection": "Notre Collection",
|
||||||
"allProducts": "Tous Les Produits",
|
"allProducts": "Tous Les Produits",
|
||||||
|
|||||||
@@ -44,28 +44,6 @@
|
|||||||
"sustainable": "Održivo",
|
"sustainable": "Održivo",
|
||||||
"sustainableDesc": "Etički nabavljeni sastojci i ekološka ambalaža za bolju planetu."
|
"sustainableDesc": "Etički nabavljeni sastojci i ekološka ambalaža za bolju planetu."
|
||||||
},
|
},
|
||||||
"Popup": {
|
|
||||||
"badge": "BESPLATAN VODIČ",
|
|
||||||
"title": "Pridružite se 15.000+ žena koje su transformisale svoju kožu",
|
|
||||||
"subtitle": "Preuzmite besplatan vodič: Tajne prirodnih ulja koje koriste najbolji eksperti",
|
|
||||||
"bullets": [
|
|
||||||
"Greška br. 1 sa uljima koja uništava kosu (i jednostavno rešenje)",
|
|
||||||
"3 ulja koja podmlađuju kožu za 30 dana",
|
|
||||||
"Rutinu 'jutarnjeg sjaja' koju koriste poznati",
|
|
||||||
"Listu sastojaka koje NIKADA ne smete koristiti"
|
|
||||||
],
|
|
||||||
"firstNamePlaceholder": "Unesite vaše ime",
|
|
||||||
"emailPlaceholder": "Unesite vaš email",
|
|
||||||
"ctaButton": "Pošaljite Mi Vodič »",
|
|
||||||
"privacyNote": "Bez spama. Odjavite se bilo kada.",
|
|
||||||
"successTitle": "Uspeh! Proverite vaš inbox!",
|
|
||||||
"successMessage": "Vodič je poslat! Proverite vaš email (i spam folder).",
|
|
||||||
"alreadySubscribedTitle": "Već ste član!",
|
|
||||||
"alreadySubscribed": "Već ste u bazi! Proverite email za vodič.",
|
|
||||||
"errorTitle": "Došlo je do greške",
|
|
||||||
"errorMessage": "Nismo mogli da pošaljemo vodič. Molimo pokušajte ponovo.",
|
|
||||||
"tryAgain": "Pokušajte ponovo"
|
|
||||||
},
|
|
||||||
"Products": {
|
"Products": {
|
||||||
"collection": "Naša kolekcija",
|
"collection": "Naša kolekcija",
|
||||||
"allProducts": "Svi proizvodi",
|
"allProducts": "Svi proizvodi",
|
||||||
@@ -485,13 +463,5 @@
|
|||||||
"description": "Platite putem bankovnog transfera",
|
"description": "Platite putem bankovnog transfera",
|
||||||
"comingSoon": "Uskoro dostupno"
|
"comingSoon": "Uskoro dostupno"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"NotFound": {
|
|
||||||
"title": "Stranica Nije Pronađena",
|
|
||||||
"description": "Stranica koju tražite ne postoji ili je premeštena.",
|
|
||||||
"browseProducts": "Pregledaj Proizvode",
|
|
||||||
"goHome": "Početna Strana",
|
|
||||||
"lookingFor": "Ne možete da pronađete ono što tražite?",
|
|
||||||
"searchSuggestion": "Pokušajte da pregledate našu kolekciju proizvoda ili nas kontaktirajte za pomoć."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useOpenPanel } from "@openpanel/nextjs";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
trackRybbitProductView,
|
trackRybbitProductView,
|
||||||
@@ -15,10 +16,109 @@ import {
|
|||||||
trackRybbitUserLogin,
|
trackRybbitUserLogin,
|
||||||
trackRybbitUserRegister,
|
trackRybbitUserRegister,
|
||||||
trackRybbitNewsletterSignup,
|
trackRybbitNewsletterSignup,
|
||||||
trackRybbitEvent,
|
|
||||||
} from "@/lib/services/RybbitService";
|
} from "@/lib/services/RybbitService";
|
||||||
|
|
||||||
export function useAnalytics() {
|
export function useAnalytics() {
|
||||||
|
const op = useOpenPanel();
|
||||||
|
|
||||||
|
// Helper to track with both OpenPanel and Rybbit
|
||||||
|
const trackDual = useCallback((
|
||||||
|
eventName: string,
|
||||||
|
openPanelData: Record<string, any>
|
||||||
|
) => {
|
||||||
|
// OpenPanel tracking
|
||||||
|
try {
|
||||||
|
op.track(eventName, openPanelData);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[OpenPanel] Tracking error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rybbit tracking (fire-and-forget)
|
||||||
|
try {
|
||||||
|
switch (eventName) {
|
||||||
|
case "product_viewed":
|
||||||
|
trackRybbitProductView({
|
||||||
|
id: openPanelData.product_id,
|
||||||
|
name: openPanelData.product_name,
|
||||||
|
price: openPanelData.price,
|
||||||
|
currency: openPanelData.currency,
|
||||||
|
category: openPanelData.category,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "add_to_cart":
|
||||||
|
trackRybbitAddToCart({
|
||||||
|
id: openPanelData.product_id,
|
||||||
|
name: openPanelData.product_name,
|
||||||
|
price: openPanelData.price,
|
||||||
|
currency: openPanelData.currency,
|
||||||
|
quantity: openPanelData.quantity,
|
||||||
|
variant: openPanelData.variant,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "remove_from_cart":
|
||||||
|
trackRybbitRemoveFromCart({
|
||||||
|
id: openPanelData.product_id,
|
||||||
|
name: openPanelData.product_name,
|
||||||
|
quantity: openPanelData.quantity,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "cart_view":
|
||||||
|
trackRybbitCartView({
|
||||||
|
total: openPanelData.cart_total,
|
||||||
|
currency: openPanelData.currency,
|
||||||
|
item_count: openPanelData.item_count,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "checkout_started":
|
||||||
|
trackRybbitCheckoutStarted({
|
||||||
|
total: openPanelData.cart_total,
|
||||||
|
currency: openPanelData.currency,
|
||||||
|
item_count: openPanelData.item_count,
|
||||||
|
items: openPanelData.items,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "checkout_step":
|
||||||
|
trackRybbitCheckoutStep(openPanelData.step, openPanelData);
|
||||||
|
break;
|
||||||
|
case "order_completed":
|
||||||
|
trackRybbitOrderCompleted({
|
||||||
|
order_id: openPanelData.order_id,
|
||||||
|
order_number: openPanelData.order_number,
|
||||||
|
total: openPanelData.total,
|
||||||
|
currency: openPanelData.currency,
|
||||||
|
item_count: openPanelData.item_count,
|
||||||
|
shipping_cost: openPanelData.shipping_cost,
|
||||||
|
customer_email: openPanelData.customer_email,
|
||||||
|
payment_method: openPanelData.payment_method,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "search":
|
||||||
|
trackRybbitSearch(openPanelData.query, openPanelData.results_count);
|
||||||
|
break;
|
||||||
|
case "external_link_click":
|
||||||
|
trackRybbitExternalLink(openPanelData.url, openPanelData.label);
|
||||||
|
break;
|
||||||
|
case "wishlist_add":
|
||||||
|
trackRybbitWishlistAdd({
|
||||||
|
id: openPanelData.product_id,
|
||||||
|
name: openPanelData.product_name,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "user_login":
|
||||||
|
trackRybbitUserLogin(openPanelData.method);
|
||||||
|
break;
|
||||||
|
case "user_register":
|
||||||
|
trackRybbitUserRegister(openPanelData.method);
|
||||||
|
break;
|
||||||
|
case "newsletter_signup":
|
||||||
|
trackRybbitNewsletterSignup(openPanelData.email, openPanelData.source);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Rybbit] Tracking error:", e);
|
||||||
|
}
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
const trackProductView = useCallback((product: {
|
const trackProductView = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,14 +126,15 @@ export function useAnalytics() {
|
|||||||
currency: string;
|
currency: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
}) => {
|
}) => {
|
||||||
trackRybbitProductView({
|
trackDual("product_viewed", {
|
||||||
id: product.id,
|
product_id: product.id,
|
||||||
name: product.name,
|
product_name: product.name,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
currency: product.currency,
|
currency: product.currency,
|
||||||
category: product.category,
|
category: product.category,
|
||||||
|
source: "client",
|
||||||
});
|
});
|
||||||
}, []);
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackAddToCart = useCallback((product: {
|
const trackAddToCart = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,39 +144,42 @@ export function useAnalytics() {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
}) => {
|
}) => {
|
||||||
trackRybbitAddToCart({
|
trackDual("add_to_cart", {
|
||||||
id: product.id,
|
product_id: product.id,
|
||||||
name: product.name,
|
product_name: product.name,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
currency: product.currency,
|
currency: product.currency,
|
||||||
quantity: product.quantity,
|
quantity: product.quantity,
|
||||||
variant: product.variant,
|
variant: product.variant,
|
||||||
|
source: "client",
|
||||||
});
|
});
|
||||||
}, []);
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackRemoveFromCart = useCallback((product: {
|
const trackRemoveFromCart = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}) => {
|
}) => {
|
||||||
trackRybbitRemoveFromCart({
|
trackDual("remove_from_cart", {
|
||||||
id: product.id,
|
product_id: product.id,
|
||||||
name: product.name,
|
product_name: product.name,
|
||||||
quantity: product.quantity,
|
quantity: product.quantity,
|
||||||
|
source: "client",
|
||||||
});
|
});
|
||||||
}, []);
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackCartView = useCallback((cart: {
|
const trackCartView = useCallback((cart: {
|
||||||
total: number;
|
total: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
item_count: number;
|
item_count: number;
|
||||||
}) => {
|
}) => {
|
||||||
trackRybbitCartView({
|
trackDual("cart_view", {
|
||||||
total: cart.total,
|
cart_total: cart.total,
|
||||||
currency: cart.currency,
|
currency: cart.currency,
|
||||||
item_count: cart.item_count,
|
item_count: cart.item_count,
|
||||||
|
source: "client",
|
||||||
});
|
});
|
||||||
}, []);
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackCheckoutStarted = useCallback((cart: {
|
const trackCheckoutStarted = useCallback((cart: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -88,17 +192,22 @@ export function useAnalytics() {
|
|||||||
price: number;
|
price: number;
|
||||||
}>;
|
}>;
|
||||||
}) => {
|
}) => {
|
||||||
trackRybbitCheckoutStarted({
|
trackDual("checkout_started", {
|
||||||
total: cart.total,
|
cart_total: cart.total,
|
||||||
currency: cart.currency,
|
currency: cart.currency,
|
||||||
item_count: cart.item_count,
|
item_count: cart.item_count,
|
||||||
items: cart.items,
|
items: cart.items,
|
||||||
|
source: "client",
|
||||||
});
|
});
|
||||||
}, []);
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||||
trackRybbitCheckoutStep(step, data);
|
trackDual("checkout_step", {
|
||||||
}, []);
|
step,
|
||||||
|
...data,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackOrderCompleted = useCallback(async (order: {
|
const trackOrderCompleted = useCallback(async (order: {
|
||||||
order_id: string;
|
order_id: string;
|
||||||
@@ -112,8 +221,8 @@ export function useAnalytics() {
|
|||||||
}) => {
|
}) => {
|
||||||
console.log("[Analytics] Tracking order:", order.order_number);
|
console.log("[Analytics] Tracking order:", order.order_number);
|
||||||
|
|
||||||
// Rybbit tracking
|
// Track with both OpenPanel and Rybbit
|
||||||
trackRybbitOrderCompleted({
|
trackDual("order_completed", {
|
||||||
order_id: order.order_id,
|
order_id: order.order_id,
|
||||||
order_number: order.order_number,
|
order_number: order.order_number,
|
||||||
total: order.total,
|
total: order.total,
|
||||||
@@ -122,8 +231,20 @@ export function useAnalytics() {
|
|||||||
shipping_cost: order.shipping_cost,
|
shipping_cost: order.shipping_cost,
|
||||||
customer_email: order.customer_email,
|
customer_email: order.customer_email,
|
||||||
payment_method: order.payment_method,
|
payment_method: order.payment_method,
|
||||||
|
source: "client",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OpenPanel revenue tracking
|
||||||
|
try {
|
||||||
|
op.revenue(order.total, {
|
||||||
|
currency: order.currency,
|
||||||
|
transaction_id: order.order_number,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[OpenPanel] Revenue tracking error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Server-side tracking for reliability
|
// Server-side tracking for reliability
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/analytics/track-order", {
|
const response = await fetch("/api/analytics/track-order", {
|
||||||
@@ -147,65 +268,73 @@ export function useAnalytics() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Server Analytics] API call failed:", e);
|
console.error("[Server Analytics] API call failed:", e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [op, trackDual]);
|
||||||
|
|
||||||
const trackSearch = useCallback((query: string, results_count: number) => {
|
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||||
trackRybbitSearch(query, results_count);
|
trackDual("search", {
|
||||||
}, []);
|
query,
|
||||||
|
results_count,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackExternalLink = useCallback((url: string, label?: string) => {
|
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||||
trackRybbitExternalLink(url, label);
|
trackDual("external_link_click", {
|
||||||
}, []);
|
url,
|
||||||
|
label,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackWishlistAdd = useCallback((product: {
|
const trackWishlistAdd = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}) => {
|
}) => {
|
||||||
trackRybbitWishlistAdd({
|
trackDual("wishlist_add", {
|
||||||
id: product.id,
|
product_id: product.id,
|
||||||
name: product.name,
|
product_name: product.name,
|
||||||
|
source: "client",
|
||||||
});
|
});
|
||||||
}, []);
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackUserLogin = useCallback((method: string) => {
|
const trackUserLogin = useCallback((method: string) => {
|
||||||
trackRybbitUserLogin(method);
|
trackDual("user_login", {
|
||||||
}, []);
|
method,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackUserRegister = useCallback((method: string) => {
|
const trackUserRegister = useCallback((method: string) => {
|
||||||
trackRybbitUserRegister(method);
|
trackDual("user_register", {
|
||||||
}, []);
|
method,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
}, [trackDual]);
|
||||||
|
|
||||||
const trackNewsletterSignup = useCallback((email: string, source: string) => {
|
const trackNewsletterSignup = useCallback((email: string, source: string) => {
|
||||||
trackRybbitNewsletterSignup(email, source);
|
trackDual("newsletter_signup", {
|
||||||
}, []);
|
email,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}, [trackDual]);
|
||||||
|
|
||||||
// Popup tracking functions
|
const identifyUser = useCallback((user: {
|
||||||
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;
|
profileId: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
}) => {
|
}) => {
|
||||||
// OpenPanel was removed - this is now a no-op
|
try {
|
||||||
// User identification is handled by Rybbit automatically via cookies
|
op.identify({
|
||||||
}, []);
|
profileId: user.profileId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[OpenPanel] Identify error:", e);
|
||||||
|
}
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackProductView,
|
trackProductView,
|
||||||
@@ -221,10 +350,6 @@ export function useAnalytics() {
|
|||||||
trackUserLogin,
|
trackUserLogin,
|
||||||
trackUserRegister,
|
trackUserRegister,
|
||||||
trackNewsletterSignup,
|
trackNewsletterSignup,
|
||||||
trackPopupView,
|
|
||||||
trackPopupSubmit,
|
|
||||||
trackPopupCtaClick,
|
|
||||||
trackPopupDismiss,
|
|
||||||
identifyUser,
|
identifyUser,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "./types";
|
|
||||||
|
|
||||||
export class AnalyticsTracker {
|
|
||||||
private providers: AnalyticsProvider[] = [];
|
|
||||||
|
|
||||||
addProvider(provider: AnalyticsProvider): void {
|
|
||||||
this.providers.push(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
track(event: AnalyticsEvent): void {
|
|
||||||
for (const provider of this.providers) {
|
|
||||||
try {
|
|
||||||
provider.track(event);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[Analytics] ${provider.name} tracking error:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identify(user: UserData): void {
|
|
||||||
for (const provider of this.providers) {
|
|
||||||
if (provider.identify) {
|
|
||||||
try {
|
|
||||||
provider.identify(user);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[Analytics] ${provider.name} identify error:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async revenue(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void> {
|
|
||||||
const promises: Promise<void>[] = [];
|
|
||||||
for (const provider of this.providers) {
|
|
||||||
if (provider.revenue) {
|
|
||||||
promises.push(
|
|
||||||
provider.revenue(amount, currency, properties).catch((e) => {
|
|
||||||
console.error(`[Analytics] ${provider.name} revenue error:`, e);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
productViewed(product: { id: string; name: string; price: number; currency: string; category?: string; variant?: string }) {
|
|
||||||
this.track({ type: "product_viewed", product });
|
|
||||||
}
|
|
||||||
|
|
||||||
addToCart(product: { id: string; name: string; price: number; currency: string; quantity: number; variant?: string }) {
|
|
||||||
this.track({ type: "add_to_cart", product });
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromCart(product: { id: string; name: string; quantity: number }) {
|
|
||||||
this.track({ type: "remove_from_cart", product });
|
|
||||||
}
|
|
||||||
|
|
||||||
cartViewed(cart: { total: number; currency: string; item_count: number }) {
|
|
||||||
this.track({ type: "cart_view", cart });
|
|
||||||
}
|
|
||||||
|
|
||||||
checkoutStarted(cart: { total: number; currency: string; item_count: number; items?: Array<{ id: string; name: string; quantity: number; price: number }> }) {
|
|
||||||
this.track({ type: "checkout_started", cart });
|
|
||||||
}
|
|
||||||
|
|
||||||
checkoutStep(step: string, data?: Record<string, unknown>) {
|
|
||||||
this.track({ type: "checkout_step", step, data });
|
|
||||||
}
|
|
||||||
|
|
||||||
orderCompleted(order: { order_id: string; order_number: string; total: number; currency: string; item_count: number; shipping_cost?: number; coupon_code?: string; customer_email?: string; payment_method?: string }) {
|
|
||||||
this.track({ type: "order_completed", order });
|
|
||||||
this.revenue(order.total, order.currency, {
|
|
||||||
transaction_id: order.order_number,
|
|
||||||
order_id: order.order_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
searchPerformed(query: string, results_count: number) {
|
|
||||||
this.track({ type: "search", query, results_count });
|
|
||||||
}
|
|
||||||
|
|
||||||
externalLinkClicked(url: string, label?: string) {
|
|
||||||
this.track({ type: "external_link_click", url, label });
|
|
||||||
}
|
|
||||||
|
|
||||||
wishlistAdded(product: { id: string; name: string }) {
|
|
||||||
this.track({ type: "wishlist_add", product });
|
|
||||||
}
|
|
||||||
|
|
||||||
userLoggedIn(method: string) {
|
|
||||||
this.track({ type: "user_login", method });
|
|
||||||
}
|
|
||||||
|
|
||||||
userRegistered(method: string) {
|
|
||||||
this.track({ type: "user_register", method });
|
|
||||||
}
|
|
||||||
|
|
||||||
newsletterSignedUp(email: string, source: string) {
|
|
||||||
this.track({ type: "newsletter_signup", email, source });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let trackerInstance: AnalyticsTracker | null = null;
|
|
||||||
|
|
||||||
export function getTracker(): AnalyticsTracker {
|
|
||||||
if (!trackerInstance) {
|
|
||||||
trackerInstance = new AnalyticsTracker();
|
|
||||||
}
|
|
||||||
return trackerInstance;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
export interface ProductData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
currency: string;
|
|
||||||
category?: string;
|
|
||||||
variant?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CartData {
|
|
||||||
total: number;
|
|
||||||
currency: string;
|
|
||||||
item_count: number;
|
|
||||||
items?: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderData {
|
|
||||||
order_id: string;
|
|
||||||
order_number: string;
|
|
||||||
total: number;
|
|
||||||
currency: string;
|
|
||||||
item_count: number;
|
|
||||||
shipping_cost?: number;
|
|
||||||
coupon_code?: string;
|
|
||||||
customer_email?: string;
|
|
||||||
payment_method?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserData {
|
|
||||||
profileId: string;
|
|
||||||
email?: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AnalyticsEvent =
|
|
||||||
| { type: "product_viewed"; product: ProductData }
|
|
||||||
| { type: "add_to_cart"; product: ProductData & { quantity: number } }
|
|
||||||
| { type: "remove_from_cart"; product: { id: string; name: string; quantity: number } }
|
|
||||||
| { type: "cart_view"; cart: CartData }
|
|
||||||
| { type: "checkout_started"; cart: CartData }
|
|
||||||
| { type: "checkout_step"; step: string; data?: Record<string, unknown> }
|
|
||||||
| { type: "order_completed"; order: OrderData }
|
|
||||||
| { type: "search"; query: string; results_count: number }
|
|
||||||
| { type: "external_link_click"; url: string; label?: string }
|
|
||||||
| { type: "wishlist_add"; product: { id: string; name: string } }
|
|
||||||
| { type: "user_login"; method: string }
|
|
||||||
| { type: "user_register"; method: string }
|
|
||||||
| { type: "newsletter_signup"; email: string; source: string };
|
|
||||||
|
|
||||||
export interface AnalyticsProvider {
|
|
||||||
name: string;
|
|
||||||
track(event: AnalyticsEvent): void;
|
|
||||||
identify?(user: UserData): void;
|
|
||||||
revenue?(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void>;
|
|
||||||
isAvailable(): boolean;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useOpenPanel } from "@openpanel/nextjs";
|
|
||||||
import { getTracker, AnalyticsTracker } from "../core/AnalyticsTracker";
|
|
||||||
import { OpenPanelProvider } from "../providers/OpenPanelProvider";
|
|
||||||
import { RybbitProvider } from "../providers/RybbitProvider";
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
export function useAnalytics(): AnalyticsTracker {
|
|
||||||
const op = useOpenPanel();
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
|
||||||
const trackerRef = useRef<AnalyticsTracker | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialized) {
|
|
||||||
const tracker = getTracker();
|
|
||||||
tracker.addProvider(new OpenPanelProvider(op));
|
|
||||||
tracker.addProvider(new RybbitProvider());
|
|
||||||
trackerRef.current = tracker;
|
|
||||||
initialized = true;
|
|
||||||
setIsReady(true);
|
|
||||||
}
|
|
||||||
}, [op]);
|
|
||||||
|
|
||||||
return trackerRef.current || getTracker();
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { AnalyticsTracker, getTracker } from "./core/AnalyticsTracker";
|
|
||||||
export type { AnalyticsEvent, AnalyticsProvider, ProductData, CartData, OrderData, UserData } from "./core/types";
|
|
||||||
export { OpenPanelProvider } from "./providers/OpenPanelProvider";
|
|
||||||
export { RybbitProvider } from "./providers/RybbitProvider";
|
|
||||||
export { useAnalytics } from "./hooks/useAnalytics";
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types";
|
|
||||||
|
|
||||||
export class OpenPanelProvider implements AnalyticsProvider {
|
|
||||||
name = "OpenPanel";
|
|
||||||
private op: ReturnType<typeof import("@openpanel/nextjs").useOpenPanel>;
|
|
||||||
private isClient: boolean;
|
|
||||||
|
|
||||||
constructor(op: ReturnType<typeof import("@openpanel/nextjs").useOpenPanel>) {
|
|
||||||
this.op = op;
|
|
||||||
this.isClient = typeof window !== "undefined";
|
|
||||||
}
|
|
||||||
|
|
||||||
isAvailable(): boolean {
|
|
||||||
return this.isClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(event: AnalyticsEvent): void {
|
|
||||||
if (!this.isAvailable()) return;
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case "product_viewed":
|
|
||||||
this.op.track("product_viewed", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
price: event.product.price,
|
|
||||||
currency: event.product.currency,
|
|
||||||
category: event.product.category,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "add_to_cart":
|
|
||||||
this.op.track("add_to_cart", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
price: event.product.price,
|
|
||||||
currency: event.product.currency,
|
|
||||||
quantity: event.product.quantity,
|
|
||||||
variant: event.product.variant,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "remove_from_cart":
|
|
||||||
this.op.track("remove_from_cart", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
quantity: event.product.quantity,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "cart_view":
|
|
||||||
this.op.track("cart_view", {
|
|
||||||
cart_total: event.cart.total,
|
|
||||||
currency: event.cart.currency,
|
|
||||||
item_count: event.cart.item_count,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_started":
|
|
||||||
this.op.track("checkout_started", {
|
|
||||||
cart_total: event.cart.total,
|
|
||||||
currency: event.cart.currency,
|
|
||||||
item_count: event.cart.item_count,
|
|
||||||
items: event.cart.items,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_step":
|
|
||||||
this.op.track("checkout_step", {
|
|
||||||
step: event.step,
|
|
||||||
...event.data,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "order_completed":
|
|
||||||
this.op.track("order_completed", {
|
|
||||||
order_id: event.order.order_id,
|
|
||||||
order_number: event.order.order_number,
|
|
||||||
total: event.order.total,
|
|
||||||
currency: event.order.currency,
|
|
||||||
item_count: event.order.item_count,
|
|
||||||
shipping_cost: event.order.shipping_cost,
|
|
||||||
coupon_code: event.order.coupon_code,
|
|
||||||
customer_email: event.order.customer_email,
|
|
||||||
payment_method: event.order.payment_method,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "search":
|
|
||||||
this.op.track("search", {
|
|
||||||
query: event.query,
|
|
||||||
results_count: event.results_count,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "external_link_click":
|
|
||||||
this.op.track("external_link_click", {
|
|
||||||
url: event.url,
|
|
||||||
label: event.label,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "wishlist_add":
|
|
||||||
this.op.track("wishlist_add", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "user_login":
|
|
||||||
this.op.track("user_login", {
|
|
||||||
method: event.method,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "user_register":
|
|
||||||
this.op.track("user_register", {
|
|
||||||
method: event.method,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "newsletter_signup":
|
|
||||||
this.op.track("newsletter_signup", {
|
|
||||||
email: event.email,
|
|
||||||
source: event.source,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identify(user: UserData): void {
|
|
||||||
if (!this.isAvailable()) return;
|
|
||||||
this.op.identify({
|
|
||||||
profileId: user.profileId,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async revenue(amount: number, currency: string, properties?: Record<string, unknown>): Promise<void> {
|
|
||||||
if (!this.isAvailable()) return;
|
|
||||||
await this.op.revenue(amount, { currency, ...properties });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
rybbit?: {
|
|
||||||
event: (eventName: string, eventData?: Record<string, any>) => void;
|
|
||||||
pageview: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type QueuedEvent = {
|
|
||||||
eventName: string;
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class RybbitProvider implements AnalyticsProvider {
|
|
||||||
name = "Rybbit";
|
|
||||||
private isClient: boolean;
|
|
||||||
private eventQueue: QueuedEvent[] = [];
|
|
||||||
private flushInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private initialized = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.isClient = typeof window !== "undefined";
|
|
||||||
|
|
||||||
if (this.isClient) {
|
|
||||||
console.log("[RybbitProvider] Constructor called");
|
|
||||||
// Start checking for rybbit availability
|
|
||||||
this.startFlushInterval();
|
|
||||||
|
|
||||||
// Also try to flush immediately in case script is already loaded
|
|
||||||
setTimeout(() => this.tryFlushQueue(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startFlushInterval() {
|
|
||||||
// Check every 500ms for up to 15 seconds
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 30;
|
|
||||||
|
|
||||||
this.flushInterval = setInterval(() => {
|
|
||||||
attempts++;
|
|
||||||
const available = this.isAvailable();
|
|
||||||
|
|
||||||
if (available && !this.initialized) {
|
|
||||||
console.log("[RybbitProvider] Script became available, flushing queue");
|
|
||||||
this.initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tryFlushQueue();
|
|
||||||
|
|
||||||
if (available || attempts >= maxAttempts) {
|
|
||||||
this.stopFlushInterval();
|
|
||||||
if (attempts >= maxAttempts && !available) {
|
|
||||||
console.warn("[RybbitProvider] Max attempts reached, script not loaded. Queue size:", this.eventQueue.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopFlushInterval() {
|
|
||||||
if (this.flushInterval) {
|
|
||||||
clearInterval(this.flushInterval);
|
|
||||||
this.flushInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryFlushQueue() {
|
|
||||||
if (!this.isAvailable() || this.eventQueue.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[RybbitProvider] Flushing ${this.eventQueue.length} queued events`);
|
|
||||||
|
|
||||||
// Flush all queued events
|
|
||||||
while (this.eventQueue.length > 0) {
|
|
||||||
const event = this.eventQueue.shift();
|
|
||||||
if (event) {
|
|
||||||
this.sendEvent(event.eventName, event.properties);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isAvailable(): boolean {
|
|
||||||
return this.isClient && typeof window.rybbit?.event === "function";
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendEvent(eventName: string, properties?: Record<string, unknown>): void {
|
|
||||||
try {
|
|
||||||
window.rybbit!.event(eventName, properties);
|
|
||||||
console.log(`[Rybbit] Event sent: ${eventName}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[Rybbit] Tracking error for ${eventName}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private trackEvent(eventName: string, properties?: Record<string, unknown>): void {
|
|
||||||
if (!this.isClient) return;
|
|
||||||
|
|
||||||
if (this.isAvailable()) {
|
|
||||||
this.sendEvent(eventName, properties);
|
|
||||||
} else {
|
|
||||||
// Queue the event for later
|
|
||||||
this.eventQueue.push({ eventName, properties });
|
|
||||||
console.log(`[Rybbit] Queued event: ${eventName}, queue size: ${this.eventQueue.length}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
track(event: AnalyticsEvent): void {
|
|
||||||
switch (event.type) {
|
|
||||||
case "product_viewed":
|
|
||||||
this.trackEvent("product_view", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
price: event.product.price,
|
|
||||||
currency: event.product.currency,
|
|
||||||
category: event.product.category,
|
|
||||||
variant: event.product.variant,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "add_to_cart":
|
|
||||||
this.trackEvent("add_to_cart", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
price: event.product.price,
|
|
||||||
currency: event.product.currency,
|
|
||||||
quantity: event.product.quantity,
|
|
||||||
variant: event.product.variant,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "remove_from_cart":
|
|
||||||
this.trackEvent("remove_from_cart", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
quantity: event.product.quantity,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "cart_view":
|
|
||||||
this.trackEvent("cart_view", {
|
|
||||||
cart_total: event.cart.total,
|
|
||||||
currency: event.cart.currency,
|
|
||||||
item_count: event.cart.item_count,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_started":
|
|
||||||
this.trackEvent("checkout_started", {
|
|
||||||
cart_total: event.cart.total,
|
|
||||||
currency: event.cart.currency,
|
|
||||||
item_count: event.cart.item_count,
|
|
||||||
items: event.cart.items,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "checkout_step":
|
|
||||||
this.trackEvent("checkout_step", {
|
|
||||||
step: event.step,
|
|
||||||
...event.data,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "order_completed":
|
|
||||||
this.trackEvent("order_completed", {
|
|
||||||
order_id: event.order.order_id,
|
|
||||||
order_number: event.order.order_number,
|
|
||||||
total: event.order.total,
|
|
||||||
currency: event.order.currency,
|
|
||||||
item_count: event.order.item_count,
|
|
||||||
shipping_cost: event.order.shipping_cost,
|
|
||||||
coupon_code: event.order.coupon_code,
|
|
||||||
customer_email: event.order.customer_email,
|
|
||||||
payment_method: event.order.payment_method,
|
|
||||||
revenue: event.order.total,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "search":
|
|
||||||
this.trackEvent("search", {
|
|
||||||
query: event.query,
|
|
||||||
results_count: event.results_count,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "external_link_click":
|
|
||||||
this.trackEvent("external_link_click", {
|
|
||||||
url: event.url,
|
|
||||||
label: event.label,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "wishlist_add":
|
|
||||||
this.trackEvent("wishlist_add", {
|
|
||||||
product_id: event.product.id,
|
|
||||||
product_name: event.product.name,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "user_login":
|
|
||||||
this.trackEvent("user_login", {
|
|
||||||
method: event.method,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "user_register":
|
|
||||||
this.trackEvent("user_register", {
|
|
||||||
method: event.method,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "newsletter_signup":
|
|
||||||
this.trackEvent("newsletter_signup", {
|
|
||||||
email: event.email,
|
|
||||||
source: event.source,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identify(_user: UserData): void {
|
|
||||||
// Rybbit doesn't have explicit identify - it's handled automatically via cookies
|
|
||||||
}
|
|
||||||
|
|
||||||
revenue?(_amount: number, _currency: string, _properties?: Record<string, unknown>): Promise<void> {
|
|
||||||
// Revenue is tracked via order_completed event
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
const response = NextResponse.next();
|
|
||||||
|
|
||||||
const url = request.nextUrl.pathname;
|
|
||||||
|
|
||||||
if (
|
|
||||||
url.startsWith("/sr") ||
|
|
||||||
url.startsWith("/en") ||
|
|
||||||
url.startsWith("/de") ||
|
|
||||||
url.startsWith("/fr") ||
|
|
||||||
url === "/"
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
!url.includes("/checkout") &&
|
|
||||||
!url.includes("/cart") &&
|
|
||||||
!url.includes("/api/")
|
|
||||||
) {
|
|
||||||
response.headers.set(
|
|
||||||
"Cache-Control",
|
|
||||||
"public, max-age=3600, stale-while-revalidate=86400"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
"/((?!_next/static|_next/image|favicon.ico|icon.png|robots.txt|sitemap.xml).*)",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user