Compare commits

..

27 Commits

Author SHA1 Message Date
Unchained
6caefb420a docs: add OpenCode project memory for git workflow
Some checks are pending
Build and Deploy / build (push) Waiting to run
2026-04-03 21:06:15 +02:00
Unchained
cbbcaace22 docs: add git workflow guidelines
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-03 21:03:04 +02:00
Unchained
eb711fbf1a feat(popup): add email capture popup with Mautic integration
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Email capture popup with scroll (10%) and exit intent triggers
- First name field and full tracking (UTM, device, time on page)
- Mautic API integration for contact creation
- GeoIP detection for country/region
- 4 locale support (sr, en, de, fr)
- Mautic tracking script in layout
2026-04-03 20:44:15 +02:00
Unchained
4e5481af1a fix(layout): restore ExitIntentDetector and Mautic tracking
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-03 20:27:25 +02:00
Unchained
618298b1b1 fix(layout): restore original structure, keep only Rybbit direct URL fix
Some checks failed
Build and Deploy / build (push) Has been cancelled
Revert unintended changes from previous commit:
- Restore AnalyticsProvider (was accidentally removed)
- Remove ExitIntentDetector (feature branch code)
- Remove Mautic tracking script (feature branch code)

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

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

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

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

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

Note: On single-node clusters, this works seamlessly. Traefik routes directly
to the node where the pod is running, preserving the original source IP.
2026-04-03 06:55:42 +02:00
Unchained
e476bc9fc4 fix(k8s): add HTTP to HTTPS redirect for manoonoils.com
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Create redirect-https middleware for permanent redirect (301)
- Split IngressRoute: HTTP route redirects to HTTPS, HTTPS route serves app
- Fixes Google Search Console 404 error on HTTP version
- No application code changes, only routing configuration
2026-04-02 22:50:43 +02:00
Unchained
f4f23aa7f3 fix(k8s): add HTTP to HTTPS redirect for manoonoils.com
- Create redirect-https middleware for permanent redirect (301)
- Split IngressRoute: HTTP route redirects to HTTPS, HTTPS route serves app
- Fixes Google Search Console 404 error on HTTP version
- No application code changes, only routing configuration
2026-04-02 22:49:26 +02:00
Unchained
9124eeedc1 fix: add ts-ignore for request.ip runtime property
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 10:47:09 +02:00
Unchained
6843d2db36 fix(404): add redirects for broken URLs and custom not-found page
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add permanent redirects for /products/manoon to /products
- Strip malformed /contact suffix from product URLs
- Create custom branded 404 page with product navigation
- Add NotFound translations for en and sr locales
2026-04-01 10:24:09 +02:00
Unchained
0b9ddeedc8 fix(analytics): properly forward client IPs to Rybbit and OpenPanel
- Create new API route /api/rybbit/track to proxy Rybbit tracking requests
- Extract real client IP from Cloudflare headers (cf-connecting-ip)
- Forward X-Forwarded-For and X-Real-IP headers to analytics backends
- Update OpenPanel proxy to also forward client IP
- Update next.config.ts rewrite to use internal API route

This fixes geo-location issues where all traffic appeared to come from
Cloudflare edge locations instead of actual visitor countries.
2026-04-01 10:24:09 +02:00
Unchained
a3873bb50d fix(analytics): properly forward client IPs to Rybbit and OpenPanel
- Create new API route /api/rybbit/track to proxy Rybbit tracking requests
- Extract real client IP from Cloudflare headers (cf-connecting-ip)
- Forward X-Forwarded-For and X-Real-IP headers to analytics backends
- Update OpenPanel proxy to also forward client IP
- Update next.config.ts rewrite to use internal API route

This fixes geo-location issues where all traffic appeared to come from
Cloudflare edge locations instead of actual visitor countries.
2026-04-01 07:42:34 +02:00
Unchained
3c9c091c46 fix: revert HeroVideo aspect-ratio, fix ProblemSection scroll animation with useEffect
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 06:43:43 +02:00
Unchained
27af03ba3a feat(performance): optimize Core Web Vitals with CSS animations and lazy analytics
- Replace framer-motion with CSS animations in TrustBadges, AsSeenIn, ProblemSection
- Create AnalyticsProvider client component for OpenPanel lazy-loading
- Fix HeroVideo CLS with explicit aspect-ratio (4/3)
- Remove deprecated swcMinify from next.config (enabled by default)
- Add optimizePackageImports for better tree-shaking
2026-04-01 06:14:49 +02:00
Unchained
ad20ffe588 Merge branch 'dev'
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 05:17:48 +02:00
Unchained
13301dca12 fix: use middleware.ts instead of proxy.ts for build compatibility 2026-04-01 05:17:36 +02:00
Unchained
e57169a807 fix: revert proxy back to middleware for Next.js build compatibility 2026-04-01 05:15:42 +02:00
Unchained
3697a5d8ea fix: rename middleware to proxy for Next.js 16 compatibility
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-01 04:19:07 +02:00
Unchained
edd5c1582b feat(performance): add ISR and Cloudflare cache headers
- Add revalidate=3600 to homepage and products page (1hr ISR)
- Add middleware to set cache headers for HTML pages
- Bypass cache for checkout and cart pages
2026-03-31 20:08:56 +02:00
Unchained
dff78b28a5 fix(analytics): restore OpenPanel proxy routes
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 13:47:14 +02:00
Unchained
b4905ce4ee chore: remove OpenPanel proxy routes (keeping core vitals changes)
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 13:44:53 +02:00
Unchained
e87c655a5b Merge branch 'feature/web-vitals-optimization' into dev
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 13:22:45 +02:00
Unchained
1c5ec1a271 fix: remove framer-motion from HeroVideo for instant content visibility 2026-03-31 13:22:45 +02:00
Unchained
8eb9f24b33 feat(performance): Core Web Vitals optimizations
- Font optimization: Replace @font-face with next/font/google (DM Sans, Inter) for faster font loading and no render-blocking
- Image optimization: Add Unsplash to remotePatterns, configure AVIF/WebP formats, add device/image sizes
- Convert native <img> tags to next/image with proper sizing and priority for LCP images
- Add optimizePackageImports for lucide-react and framer-motion to reduce bundle size
- Fix CLS: Urgency message uses fixed min-height instead of animated height
- Fix CLS: ProductCard quick-add button uses opacity instead of translate for hover
- Convert HeroVideo scroll indicator to CSS animation
- Script loading: Rybbit uses lazyOnload strategy for better INP
2026-03-31 12:03:34 +02:00
41 changed files with 1934 additions and 448 deletions

189
.opencode/PROJECT_MEMORY.md Normal file
View File

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

51
CONTRIBUTING.md Normal file
View File

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

View File

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

View File

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

View File

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

9
k8s/middleware.yaml Normal file
View File

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

View File

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

View File

@@ -5,18 +5,40 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
output: 'standalone',
async rewrites() {
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
const openpanelUrl = process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api";
const openpanelScriptUrl = "https://op.nodecrew.me";
async redirects() {
return [
// Fix malformed URLs with /contact appended to product slugs
{
source: "/api/script.js",
destination: `${rybbitHost}/api/script.js`,
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() {
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
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/track",
destination: `${rybbitHost}/api/track`,
destination: "/api/rybbit/track",
},
{
source: "/api/site/tracking-config/:id",
@@ -30,13 +52,12 @@ const nextConfig: NextConfig = {
source: "/api/session-replay/record/:id",
destination: `${rybbitHost}/api/session-replay/record/:id`,
},
{
source: "/api/op/track",
destination: `${openpanelUrl}/track`,
},
];
},
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
remotePatterns: [
{
protocol: "https",
@@ -58,8 +79,16 @@ const nextConfig: NextConfig = {
hostname: "**.saleor.cloud",
pathname: "/**",
},
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
],
},
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion", "clsx", "motion"],
},
};
export default withNextIntl(nextConfig);

View File

@@ -5,6 +5,7 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next";
import Image from "next/image";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -67,10 +68,13 @@ export default async function AboutPage({ params }: AboutPageProps) {
</div>
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
<img
<Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
alt={metadata.about.productionAlt}
className="w-full h-full object-cover"
fill
priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-black/20" />
</div>

View File

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

View File

@@ -0,0 +1,68 @@
"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} />
</>
);
}

View File

@@ -14,6 +14,9 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next";
import Image from "next/image";
export const revalidate = 3600;
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -157,10 +160,12 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
</a>
</div>
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
<img
<Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
alt={metadata.home.productionAlt}
className="w-full h-full object-cover"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
</div>

View File

@@ -9,6 +9,8 @@ import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/l
import { getPageKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next";
export const revalidate = 3600;
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
interface ProductsPageProps {

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { createMauticContact } from "@/lib/mautic";
const requestCache = new Map<string, number>();
const DEBOUNCE_MS = 5000;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
email,
locale,
country,
countryCode,
source,
trigger,
firstName,
lastName,
timeOnPage,
referrer,
pageUrl,
pageLanguage,
preferredLocale,
deviceName,
deviceOS,
userAgent,
utmSource,
utmMedium,
utmCampaign,
utmContent,
fbclid,
} = body;
if (!email || !email.includes("@")) {
return NextResponse.json(
{ error: "Invalid email" },
{ status: 400 }
);
}
const cacheKey = `${email}:${Date.now()}`;
const lastRequest = requestCache.get(cacheKey);
if (lastRequest && Date.now() - lastRequest < DEBOUNCE_MS) {
return NextResponse.json(
{ error: "Please wait before submitting again" },
{ status: 429 }
);
}
requestCache.set(cacheKey, Date.now());
const tags = [
"source:popup",
`locale:${locale || "en"}`,
`country:${countryCode || "XX"}`,
`popup_${trigger || "unknown"}`,
"lead:warm",
...(utmSource ? [`utm:${utmSource}`] : []),
...(deviceName ? [`device:${deviceName}`] : []),
];
const forwardedFor = request.headers.get("x-forwarded-for");
const realIP = request.headers.get("x-real-ip");
const ipAddress = forwardedFor?.split(",")[0]?.trim() || realIP || "unknown";
const result = await createMauticContact(email, tags, {
firstName: firstName || "",
lastName: lastName || "",
country: country || "",
preferredLocale: preferredLocale || locale || "en",
ipAddress,
utmSource: utmSource || "",
utmMedium: utmMedium || "",
utmCampaign: utmCampaign || "",
utmContent: utmContent || "",
pageUrl: pageUrl || request.headers.get("referer") || "",
});
console.log("Email capture success:", {
email,
firstName,
timeOnPage,
deviceName,
deviceOS,
utmSource,
utmMedium,
result
});
return NextResponse.json({
success: true,
alreadySubscribed: result.alreadyExists,
contactId: result.contactId,
});
} catch (error) {
console.error("Email capture error:", error);
return NextResponse.json(
{ error: "Failed to process subscription", details: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
// Check for Cloudflare's IP header first (production)
const cfConnectingIp = request.headers.get("cf-connecting-ip");
const forwardedFor = request.headers.get("x-forwarded-for");
const realIP = request.headers.get("x-real-ip");
// Use Cloudflare IP first, then fall back to other headers
let ip = cfConnectingIp || forwardedFor?.split(",")[0]?.trim() || realIP || "127.0.0.1";
// For local development, return XX as country code (Mautic accepts this)
if (ip === "127.0.0.1" || ip === "::1" || ip.startsWith("192.168.") || ip.startsWith("10.")) {
console.log("[GeoIP] Local/private IP detected:", ip);
return NextResponse.json({
country: "Unknown",
countryCode: "XX",
region: "",
city: "",
timezone: "",
});
}
const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,timezone`, {
headers: {
"Accept": "application/json",
},
});
if (!response.ok) {
throw new Error("GeoIP lookup failed");
}
const data = await response.json();
if (data.status !== "success") {
console.error("[GeoIP] API error:", data.message, "for IP:", ip);
return NextResponse.json({
country: "Unknown",
countryCode: "XX",
region: "",
city: "",
timezone: "",
});
}
console.log("[GeoIP] Success:", data.country, "(" + data.countryCode + ")");
return NextResponse.json({
country: data.country,
countryCode: data.countryCode,
region: data.regionName,
city: data.city,
timezone: data.timezone,
});
} catch (error) {
console.error("[GeoIP] Error:", error);
return NextResponse.json({
country: "Unknown",
countryCode: "XX",
region: "",
city: "",
timezone: "",
});
}
}

View File

@@ -1,24 +0,0 @@
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 });
}
}

View File

@@ -0,0 +1,87 @@
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",
},
});
}

View File

@@ -53,8 +53,7 @@
--color-cta-hover: #333333;
--color-overlay: rgba(0, 0, 0, 0.4);
--font-display: 'DM Sans', sans-serif;
--font-body: 'Inter', sans-serif;
/* Font variables will be set by next/font in layout.tsx */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
@@ -66,26 +65,9 @@
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
/* ============================================
FONT IMPORTS
============================================ */
@font-face {
font-family: 'DM Sans';
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhS2f3ZGMZpg.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}
/* ============================================
BASE STYLES (in Tailwind base layer)
Fonts loaded via next/font in layout.tsx
============================================ */
@layer base {
@@ -266,6 +248,38 @@
}
}
/* ============================================
SCROLL INDICATOR ANIMATION
============================================ */
@keyframes scrollBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
.scroll-indicator {
animation: scrollBounce 1.5s ease-in-out infinite;
}
/* ============================================
FADE SLIDE UP ANIMATION
============================================ */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeSlideUp {
animation: fadeSlideUp 0.6s ease-out both;
}
/* ============================================
UTILITIES
============================================ */

View File

@@ -1,9 +1,22 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
import { DM_Sans, Inter } from "next/font/google";
import ErrorBoundary from "@/components/providers/ErrorBoundary";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
import { OrganizationSchema } from "@/components/seo";
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
});
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
export const metadata: Metadata = {
@@ -39,7 +52,7 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
return (
<html suppressHydrationWarning>
<html suppressHydrationWarning className={`${dmSans.variable} ${inter.variable}`}>
<body className="antialiased" suppressHydrationWarning>
<ErrorBoundary>
{children}

View File

@@ -1,6 +1,5 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
const mediaLogos = [
@@ -40,15 +39,9 @@ export default function AsSeenIn() {
return (
<section className="py-12 bg-[#1a1a1a] overflow-hidden border-y border-white/10">
<div className="container mx-auto px-4 mb-8">
<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 }}
>
<p className="text-center text-[10px] uppercase tracking-[0.4em] text-[#c9a962] font-bold animate-fade-in">
{t("title")}
</motion.p>
</p>
</div>
<div className="relative">
@@ -56,29 +49,30 @@ 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="flex overflow-hidden">
<motion.div
className="flex items-center gap-16"
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 className="flex items-center gap-16 animate-marquee">
{[...mediaLogos, ...mediaLogos].map((logo, index) => (
<LogoItem key={`${logo.name}-${index}`} name={logo.name} />
))}
{mediaLogos.map((logo, index) => (
<LogoItem key={`second-${index}`} name={logo.name} />
))}
</motion.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>
);
}
}

View File

@@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import { X, Sparkles, ArrowRight, Check, Loader2 } from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
interface EmailCapturePopupProps {
isOpen: boolean;
onClose: () => void;
onSubscribe?: () => void;
trigger: "scroll" | "exit";
locale: string;
country: string;
countryCode: string;
}
function getUtmParams() {
if (typeof window === "undefined") return {};
const params = new URLSearchParams(window.location.search);
return {
utmSource: params.get("utm_source") || "",
utmMedium: params.get("utm_medium") || "",
utmCampaign: params.get("utm_campaign") || "",
utmContent: params.get("utm_content") || "",
fbclid: params.get("fbclid") || "",
};
}
function getDeviceInfo() {
if (typeof window === "undefined") return { deviceName: "", deviceOS: "", userAgent: "" };
const userAgent = navigator.userAgent;
let deviceName = "Unknown";
let deviceOS = "Unknown";
if (userAgent.match(/Windows/i)) deviceOS = "Windows";
else if (userAgent.match(/Mac/i)) deviceOS = "MacOS";
else if (userAgent.match(/Linux/i)) deviceOS = "Linux";
else if (userAgent.match(/Android/i)) deviceOS = "Android";
else if (userAgent.match(/iPhone|iPad|iPod/i)) deviceOS = "iOS";
if (userAgent.match(/Mobile/i)) deviceName = "Mobile";
else if (userAgent.match(/Tablet/i)) deviceName = "Tablet";
else deviceName = "Desktop";
return { deviceName, deviceOS, userAgent };
}
export default function EmailCapturePopup({
isOpen,
onClose,
onSubscribe,
trigger,
locale,
country,
countryCode,
}: EmailCapturePopupProps) {
const t = useTranslations("Popup");
const { trackPopupSubmit, trackPopupCtaClick } = useAnalytics();
const [firstName, setFirstName] = useState("");
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "alreadySubscribed" | "error">("idle");
const [pageLoadTime] = useState(() => Date.now());
const handleCTAClick = () => {
trackPopupCtaClick({ locale });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !email.includes("@")) return;
setIsSubmitting(true);
trackPopupSubmit({ trigger, locale, country: countryCode });
const timeOnPage = Math.floor((Date.now() - pageLoadTime) / 1000);
const utmParams = getUtmParams();
const deviceInfo = getDeviceInfo();
try {
const response = await fetch("/api/email-capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: firstName.trim(),
email,
locale,
country,
countryCode,
source: "popup",
trigger,
timeOnPage,
referrer: document.referrer || "",
pageUrl: window.location.href,
pageLanguage: navigator.language || "",
preferredLocale: locale,
...deviceInfo,
...utmParams,
}),
});
if (response.ok) {
const data = await response.json();
if (data.alreadySubscribed) {
setStatus("alreadySubscribed");
} else {
setStatus("success");
}
onSubscribe?.();
} else {
setStatus("error");
}
} catch (error) {
setStatus("error");
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
>
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-white/80 hover:bg-white transition-colors shadow-sm"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-500" />
</button>
<div className="flex flex-col">
<div className="p-8 pt-10">
{status === "idle" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="text-center mb-6">
<span className="inline-block px-3 py-1 text-xs font-semibold tracking-wider text-[#c9a962] bg-[#c9a962]/10 rounded-full mb-4">
{t("badge")}
</span>
<h2 className="text-2xl font-bold text-gray-900 mb-2 leading-tight">
{t("title")}
</h2>
<p className="text-gray-600 text-sm leading-relaxed">
{t("subtitle")}
</p>
</div>
<div className="space-y-4 mb-6">
{t.raw("bullets").map((bullet: string, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-[#c9a962]/20 flex items-center justify-center mt-0.5">
<Check className="w-3 h-3 text-[#c9a962]" />
</div>
<p className="text-sm text-gray-700">{bullet}</p>
</motion.div>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="relative">
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={t("firstNamePlaceholder")}
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#c9a962]/50 focus:border-[#c9a962] transition-all text-gray-900 placeholder:text-gray-400"
/>
</div>
<div className="relative">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("emailPlaceholder")}
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#c9a962]/50 focus:border-[#c9a962] transition-all text-gray-900 placeholder:text-gray-400"
required
/>
</div>
<button
type="submit"
onClick={handleCTAClick}
disabled={isSubmitting}
className="w-full py-4 bg-gradient-to-r from-[#c9a962] to-[#e8c547] text-white font-semibold rounded-xl hover:shadow-lg hover:shadow-[#c9a962]/25 transition-all disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
{t("ctaButton")}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</form>
<p className="text-center text-xs text-gray-400 mt-4">
{t("privacyNote")}
</p>
</motion.div>
)}
{status === "success" && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-8"
>
<div className="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<Check className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{t("successTitle")}
</h3>
<p className="text-gray-600">{t("successMessage")}</p>
</motion.div>
)}
{status === "alreadySubscribed" && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-8"
>
<div className="w-16 h-16 mx-auto mb-4 bg-[#c9a962]/20 rounded-full flex items-center justify-center">
<Sparkles className="w-8 h-8 text-[#c9a962]" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{t("alreadySubscribedTitle")}
</h3>
<p className="text-gray-600">{t("alreadySubscribed")}</p>
</motion.div>
)}
{status === "error" && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-8"
>
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
<X className="w-8 h-8 text-red-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{t("errorTitle")}
</h3>
<p className="text-gray-600 mb-4">{t("errorMessage")}</p>
<button
onClick={() => setStatus("idle")}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm font-medium"
>
{t("tryAgain")}
</button>
</motion.div>
)}
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useScrollDepth } from "@/hooks/useScrollDepth";
import { useExitIntent } from "@/hooks/useExitIntent";
import { useVisitorStore } from "@/hooks/useVisitorStore";
import EmailCapturePopup from "./EmailCapturePopup";
import { useAnalytics } from "@/lib/analytics";
const SCROLL_POPUP_DELAY_MS = 5000;
export default function ExitIntentDetector() {
const params = useParams();
const locale = (params.locale as string) || "en";
const { trackPopupView } = useAnalytics();
const scrollTriggered = useScrollDepth(10);
const exitTriggered = useExitIntent();
const { canShowPopup, markPopupShown, markSubscribed } = useVisitorStore();
const [showPopup, setShowPopup] = useState(false);
const [trigger, setTrigger] = useState<"scroll" | "exit">("scroll");
const [country, setCountry] = useState("Unknown");
const [countryCode, setCountryCode] = useState("XX");
const [city, setCity] = useState("");
const [region, setRegion] = useState("");
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const fetchCountry = async () => {
try {
const response = await fetch("/api/geoip");
if (response.ok) {
const data = await response.json();
setCountry(data.country);
setCountryCode(data.countryCode);
setCity(data.city || "");
setRegion(data.region || "");
}
} catch (error) {
console.error("Failed to get country:", error);
}
setIsReady(true);
};
fetchCountry();
}, []);
useEffect(() => {
console.log("[ExitIntent] Scroll triggered:", scrollTriggered);
console.log("[ExitIntent] Exit triggered:", exitTriggered);
console.log("[ExitIntent] isReady:", isReady);
console.log("[ExitIntent] canShowPopup:", canShowPopup());
if (!isReady || !canShowPopup()) return;
let timer: NodeJS.Timeout;
if (scrollTriggered || exitTriggered) {
const newTrigger = exitTriggered ? "exit" : "scroll";
console.log("[ExitIntent] Trigger activated:", newTrigger);
setTrigger(newTrigger);
// Exit intent shows immediately, scroll has a delay
const delay = exitTriggered ? 0 : SCROLL_POPUP_DELAY_MS;
timer = setTimeout(() => {
console.log("[ExitIntent] Timer fired, checking canShowPopup again");
if (canShowPopup()) {
console.log("[ExitIntent] Showing popup!");
setShowPopup(true);
markPopupShown(newTrigger);
trackPopupView({ trigger: newTrigger, locale, country: countryCode });
}
}, delay);
}
return () => clearTimeout(timer);
}, [scrollTriggered, exitTriggered, isReady, canShowPopup, markPopupShown, trackPopupView, locale, countryCode]);
const handleClose = () => {
setShowPopup(false);
};
const handleSubscribe = () => {
markSubscribed();
};
if (!isReady) return null;
return (
<EmailCapturePopup
isOpen={showPopup}
onClose={handleClose}
onSubscribe={handleSubscribe}
trigger={trigger}
locale={locale}
country={country}
countryCode={countryCode}
/>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { ChevronDown } from "lucide-react";
@@ -23,30 +23,23 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
return (
<section className="relative min-h-screen w-full overflow-hidden">
{/* Background Image with Overlay */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop')`,
}}
>
<div className="absolute inset-0">
<Image
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop"
alt=""
fill
priority
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
</div>
{/* Content */}
{/* Content - Visible immediately, animations are enhancements */}
<div className="relative z-10 min-h-screen flex flex-col items-center justify-center text-center text-white px-4 py-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="max-w-4xl mx-auto"
>
<div className="max-w-4xl mx-auto animate-fadeSlideUp">
{/* Social Proof Micro */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="flex items-center justify-center gap-2 mb-6"
>
<div className="flex items-center justify-center gap-2 mb-6 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<svg key={star} className="w-4 h-4 fill-yellow-400 text-yellow-400" viewBox="0 0 24 24">
@@ -57,36 +50,30 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
<span className="text-sm text-white/80">
{t("lovedBy")}
</span>
</motion.div>
</div>
{/* Main Heading - Outcome Focused */}
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5 }}
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight"
{/* Main Heading */}
<h1
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight animate-fadeSlideUp"
style={{ animationDelay: "0.2s" }}
>
{t("transformHeadline")}
<br />
<span className="text-white/90">{t("withNaturalOils")}</span>
</motion.h1>
</h1>
{/* Subtitle - Expands on how */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.7 }}
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed"
{/* Subtitle */}
<p
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed animate-fadeSlideUp"
style={{ animationDelay: "0.3s" }}
>
{t("subtitleText")}
</motion.p>
</p>
{/* CTA Button - Action verb + value */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.9 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4"
{/* CTA Buttons */}
<div
className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fadeSlideUp"
style={{ animationDelay: "0.4s" }}
>
<Link
href={`${localePath}/products`}
@@ -100,14 +87,12 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
>
{t("learnStory")}
</Link>
</motion.div>
</div>
{/* Trust Indicators */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.2, duration: 0.8 }}
className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60"
<div
className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60 animate-fadeSlideUp"
style={{ animationDelay: "0.5s" }}
>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -127,26 +112,21 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
</svg>
<span>{t("crueltyFree")}</span>
</div>
</motion.div>
</motion.div>
</div>
</div>
</div>
{/* Scroll Indicator */}
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.5, duration: 0.8 }}
<button
onClick={scrollToContent}
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer"
className="absolute bottom-10 left-1/2 -translate-x-1/2 text-white/60 hover:text-white transition-colors cursor-pointer opacity-0 animate-fade-in"
style={{ animationDelay: "1.5s", animationFillMode: "forwards" }}
aria-label="Scroll to content"
>
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
>
<div className="scroll-indicator">
<ChevronDown className="w-6 h-6" strokeWidth={1.5} />
</motion.div>
</motion.button>
</div>
</button>
</section>
);
}
}

View File

@@ -1,22 +1,36 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { useEffect, useRef } from "react";
export default function ProblemSection() {
const t = useTranslations("ProblemSection");
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 (
<section className="py-24 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
<section ref={sectionRef} className="py-24 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
<div className="container mx-auto px-4">
<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 }}
>
<div className="max-w-3xl mx-auto text-center animate-on-scroll">
<span className="text-xs uppercase tracking-[0.3em] text-[#c9a962] mb-4 block font-medium">
{t("title")}
</span>
@@ -27,18 +41,14 @@ export default function ProblemSection() {
{t("description")}
</p>
<div className="w-16 h-1 bg-gradient-to-r from-[#c9a962] to-[#FFD700] mx-auto mt-8 rounded-full" />
</motion.div>
</div>
<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) => (
<motion.div
<div
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"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -5 }}
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"
style={{ animationDelay: `${index * 100}ms` }}
>
<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" />
@@ -61,10 +71,29 @@ export default function ProblemSection() {
</div>
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-3">{item.problem}</h3>
<p className="text-sm text-[#666666] leading-relaxed">{item.description}</p>
</motion.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>
);
}
}

View File

@@ -1,6 +1,5 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
export default function TrustBadges() {
@@ -9,21 +8,8 @@ export default function TrustBadges() {
return (
<section className="py-16 bg-gradient-to-b from-[#fefcfb] to-[#faf9f7]">
<div className="container mx-auto px-4">
<motion.div
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="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<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" }}>
<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">
<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" />
@@ -38,16 +24,9 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5">
{t("basedOnReviews")}
</p>
</motion.div>
</div>
<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="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" }}>
<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">
<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" />
@@ -62,16 +41,9 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5">
{t("worldwide")}
</p>
</motion.div>
</div>
<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="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" }}>
<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">
<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" />
@@ -86,19 +58,12 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5">
{t("noAdditives")}
</p>
</motion.div>
</div>
<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="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" }}>
<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">
<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" />
<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" />
</svg>
</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">
@@ -110,9 +75,26 @@ export default function TrustBadges() {
<p className="text-xs text-[#888888] mt-0.5">
{t("ordersOver")}
</p>
</motion.div>
</motion.div>
</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>
);
}
}

View File

@@ -32,11 +32,13 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? (
<img
<Image
src={image}
alt={localized.name}
className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
loading="lazy"
fill
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"
loading={index < 4 ? "eager" : "lazy"}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
@@ -52,7 +54,7 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
</div>
)}
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<div className="absolute inset-x-0 bottom-0 p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
onClick={(e) => {

View File

@@ -245,10 +245,12 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
: "border-transparent hover:border-[#999999]"
}`}
>
<img
<Image
src={image.url}
alt={image.alt || localized.name}
className="w-full h-full object-cover"
fill
className="object-cover"
sizes="100px"
/>
</button>
))}
@@ -256,10 +258,13 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
)}
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1">
<img
<Image
src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name}
className="w-full h-full object-cover"
fill
priority
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
{images.length > 1 && (
@@ -307,17 +312,15 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
transition={{ duration: 0.6, delay: 0.2 }}
className="lg:pl-8"
>
<motion.div
key={urgencyIndex}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 rounded-lg mb-4 text-sm font-medium text-left"
>
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
{urgencyMessages[urgencyIndex].text}
</motion.div>
<div className="min-h-[52px] flex items-center">
<div
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 px-4 rounded-lg mb-4 text-sm font-medium text-left w-full"
key={urgencyIndex}
>
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
{urgencyMessages[urgencyIndex].text}
</div>
</div>
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
{localized.name}

View File

@@ -0,0 +1,13 @@
"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 <></>;
}

View File

@@ -0,0 +1,62 @@
"use client";
import { ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X } from "lucide-react";
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
side?: "left" | "right";
width?: string;
}
export default function Drawer({
isOpen,
onClose,
children,
side = "left",
width = "max-w-[420px]",
}: DrawerProps) {
const slideAnimation = {
initial: { x: side === "left" ? "-100%" : "100%" },
animate: { x: 0 },
exit: { x: side === "left" ? "-100%" : "100%" },
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
/>
<motion.div
className={`fixed top-0 ${side}-0 bottom-0 ${width} w-full bg-white z-50 shadow-2xl`}
initial={slideAnimation.initial}
animate={slideAnimation.animate}
exit={slideAnimation.exit}
transition={{ type: "tween", duration: 0.3 }}
>
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full hover:bg-gray-100 transition-colors z-10"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-500" />
</button>
<div className="h-full overflow-y-auto">{children}</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
export function useExitIntent(): boolean {
const [showExitIntent, setShowExitIntent] = useState(false);
useEffect(() => {
const handleMouseLeave = (e: MouseEvent) => {
if (e.clientY <= 0) {
setShowExitIntent(true);
}
};
document.addEventListener("mouseleave", handleMouseLeave);
return () => document.removeEventListener("mouseleave", handleMouseLeave);
}, []);
return showExitIntent;
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
export function useScrollDepth(threshold: number = 20): boolean {
const [hasReachedThreshold, setHasReachedThreshold] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (hasReachedThreshold) return;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollPercent = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
if (scrollPercent >= threshold) {
setHasReachedThreshold(true);
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}, [threshold, hasReachedThreshold]);
return hasReachedThreshold;
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState, useCallback } from "react";
const STORAGE_KEY = "manoonoils-visitor";
const SESSION_DURATION_HOURS = 24;
interface VisitorState {
visitorId: string;
popupShown: boolean;
popupShownAt: number | null;
popupTrigger: "scroll" | "exit" | null;
subscribed: boolean;
}
export function useVisitorStore() {
const [state, setState] = useState<VisitorState>({
visitorId: "",
popupShown: false,
popupShownAt: null,
popupTrigger: null,
subscribed: false,
});
useEffect(() => {
// Check for reset flag in URL
if (typeof window !== 'undefined' && window.location.search.includes('reset-popup=true')) {
localStorage.removeItem(STORAGE_KEY);
console.log("[VisitorStore] Reset popup tracking");
}
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setState(parsed);
console.log("[VisitorStore] Loaded state:", parsed);
} else {
const newState: VisitorState = {
visitorId: generateVisitorId(),
popupShown: false,
popupShownAt: null,
popupTrigger: null,
subscribed: false,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
setState(newState);
console.log("[VisitorStore] Created new state:", newState);
}
}, []);
const canShowPopup = useCallback((): boolean => {
if (state.subscribed) {
console.log("[VisitorStore] canShowPopup: false (already subscribed)");
return false;
}
if (!state.popupShown || !state.popupShownAt) {
console.log("[VisitorStore] canShowPopup: true (never shown)");
return true;
}
const hoursPassed = (Date.now() - state.popupShownAt) / (1000 * 60 * 60);
const canShow = hoursPassed >= SESSION_DURATION_HOURS;
console.log("[VisitorStore] canShowPopup:", canShow, "hours passed:", hoursPassed);
return canShow;
}, [state.popupShown, state.popupShownAt, state.subscribed]);
const markPopupShown = useCallback((trigger: "scroll" | "exit") => {
const newState: VisitorState = {
...state,
popupShown: true,
popupShownAt: Date.now(),
popupTrigger: trigger,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
setState(newState);
}, [state]);
const markSubscribed = useCallback(() => {
const newState: VisitorState = {
...state,
subscribed: true,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
setState(newState);
console.log("[VisitorStore] Marked as subscribed");
}, [state]);
return {
visitorId: state.visitorId,
canShowPopup,
markPopupShown,
markSubscribed,
popupTrigger: state.popupTrigger,
};
}
function generateVisitorId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

View File

@@ -44,6 +44,28 @@
"sustainable": "Nachhaltig",
"sustainableDesc": "Ethnisch beschaffte Zutaten und umweltfreundliche Verpackungen für einen besseren Planeten."
},
"Popup": {
"badge": "KOSTENLOSER LEITFADEN",
"title": "Schließen Sie sich 15.000+ Frauen an, die Ihre Haut transformiert haben",
"subtitle": "Holen Sie sich unseren kostenlosen Leitfaden: Die Natürlichen Öl-Geheimnisse der Top-Experten",
"bullets": [
"Der Öl-Fehler Nr. 1, der Haare beschädigt (und die einfache Lösung)",
"3 Öle, die die Haut in 30 Tagen verjüngen",
"Die 'Morning Glow'-Routine, die Promis täglich nutzen",
"Die schwarze Liste der Inhaltsstoffe, die Sie NIE verwenden sollten"
],
"firstNamePlaceholder": "Geben Sie Ihren Vornamen ein",
"emailPlaceholder": "Ihre beste E-Mail-Adresse",
"ctaButton": "Senden Sie Mir Den Leitfaden »",
"privacyNote": "Kein Spam. Jederzeit abmelden.",
"successTitle": "Erfolg! Prüfen Sie jetzt Ihren Posteingang!",
"successMessage": "Der Leitfaden wurde gesendet! Prüfen Sie Ihre E-Mails (und Spam-Ordner).",
"alreadySubscribedTitle": "Sie sind bereits dabei!",
"alreadySubscribed": "Sie sind bereits dabei! Prüfen Sie Ihre E-Mails für den Leitfaden.",
"errorTitle": "Etwas ist schief gelaufen",
"errorMessage": "Wir konnten den Leitfaden nicht senden. Bitte versuchen Sie es erneut.",
"tryAgain": "Erneut versuchen"
},
"Products": {
"collection": "Unsere Kollektion",
"allProducts": "Alle Produkte",

View File

@@ -44,6 +44,28 @@
"sustainable": "Sustainable",
"sustainableDesc": "Ethically sourced ingredients and eco-friendly packaging for a better planet."
},
"Popup": {
"badge": "FREE GUIDE",
"title": "Join 15,000+ Women Who Transformed Their Skin",
"subtitle": "Get Our Free Guide: The Natural Oil Secrets Top Beauty Experts Swear By",
"bullets": [
"The #1 oil mistake that damages hair (and the simple fix)",
"3 oils that reverse aging skin in 30 days",
"The 'morning glow' routine celebrities use daily",
"The ingredient blacklist you should NEVER use"
],
"firstNamePlaceholder": "Enter your first name",
"emailPlaceholder": "Enter your email",
"ctaButton": "Send Me The Free Guide »",
"privacyNote": "No spam. Unsubscribe anytime.",
"successTitle": "Success! Check your inbox now!",
"successMessage": "The guide has been sent! Check your email (and spam folder) for your free guide.",
"alreadySubscribedTitle": "You're already a member!",
"alreadySubscribed": "You're already in! Check your email for the guide.",
"errorTitle": "Something went wrong",
"errorMessage": "We couldn't send the guide. Please try again.",
"tryAgain": "Try again"
},
"Products": {
"collection": "Our Collection",
"allProducts": "All Products",
@@ -464,5 +486,13 @@
"description": "Pay via bank transfer",
"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."
}
}

View File

@@ -44,6 +44,28 @@
"sustainable": "Durable",
"sustainableDesc": "Ingrédients sourcés éthiquement et emballage écologique pour une meilleure planète."
},
"Popup": {
"badge": "GUIDE GRATUIT",
"title": "Rejoignez 15 000+ femmes qui ont transformé leur peau",
"subtitle": "Téléchargez notre guide gratuit: Les Secrets des Huiles Naturelles des Meilleurs Experts",
"bullets": [
"L'erreur huile n°1 qui abîme les cheveux (et la solution simple)",
"3 huiles qui rajeunissent la peau en 30 jours",
"La routine 'éclat du matin' utilisée par les célébrités",
"La liste noire des ingrédients que vous ne devez JAMAIS utiliser"
],
"firstNamePlaceholder": "Entrez votre prénom",
"emailPlaceholder": "Votre meilleure adresse email",
"ctaButton": "Envoyez-Moi Le Guide Gratuit »",
"privacyNote": "Pas de spam. Désabonnez-vous à tout moment.",
"successTitle": "Succès! Vérifiez votre boîte de réception maintenant!",
"successMessage": "Le guide a été envoyé! Vérifiez vos emails (et dossier spam).",
"alreadySubscribedTitle": "Vous êtes déjà inscrit!",
"alreadySubscribed": "Vous êtes déjà inscrit! Vérifiez vos emails pour le guide.",
"errorTitle": "Quelque chose s'est mal passé",
"errorMessage": "Nous n'avons pas pu envoyer le guide. Veuillez réessayer.",
"tryAgain": "Réessayer"
},
"Products": {
"collection": "Notre Collection",
"allProducts": "Tous Les Produits",

View File

@@ -44,6 +44,28 @@
"sustainable": "Održivo",
"sustainableDesc": "Etički nabavljeni sastojci i ekološka ambalaža za bolju planetu."
},
"Popup": {
"badge": "BESPLATAN VODIČ",
"title": "Pridružite se 15.000+ žena koje su transformisale svoju kožu",
"subtitle": "Preuzmite besplatan vodič: Tajne prirodnih ulja koje koriste najbolji eksperti",
"bullets": [
"Greška br. 1 sa uljima koja uništava kosu (i jednostavno rešenje)",
"3 ulja koja podmlađuju kožu za 30 dana",
"Rutinu 'jutarnjeg sjaja' koju koriste poznati",
"Listu sastojaka koje NIKADA ne smete koristiti"
],
"firstNamePlaceholder": "Unesite vaše ime",
"emailPlaceholder": "Unesite vaš email",
"ctaButton": "Pošaljite Mi Vodič »",
"privacyNote": "Bez spama. Odjavite se bilo kada.",
"successTitle": "Uspeh! Proverite vaš inbox!",
"successMessage": "Vodič je poslat! Proverite vaš email (i spam folder).",
"alreadySubscribedTitle": "Već ste član!",
"alreadySubscribed": "Već ste u bazi! Proverite email za vodič.",
"errorTitle": "Došlo je do greške",
"errorMessage": "Nismo mogli da pošaljemo vodič. Molimo pokušajte ponovo.",
"tryAgain": "Pokušajte ponovo"
},
"Products": {
"collection": "Naša kolekcija",
"allProducts": "Svi proizvodi",
@@ -463,5 +485,13 @@
"description": "Platite putem bankovnog transfera",
"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ć."
}
}

View File

@@ -1,6 +1,5 @@
"use client";
import { useOpenPanel } from "@openpanel/nextjs";
import { useCallback } from "react";
import {
trackRybbitProductView,
@@ -16,109 +15,10 @@ import {
trackRybbitUserLogin,
trackRybbitUserRegister,
trackRybbitNewsletterSignup,
trackRybbitEvent,
} from "@/lib/services/RybbitService";
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: {
id: string;
name: string;
@@ -126,15 +26,14 @@ export function useAnalytics() {
currency: string;
category?: string;
}) => {
trackDual("product_viewed", {
product_id: product.id,
product_name: product.name,
trackRybbitProductView({
id: product.id,
name: product.name,
price: product.price,
currency: product.currency,
category: product.category,
source: "client",
});
}, [trackDual]);
}, []);
const trackAddToCart = useCallback((product: {
id: string;
@@ -144,42 +43,39 @@ export function useAnalytics() {
quantity: number;
variant?: string;
}) => {
trackDual("add_to_cart", {
product_id: product.id,
product_name: product.name,
trackRybbitAddToCart({
id: product.id,
name: product.name,
price: product.price,
currency: product.currency,
quantity: product.quantity,
variant: product.variant,
source: "client",
});
}, [trackDual]);
}, []);
const trackRemoveFromCart = useCallback((product: {
id: string;
name: string;
quantity: number;
}) => {
trackDual("remove_from_cart", {
product_id: product.id,
product_name: product.name,
trackRybbitRemoveFromCart({
id: product.id,
name: product.name,
quantity: product.quantity,
source: "client",
});
}, [trackDual]);
}, []);
const trackCartView = useCallback((cart: {
total: number;
currency: string;
item_count: number;
}) => {
trackDual("cart_view", {
cart_total: cart.total,
trackRybbitCartView({
total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
source: "client",
});
}, [trackDual]);
}, []);
const trackCheckoutStarted = useCallback((cart: {
total: number;
@@ -192,22 +88,17 @@ export function useAnalytics() {
price: number;
}>;
}) => {
trackDual("checkout_started", {
cart_total: cart.total,
trackRybbitCheckoutStarted({
total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
items: cart.items,
source: "client",
});
}, [trackDual]);
}, []);
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
trackDual("checkout_step", {
step,
...data,
source: "client",
});
}, [trackDual]);
trackRybbitCheckoutStep(step, data);
}, []);
const trackOrderCompleted = useCallback(async (order: {
order_id: string;
@@ -221,8 +112,8 @@ export function useAnalytics() {
}) => {
console.log("[Analytics] Tracking order:", order.order_number);
// Track with both OpenPanel and Rybbit
trackDual("order_completed", {
// Rybbit tracking
trackRybbitOrderCompleted({
order_id: order.order_id,
order_number: order.order_number,
total: order.total,
@@ -231,20 +122,8 @@ export function useAnalytics() {
shipping_cost: order.shipping_cost,
customer_email: order.customer_email,
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
try {
const response = await fetch("/api/analytics/track-order", {
@@ -268,73 +147,65 @@ export function useAnalytics() {
} catch (e) {
console.error("[Server Analytics] API call failed:", e);
}
}, [op, trackDual]);
}, []);
const trackSearch = useCallback((query: string, results_count: number) => {
trackDual("search", {
query,
results_count,
source: "client",
});
}, [trackDual]);
trackRybbitSearch(query, results_count);
}, []);
const trackExternalLink = useCallback((url: string, label?: string) => {
trackDual("external_link_click", {
url,
label,
source: "client",
});
}, [trackDual]);
trackRybbitExternalLink(url, label);
}, []);
const trackWishlistAdd = useCallback((product: {
id: string;
name: string;
}) => {
trackDual("wishlist_add", {
product_id: product.id,
product_name: product.name,
source: "client",
trackRybbitWishlistAdd({
id: product.id,
name: product.name,
});
}, [trackDual]);
}, []);
const trackUserLogin = useCallback((method: string) => {
trackDual("user_login", {
method,
source: "client",
});
}, [trackDual]);
trackRybbitUserLogin(method);
}, []);
const trackUserRegister = useCallback((method: string) => {
trackDual("user_register", {
method,
source: "client",
});
}, [trackDual]);
trackRybbitUserRegister(method);
}, []);
const trackNewsletterSignup = useCallback((email: string, source: string) => {
trackDual("newsletter_signup", {
email,
source,
});
}, [trackDual]);
trackRybbitNewsletterSignup(email, source);
}, []);
const identifyUser = useCallback((user: {
// Popup tracking functions
const trackPopupView = useCallback((data: { trigger: string; locale: string; country?: string }) => {
trackRybbitEvent("popup_view", data);
}, []);
const trackPopupSubmit = useCallback((data: { trigger: string; locale: string; country?: string }) => {
trackRybbitEvent("popup_submit", data);
}, []);
const trackPopupCtaClick = useCallback((data: { locale: string }) => {
trackRybbitEvent("popup_cta_click", data);
}, []);
const trackPopupDismiss = useCallback((data: { trigger: string; locale: string }) => {
trackRybbitEvent("popup_dismiss", data);
}, []);
// No-op placeholder for identifyUser (OpenPanel removed)
const identifyUser = useCallback((_user: {
profileId: string;
email?: string;
firstName?: string;
lastName?: string;
}) => {
try {
op.identify({
profileId: user.profileId,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
});
} catch (e) {
console.error("[OpenPanel] Identify error:", e);
}
}, [op]);
// OpenPanel was removed - this is now a no-op
// User identification is handled by Rybbit automatically via cookies
}, []);
return {
trackProductView,
@@ -350,6 +221,10 @@ export function useAnalytics() {
trackUserLogin,
trackUserRegister,
trackNewsletterSignup,
trackPopupView,
trackPopupSubmit,
trackPopupCtaClick,
trackPopupDismiss,
identifyUser,
};
}

View File

@@ -11,30 +11,104 @@ declare global {
}
}
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 trackEvent(eventName: string, properties?: Record<string, unknown>): void {
if (!this.isAvailable()) {
console.warn(`[Rybbit] Not available for event: ${eventName}`);
return;
}
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":

19
src/lib/geoip.ts Normal file
View File

@@ -0,0 +1,19 @@
interface GeoIPResponse {
country: string;
countryCode: string;
}
export async function getCountryFromIP(): Promise<GeoIPResponse> {
try {
const response = await fetch("/api/geoip");
if (!response.ok) {
throw new Error("Failed to get country");
}
return await response.json();
} catch (error) {
return {
country: "Unknown",
countryCode: "XX",
};
}
}

120
src/lib/mautic.ts Normal file
View File

@@ -0,0 +1,120 @@
interface MauticToken {
access_token: string;
expires_in: number;
token_type: string;
}
let cachedToken: MauticToken | null = null;
let tokenExpiresAt: number = 0;
async function getMauticToken(): Promise<string> {
if (cachedToken && Date.now() < tokenExpiresAt - 60000) {
return cachedToken.access_token;
}
const clientId = process.env.MAUTIC_CLIENT_ID;
const clientSecret = process.env.MAUTIC_CLIENT_SECRET;
const apiUrl = process.env.MAUTIC_API_URL || "https://mautic.nodecrew.me";
if (!clientId || !clientSecret) {
throw new Error("Mautic credentials not configured");
}
const response = await fetch(`${apiUrl}/oauth/v2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Mautic token error:", response.status, errorText);
throw new Error(`Failed to get Mautic token: ${response.status} - ${errorText}`);
}
const token: MauticToken = await response.json();
cachedToken = token;
tokenExpiresAt = Date.now() + token.expires_in * 1000;
return token.access_token;
}
export async function createMauticContact(
email: string,
tags: string[],
additionalData?: {
firstName?: string;
lastName?: string;
country?: string;
city?: string;
phone?: string;
website?: string;
preferredLocale?: string;
ipAddress?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmContent?: string;
pageUrl?: string;
}
): Promise<{ success: boolean; alreadyExists?: boolean; contactId?: number }> {
try {
const token = await getMauticToken();
const apiUrl = process.env.MAUTIC_API_URL || "https://mautic.nodecrew.me";
const payload: any = {
email,
tags: tags.join(","),
};
if (additionalData) {
if (additionalData.firstName) payload.firstname = additionalData.firstName;
if (additionalData.lastName) payload.lastname = additionalData.lastName;
if (additionalData.country) payload.country = additionalData.country;
if (additionalData.city) payload.city = additionalData.city;
if (additionalData.phone) payload.phone = additionalData.phone;
if (additionalData.preferredLocale) payload.preferred_locale = additionalData.preferredLocale;
if (additionalData.utmSource) payload.utm_source = additionalData.utmSource;
if (additionalData.utmMedium) payload.utm_medium = additionalData.utmMedium;
if (additionalData.utmCampaign) payload.utm_campaign = additionalData.utmCampaign;
if (additionalData.utmContent) payload.utm_content = additionalData.utmContent;
if (additionalData.pageUrl) payload.page_url = additionalData.pageUrl;
}
const response = await fetch(`${apiUrl}/api/contacts/new`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (response.status === 409) {
return { success: true, alreadyExists: true };
}
if (!response.ok) {
const errorText = await response.text();
console.error("Mautic API error:", response.status, errorText);
throw new Error(`Mautic API error: ${response.status} - ${errorText}`);
}
const responseData = await response.json();
console.log("Mautic API success:", responseData);
return {
success: true,
contactId: responseData.contact?.id
};
} catch (error) {
console.error("Mautic contact creation failed:", error);
throw error;
}
}

35
src/middleware.ts Normal file
View File

@@ -0,0 +1,35 @@
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).*)",
],
};