Compare commits

...

38 Commits

Author SHA1 Message Date
Unchained
57bae7ed6f fix: canonical URLs to always include locale prefix
Fixed Canonical Redirect Error in Google Search Console by ensuring
canonical URLs always include the locale prefix (/sr, /en, /de, /fr)
instead of omitting it for the default locale.

Changes:
- layout.tsx: Fixed canonical and hreflang URL generation
- page.tsx: Fixed homepage canonical URL
- contact/page.tsx: Fixed contact page canonical URL
- about/page.tsx: Fixed about page canonical URL
- products/page.tsx: Fixed products page canonical URL
- products/[slug]/page.tsx: Fixed product detail canonical URL

Before: /contact (when locale=sr)
After: /sr/contact (all locales)

Fixes: Canonical Redirect Error in Google Search Console
2026-04-06 15:32:24 +02:00
Unchained
2097280f20 fix: force no-cache build
All checks were successful
Build and Deploy / build (push) Successful in 1s
2026-04-05 13:05:28 +02:00
Unchained
bea6aba014 fix: simplify workflow with proper build args
All checks were successful
Build and Deploy / build (push) Successful in 0s
2026-04-05 13:02:05 +02:00
Unchained
8454ffc5b3 test: trigger build with args
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-05 12:59:49 +02:00
Unchained
38defdfb9b chore: remove test workflow
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-05 12:59:13 +02:00
Flux CD
9c04dffa46 fix: add missing build args to workflow 2026-04-05 10:58:54 +00:00
Unchained
bd1fa0d96a test: add no-cache build workflow
All checks were successful
Build and Deploy / build (push) Successful in 59s
2026-04-05 12:53:44 +02:00
Unchained
826d1ebb46 trigger: rebuild with correct env vars
All checks were successful
Build and Deploy / build (push) Successful in 59s
Previous build had localhost:8000 hardcoded.
This rebuild uses the fixed Dockerfile with build args.
2026-04-05 12:30:07 +02:00
Unchained
09b0614695 fix: remove kubectl annotate from build workflow
All checks were successful
Build and Deploy / build (push) Successful in 59s
- Remove kubectl command that was causing build failures
- Flux will auto-detect new image within 5 minutes via polling
- Simpler, more reliable build process
2026-04-05 12:03:27 +02:00
Unchained
7c7611b723 fix: simplify build workflow YAML syntax
All checks were successful
Build and Deploy / build (push) Successful in 10m0s
- Use 'command' and 'args' instead of multiline command
- Use quoted heredoc delimiter to prevent variable expansion
- Simplify clone and build scripts
2026-04-05 11:50:23 +02:00
Unchained
6563f0c966 fix: use full cluster DNS for gitea service
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Change gitea URL from http://gitea:3000 to http://gitea.gitea.svc.cluster.local:3000
- Add set -x for debugging
- Add explicit clone exit code checking
2026-04-05 11:43:57 +02:00
Unchained
cdbcd8424b fix: improve git clone error handling in build workflow
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-04-05 11:39:39 +02:00
Unchained
05b2c26634 fix: correct syntax errors in build workflow
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Fix unclosed quote on line 13
- Remove malformed git checkout command on line 39
2026-04-05 11:35:01 +02:00
Unchained
bdc35ff2b4 fix: remove quotes from date command in build workflow
Some checks failed
Build and Deploy / build (push) Failing after 0s
2026-04-05 11:31:59 +02:00
Unchained
d53665d6da build: pass env vars as build args to fix localhost:8000 error
Some checks failed
Build and Deploy / build (push) Failing after 0s
- Add ARG and ENV directives to Dockerfile for NEXT_PUBLIC_* vars
- Pass build args in buildctl command with --opt build-arg
- Fixes ERR_BLOCKED_BY_CLIENT on localhost:8000/graphql
2026-04-05 11:24:01 +02:00
Flux CD
80da03504c ci: add Gitea Actions workflow with BuildKit 2026-04-05 06:16:10 +00:00
Unchained
328bbbaaa2 ci: add Gitea Actions workflow with BuildKit
Some checks failed
Build and Deploy / build (push) Failing after 23m52s
2026-04-05 08:13:55 +02:00
Flux CD
37d1894ad4 fix: remove image transformer, use deployment image directly 2026-04-05 05:10:32 +00:00
Flux CD
6236092d77 feat: add image policy setter marker for Flux automation 2026-04-05 05:07:17 +00:00
Flux CD
61b20beffa feat: switch to pre-built GHCR image using BuildKit 2026-04-05 05:02:51 +00:00
Unchained
29894cd555 chore: trigger Gitea Actions build
Some checks failed
Build and Push to GHCR / build (push) Failing after 4m0s
2026-04-05 06:32:05 +02:00
Unchained
c80970bcda feat(ci): add Gitea Actions workflow for building and pushing to GHCR
Some checks failed
Build and Push to GHCR / build (push) Has been cancelled
Add .gitea/workflows/build.yaml that:
- Builds Docker image on push to master
- Pushes to ghcr.io/unchainedio/manoon-headless
- Tags with commit SHA and 'latest'
- Updates k8s/kustomization.yaml with new image tag
- Commits and pushes the tag update back to repo

Requires Gitea Actions runner to be configured.
2026-04-05 06:24:36 +02:00
Unchained
1dec08f857 Revert to working deployment while GHCR image builds
Some checks failed
Build and Deploy / build (push) Failing after 12m23s
Will re-apply pre-built image once GitHub Actions successfully
pushes image to ghcr.io/unchainedio/manoon-headless
2026-04-05 06:15:54 +02:00
Unchained
cc33d317ba fix(k8s): use latest tag for manoon-headless image
Some checks failed
Build and Deploy / build (push) Has been cancelled
Temporary fix until GitHub Actions builds and pushes the image.
Workflow will update to specific SHA on next push.
2026-04-05 06:12:44 +02:00
Unchained
3c495f48b7 refactor(k8s): use pre-built GHCR image instead of building in pod
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Remove init containers (clone, install, build)
- Use ghcr.io/unchainedio/manoon-headless:latest image
- Faster pod startup, less resource usage
- Image built by GitHub Actions on push to master
2026-04-05 06:09:55 +02:00
Unchained
a636d29f0b fix(k8s): handle existing workspace on pod restart
Some checks failed
Build and Deploy / build (push) Has been cancelled
The clone init container was failing with 'destination path already exists'
when the pod restarted. EmptyDir volumes persist across container restarts
but init containers run again.

Now checks if workspace exists:
- If .git directory exists: fetch and reset to latest master
- If not: clean and clone fresh

This fixes the CrashLoopBackOff caused by failed clone attempts.
2026-04-05 05:17:30 +02:00
Unchained
6caefb420a docs: add OpenCode project memory for git workflow
Some checks failed
Build and Deploy / build (push) Has been cancelled
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
34 changed files with 1422 additions and 127 deletions

View File

@@ -0,0 +1,68 @@
name: Build and Deploy
on:
push:
branches: [master, main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Trigger BuildKit Build
run: |
kubectl delete job build-manoon-headless-action -n gitea --ignore-not-found=true 2>/dev/null || true
cat << 'JOBEOF' | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: build-manoon-headless-action
namespace: gitea
spec:
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
initContainers:
- name: clone
image: alpine/git:latest
command: ["sh", "-c"]
args:
- git clone --depth 1 http://gitea:3000/unchained/manoon-headless.git /workspace
volumeMounts:
- name: workspace
mountPath: /workspace
containers:
- name: build
image: moby/buildkit:latest
command: ["sh", "-c"]
args:
- |
mkdir -p /root/.docker
cp /docker-config/.dockerconfigjson /root/.docker/config.json
buildctl --addr tcp://buildkit.gitea.svc.cluster.local:1234 build \
--frontend dockerfile.v0 \
--local context=/workspace \
--local dockerfile=/workspace \
--opt build-arg:NEXT_PUBLIC_SALEOR_API_URL=https://api.manoonoils.com/graphql/ \
--opt build-arg:NEXT_PUBLIC_SITE_URL=https://manoonoils.com \
--opt build-arg:NEXT_PUBLIC_OPENPANEL_CLIENT_ID=fa61f8ae-0b5d-4187-a9b1-5a04b0025674 \
--opt build-arg:NEXT_PUBLIC_RYBBIT_HOST=https://rybbit.nodecrew.me \
--opt build-arg:NEXT_PUBLIC_RYBBIT_SITE_ID=1 \
--no-cache \
--output type=image,name=ghcr.io/unchainedio/manoon-headless:latest,push=true
volumeMounts:
- name: workspace
mountPath: /workspace
- name: docker-config
mountPath: /docker-config
readOnly: true
volumes:
- name: workspace
emptyDir: {}
- name: docker-config
secret:
secretName: ghcr-pull-secret
JOBEOF
echo "Build triggered!"

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

@@ -1,17 +1,25 @@
# Multi-stage build for Next.js
FROM node:20-slim AS builder FROM node:20-slim AS builder
WORKDIR /app WORKDIR /app
# Copy package files ARG NEXT_PUBLIC_SALEOR_API_URL
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_OPENPANEL_CLIENT_ID
ARG NEXT_PUBLIC_RYBBIT_HOST
ARG NEXT_PUBLIC_RYBBIT_SITE_ID
ENV NEXT_PUBLIC_SALEOR_API_URL=${NEXT_PUBLIC_SALEOR_API_URL}
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_OPENPANEL_CLIENT_ID=${NEXT_PUBLIC_OPENPANEL_CLIENT_ID}
ENV NEXT_PUBLIC_RYBBIT_HOST=${NEXT_PUBLIC_RYBBIT_HOST}
ENV NEXT_PUBLIC_RYBBIT_SITE_ID=${NEXT_PUBLIC_RYBBIT_SITE_ID}
COPY package*.json ./ COPY package*.json ./
RUN npm install --prefer-offline --no-audit RUN npm install --prefer-offline --no-audit
# Copy source and build
COPY . . COPY . .
RUN npm run build RUN npm run build
# Production stage
FROM node:20-slim AS runner FROM node:20-slim AS runner
WORKDIR /app WORKDIR /app
@@ -20,7 +28,6 @@ ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
# Copy necessary files from builder
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static

View File

@@ -39,3 +39,6 @@ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/bui
// Auto-deploy test: 2026-03-07T09:02:49Z // Auto-deploy test: 2026-03-07T09:02:49Z
// Auto-deploy test: 2026-03-07T10:33:23Z // Auto-deploy test: 2026-03-07T10:33:23Z
// Auto-deploy test 2: 2026-03-07T10:37:05Z // Auto-deploy test 2: 2026-03-07T10:37:05Z
# Trigger build Sun Apr 5 06:32:05 AM EET 2026
# Trigger build with env vars Sun Apr 5 08:45:00 AM EET 2026
# Build test Sun Apr 5 12:59:49 PM EET 2026

View File

@@ -13,96 +13,16 @@ spec:
labels: labels:
app: storefront app: storefront
spec: spec:
initContainers: imagePullSecrets:
- name: clone - name: ghcr-pull-secret
image: alpine/git:latest
command:
- sh
- -c
- |
set -e
apk add --no-cache git
git clone --depth 1 --branch master \
http://gitea.gitea.svc.cluster.local:3000/unchained/manoon-headless.git \
/workspace
echo "Clone complete."
volumeMounts:
- name: workspace
mountPath: /workspace
securityContext:
runAsUser: 0
resources:
limits:
cpu: 500m
memory: 256Mi
- name: install
image: node:20-slim
workingDir: /workspace
command:
- sh
- -c
- |
set -e
echo "Installing dependencies..."
npm install --prefer-offline --no-audit 2>&1
echo "Dependencies installed."
volumeMounts:
- name: workspace
mountPath: /workspace
securityContext:
runAsUser: 0
resources:
limits:
cpu: 2000m
memory: 3Gi
requests:
cpu: 100m
memory: 1Gi
- name: build
image: node:20-slim
workingDir: /workspace
command:
- sh
- -c
- |
set -e
echo "Building Next.js app..."
npm run build
echo "Build complete!"
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_SALEOR_API_URL
value: "https://api.manoonoils.com/graphql/"
- name: NEXT_PUBLIC_SITE_URL
value: "https://manoonoils.com"
- name: DASHBOARD_URL
value: "https://dashboard.manoonoils.com"
- name: NEXT_PUBLIC_OPENPANEL_CLIENT_ID
value: "fa61f8ae-0b5d-4187-a9b1-5a04b0025674"
- name: OPENPANEL_CLIENT_SECRET
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
- name: OPENPANEL_API_URL
value: "https://op.nodecrew.me/api"
volumeMounts:
- name: workspace
mountPath: /workspace
securityContext:
runAsUser: 0
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 100m
memory: 512Mi
containers: containers:
- name: storefront - name: storefront
image: node:20-slim image: ghcr.io/unchainedio/manoon-headless:latest # {"": "flux-system:manoon-headless"}
workingDir: /workspace imagePullPolicy: Always
command: command:
- npm - node
- start - server.js
workingDir: /app
ports: ports:
- containerPort: 3000 - containerPort: 3000
env: env:
@@ -132,6 +52,12 @@ spec:
value: "1" value: "1"
- name: RYBBIT_API_KEY - name: RYBBIT_API_KEY
value: "rb_NgFoMtHeohWoJULLiKqSEJmdghSrhJajgseSWQLjfxyeUJcFfQvUrfYwdllSTsLx" value: "rb_NgFoMtHeohWoJULLiKqSEJmdghSrhJajgseSWQLjfxyeUJcFfQvUrfYwdllSTsLx"
- name: MAUTIC_CLIENT_ID
value: "2_23cgmaqef8kgg8oo4kggc0w4wccwoss8o8w48o8sc40cowgkkg"
- name: MAUTIC_CLIENT_SECRET
value: "4k8367ab306co48c4c8g8sco8cgcwwww044gwccs0o0c8w4gco"
- name: MAUTIC_API_URL
value: "https://mautic.nodecrew.me"
resources: resources:
limits: limits:
cpu: 500m cpu: 500m
@@ -157,10 +83,3 @@ spec:
port: 3000 port: 3000
periodSeconds: 5 periodSeconds: 5
failureThreshold: 3 failureThreshold: 3
volumeMounts:
- name: workspace
mountPath: /workspace
volumes:
- name: workspace
emptyDir:
sizeLimit: 2Gi

View File

@@ -6,10 +6,26 @@ metadata:
spec: spec:
entryPoints: entryPoints:
- web - web
routes:
- 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 - websecure
routes: routes:
- match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`) - kind: Rule
kind: Rule match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
services: services:
- name: storefront - name: storefront
port: 3000 port: 3000

View File

@@ -3,7 +3,5 @@ kind: Kustomization
resources: resources:
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- middleware.yaml
- ingress.yaml - ingress.yaml
images:
- name: ghcr.io/unchainedio/manoon-headless
newTag: 2c27fc6 # Updated by GitHub Actions

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 name: storefront
namespace: manoonoils namespace: manoonoils
spec: spec:
# Use NodePort with externalTrafficPolicy: Local to preserve client source IP
# This is required for proper client IP detection in analytics (Rybbit, etc.)
type: NodePort
externalTrafficPolicy: Local
selector: selector:
app: storefront app: storefront
ports: ports:
- port: 3000 - port: 3000
targetPort: 3000 targetPort: 3000
type: ClusterIP # Let Kubernetes assign a NodePort automatically

View File

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

View File

@@ -19,7 +19,7 @@ export async function generateMetadata({ params }: AboutPageProps): Promise<Meta
const metadata = getPageMetadata(validLocale as Locale); const metadata = getPageMetadata(validLocale as Locale);
const keywords = getPageKeywords(validLocale as Locale, 'about'); const keywords = getPageKeywords(validLocale as Locale, 'about');
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`; const localePrefix = `/${validLocale}`;
const canonicalUrl = `${baseUrl}${localePrefix}/about`; const canonicalUrl = `${baseUrl}${localePrefix}/about`;
return { return {

View File

@@ -16,7 +16,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
const metadata = getPageMetadata(validLocale as Locale); const metadata = getPageMetadata(validLocale as Locale);
const keywords = getPageKeywords(validLocale as Locale, 'contact'); const keywords = getPageKeywords(validLocale as Locale, 'contact');
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`; const localePrefix = `/${validLocale}`;
const canonicalUrl = `${baseUrl}${localePrefix}/contact`; const canonicalUrl = `${baseUrl}${localePrefix}/contact`;
return { return {

View File

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

View File

@@ -29,8 +29,8 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
setRequestLocale(validLocale); setRequestLocale(validLocale);
// Build canonical URL // Build canonical URL
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`; const localePrefix = `/${validLocale}`;
const canonicalUrl = `${baseUrl}${localePrefix || '/'}`; const canonicalUrl = `${baseUrl}${localePrefix}`;
return { return {
title: metadata.home.title, title: metadata.home.title,

View File

@@ -57,7 +57,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
const secondaryKeywords = keywords.secondary.map(replaceTemplate); const secondaryKeywords = keywords.secondary.map(replaceTemplate);
// Build canonical URL // Build canonical URL
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`; const localePrefix = `/${validLocale}`;
const canonicalUrl = `${baseUrl}${localePrefix}/products/${slug}`; const canonicalUrl = `${baseUrl}${localePrefix}/products/${slug}`;
// Get product image for OpenGraph // Get product image for OpenGraph

View File

@@ -24,7 +24,7 @@ export async function generateMetadata({ params }: ProductsPageProps): Promise<M
const keywords = getPageKeywords(validLocale as Locale, 'products'); const keywords = getPageKeywords(validLocale as Locale, 'products');
// Build canonical URL // Build canonical URL
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`; const localePrefix = `/${validLocale}`;
const canonicalUrl = `${baseUrl}${localePrefix}/products`; const canonicalUrl = `${baseUrl}${localePrefix}/products`;
return { return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,5 +9,5 @@ interface AnalyticsProviderProps {
export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) { export default function AnalyticsProvider({ clientId }: AnalyticsProviderProps) {
// No-op component - Rybbit is loaded via next/script in layout.tsx // No-op component - Rybbit is loaded via next/script in layout.tsx
return null; return <></>;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
trackRybbitUserLogin, trackRybbitUserLogin,
trackRybbitUserRegister, trackRybbitUserRegister,
trackRybbitNewsletterSignup, trackRybbitNewsletterSignup,
trackRybbitEvent,
} from "@/lib/services/RybbitService"; } from "@/lib/services/RybbitService";
export function useAnalytics() { export function useAnalytics() {
@@ -178,6 +179,23 @@ export function useAnalytics() {
trackRybbitNewsletterSignup(email, source); trackRybbitNewsletterSignup(email, source);
}, []); }, []);
// Popup tracking functions
const trackPopupView = useCallback((data: { trigger: string; locale: string; country?: string }) => {
trackRybbitEvent("popup_view", data);
}, []);
const trackPopupSubmit = useCallback((data: { trigger: string; locale: string; country?: string }) => {
trackRybbitEvent("popup_submit", data);
}, []);
const trackPopupCtaClick = useCallback((data: { locale: string }) => {
trackRybbitEvent("popup_cta_click", data);
}, []);
const trackPopupDismiss = useCallback((data: { trigger: string; locale: string }) => {
trackRybbitEvent("popup_dismiss", data);
}, []);
// No-op placeholder for identifyUser (OpenPanel removed) // No-op placeholder for identifyUser (OpenPanel removed)
const identifyUser = useCallback((_user: { const identifyUser = useCallback((_user: {
profileId: string; profileId: string;
@@ -203,6 +221,10 @@ export function useAnalytics() {
trackUserLogin, trackUserLogin,
trackUserRegister, trackUserRegister,
trackNewsletterSignup, trackNewsletterSignup,
trackPopupView,
trackPopupSubmit,
trackPopupCtaClick,
trackPopupDismiss,
identifyUser, identifyUser,
}; };
} }

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

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

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

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