Compare commits

..

51 Commits

Author SHA1 Message Date
Flux CD
cca6f44139 Merge branch 'master' into feature/programmatic-seo 2026-04-05 11:25:22 +00: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
f6cdcd86df merge: integrate latest master with Gitea Actions CI/CD 2026-04-05 06:20:16 +00: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
Unchained
6a05abc6de ci: simplify Gitea Actions workflow to use BuildKit 2026-04-05 08:12:24 +02:00
Flux CD
9058002f8d merge: integrate master deployment changes (keep BuildKit setup) 2026-04-05 05:49:31 +00: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
590b6ca6ea fix(k8s): handle existing workspace on pod restart
Apply same fix from master branch:
- Check if workspace exists before cloning
- Fetch and reset if .git directory exists
- Clean and clone fresh if not

Prevents CrashLoopBackOff on pod restarts.
2026-04-05 05:33:17 +02:00
Unchained
f6609f07d7 feat: implement programmatic SEO solutions hub
- Add /solutions hub page with 10 category cards
- Add /solutions/by-concern directory page
- Add /solutions/by-oil directory page
- Add Solutions section to Footer with navigation links
- Add Breadcrumb component for solution pages
- Add translations for all solution pages (sr, en, de, fr)
- Fix ExitIntentDetector JSON parsing error
- Update sitemap with solution pages
- Create 3 sample solution pages with data files
2026-04-05 05:21:57 +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
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
55 changed files with 5107 additions and 517 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
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 ./
RUN npm install --prefer-offline --no-audit
# Copy source and build
COPY . .
RUN npm run build
# Production stage
FROM node:20-slim AS runner
WORKDIR /app
@@ -20,7 +28,6 @@ ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
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-07T10:33:23Z
// 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

@@ -0,0 +1,206 @@
{
"slug": "best-argan-oil-for-under-eye-bags",
"oilSlug": "argan-oil",
"concernSlug": "under-eye-bags",
"pageTitle": {
"sr": "Najbolje arganovo ulje za podočnjake",
"en": "Best Argan Oil for Under Eye Bags",
"de": "Bestes Arganöl für Augenringe",
"fr": "Meilleure huile d'argan pour les cernes"
},
"metaTitle": {
"sr": "Najbolje arganovo ulje za podočnjake | Prirodno uklanjanje otoka | ManoonOils",
"en": "Best Argan Oil for Under Eye Bags | Natural Depuffing Solution | ManoonOils",
"de": "Bestes Arganöl für Augenringe | Natürliche Abschwelllösung | ManoonOils",
"fr": "Meilleure huile d'argan pour les cernes | Solution naturelle dégonflante | ManoonOils"
},
"metaDescription": {
"sr": "Saznajte zašto je arganovo ulje idealno za uklanjanje podočnjaka. Bogato vitaminom E i antioksidansima za osveženu, odmornu pojavu očiju.",
"en": "Learn why argan oil is ideal for reducing under eye bags. Rich in vitamin E and antioxidants for a refreshed, rested eye appearance.",
"de": "Erfahren Sie, warum Arganöl ideal zur Reduzierung von Augenringen ist. Reich an Vitamin E und Antioxidantien für ein erfrischtes, ausgeruhtes Augenaussehen.",
"fr": "Découvrez pourquoi l'huile d'argan est idéale pour réduire les cernes. Riche en vitamine E et antioxydants pour un regard frais et reposé."
},
"oilName": {
"sr": "Arganovo ulje",
"en": "Argan Oil",
"de": "Arganöl",
"fr": "Huile d'argan"
},
"concernName": {
"sr": "Podočnjaci",
"en": "Under Eye Bags",
"de": "Augenringe",
"fr": "Cernes"
},
"whyThisWorks": {
"sr": "Arganovo ulje je poznato kao tečno zlato iz Maroka zbog svoje izuzetne moći da hidratizira i neguje nežnu kožu oko očiju. Visoka koncentracija vitamina E i esencijalnih masnih kiselina pomaže u jačanju krvnih sudova, smanjenju zadržavanja tečnosti i poboljšanju mikrocirkulacije - ključnih faktora za uklanjanje podočnjaka.",
"en": "Argan oil is known as liquid gold from Morocco for its exceptional ability to hydrate and nourish the delicate eye area. The high concentration of vitamin E and essential fatty acids helps strengthen blood vessels, reduce fluid retention, and improve microcirculation - key factors for eliminating under eye bags.",
"de": "Arganöl ist als flüssiges Gold aus Marokko bekannt für seine außergewöhnliche Fähigkeit, den empfindlichen Augenbereich zu hydratisieren und zu pflegen. Die hohe Konzentration an Vitamin E und essenziellen Fettsäuren hilft, Blutgefäße zu stärken, Flüssigkeitsretention zu reduzieren und die Mikrozirkulation zu verbessern - Schlüsselfaktoren für die Elimination von Augenringen.",
"fr": "L'huile d'argan est connue comme l'or liquide du Maroc pour sa capacité exceptionnelle à hydrater et nourrir la zone délicate des yeux. La haute concentration en vitamine E et acides gras essentiels aide à renforcer les vaisseaux sanguins, réduire la rétention d'eau et améliorer la microcirculation - facteurs clés pour éliminer les cernes."
},
"keyBenefits": {
"sr": [
"Smanjuje zadržavanje tečnosti u predelu očiju",
"Jača krvne sudove i kapilare",
"Poboljšava mikrocirkulaciju",
"Hidratizira nežnu kožu bez iritacije",
"Smanjuje tamne krugove uz podočnjake",
"Daje odmoran, osvežen izgled"
],
"en": [
"Reduces fluid retention in the eye area",
"Strengthens blood vessels and capillaries",
"Improves microcirculation",
"Hydrates delicate skin without irritation",
"Reduces dark circles along with bags",
"Gives a rested, refreshed appearance"
],
"de": [
"Reduziert Flüssigkeitsretention im Augenbereich",
"Stärkt Blutgefäße und Kapillaren",
"Verbessert die Mikrozirkulation",
"Hydratisiert empfindliche Haut ohne Reizung",
"Reduziert Augenringe zusammen mit Tränensäcken",
"Gibt ein ausgeruhtes, erfrischtes Aussehen"
],
"fr": [
"Réduit la rétention d'eau dans la zone des yeux",
"Renforce les vaisseaux sanguins et capillaires",
"Améliore la microcirculation",
"Hydrate la peau délicate sans irritation",
"Réduit les cernes en plus des poches",
"Donne un aspect reposé et frais"
]
},
"howToApply": {
"sr": [
"Nanesite samo 1 kap ulja na četvrti prst",
"Pažljivo utapkajte oko očiju, od unutra ka spolja",
"Lagano masirajte u krugovima 30 sekundi",
"Koristite ujutru za smanjenje otoka",
"Možete staviti u frižider za dodatno osveženje",
"Budite nežni - koža oko očiju je veoma tanja"
],
"en": [
"Apply only 1 drop of oil to your ring finger",
"Gently pat around eyes, from inner to outer corner",
"Lightly massage in circles for 30 seconds",
"Use in the morning for de-puffing",
"Can be stored in fridge for extra refreshment",
"Be gentle - eye skin is much thinner"
],
"de": [
"Tragen Sie nur 1 Tropfen Öl auf den Ringfinger auf",
"Tupfen Sie sanft um die Augen, von innen nach außen",
"Massieren Sie 30 Sekunden leicht kreisförmig",
"Verwenden Sie morgens zur Abschwellung",
"Kann im Kühlschrank für zusätzliche Frische gelagert werden",
"Seien Sie sanft - die Augenhaut ist viel dünner"
],
"fr": [
"Appliquez seulement 1 goutte d'huile sur votre annulaire",
"Tapotez délicatement autour des yeux, de l'intérieur vers l'extérieur",
"Massez légèrement en cercles pendant 30 secondes",
"Utilisez le matin pour dégonfler",
"Peut être conservé au réfrigérateur pour plus de fraîcheur",
"Soyez doux - la peau des yeux est beaucoup plus fine"
]
},
"expectedResults": {
"sr": "Mnoge klijentkinje primećuju smanjenje otoka već nakon prve aplikacije zahvaljujući hidrataciji. Za trajno smanjenje podočnjaka, potrebno je 3-4 nedelje redovne upotrebe. Kombinacija sa dobrim spavanjem i hidratacijom daje najbolje rezultate.",
"en": "Many clients notice reduced puffiness after just the first application thanks to hydration. For lasting reduction of under eye bags, 3-4 weeks of regular use is needed. Combination with good sleep and hydration gives best results.",
"de": "Viele Kundinnen bemerken bereits nach der ersten Anwendung eine reduzierte Schwellung dank Hydratation. Für eine dauerhafte Reduzierung von Augenringen sind 3-4 Wochen regelmäßige Anwendung erforderlich. Die Kombination mit gutem Schlaf und Hydratation liefert die besten Ergebnisse.",
"fr": "De nombreuses clientes remarquent une réduction du gonflement dès la première application grâce à l'hydratation. Pour une réduction durable des cernes, 3-4 semaines d'utilisation régulière sont nécessaires. La combinaison avec un bon sommeil et une bonne hydratation donne les meilleurs résultats."
},
"timeframe": {
"sr": "Odmah za hidrataciju, 3-4 nedelje za smanjenje podočnjaka",
"en": "Immediate hydration, 3-4 weeks for bag reduction",
"de": "Sofortige Feuchtigkeit, 3-4 Wochen für Reduzierung",
"fr": "Hydratation immédiate, 3-4 semaines pour réduction"
},
"complementaryIngredients": [
"caffeine",
"vitamin-k",
"cucumber-extract",
"green-tea"
],
"productsToShow": [
"Manoon Eye Serum",
"Manoon 7"
],
"customerResults": [
{
"quote": {
"sr": "Kao majka troje dece, podočnjaci su bili moja stvarnost. Ovo ulje mi je vratilo osvežen izgled!",
"en": "As a mother of three, under eye bags were my reality. This oil gave me back a refreshed look!",
"de": "Als Mutter von drei Kindern waren Augenringe meine Realität. Dieses Öl gab mir ein erfrischtes Aussehen zurück!",
"fr": "En tant que mère de trois enfants, les cernes étaient ma réalité. Cette huile m'a redonné un look frais!"
},
"name": "Jelena M.",
"age": 38,
"timeframe": "3 weeks"
}
],
"faqs": [
{
"question": {
"sr": "Da li arganovo ulje može da uđe u oči?",
"en": "Can argan oil get into the eyes?",
"de": "Kann Arganöl in die Augen gelangen?",
"fr": "L'huile d'argan peut-elle entrer dans les yeux?"
},
"answer": {
"sr": "Nanesite ga pažljivo oko očiju, ne direktno na kapak. Ako uđe u oko, isperite obilno vodom. Arganaovo ulje je blago, ali kontakt sa očima može izazvati privremeno zamućenje vida.",
"en": "Apply carefully around the eyes, not directly on the eyelid. If it gets in the eye, rinse thoroughly with water. Argan oil is mild, but eye contact may cause temporary blurred vision.",
"de": "Tragen Sie es vorsichtig um die Augen auf, nicht direkt auf das Augenlid. Bei Kontakt mit dem Auge gründlich mit Wasser spülen. Arganöl ist mild, aber Augenkontakt kann vorübergehend verschwommenes Sehen verursachen.",
"fr": "Appliquez délicatement autour des yeux, pas directement sur la paupière. En cas de contact avec l'œil, rincer abondamment à l'eau. L'huile d'argan est douce, mais le contact avec les yeux peut provoquer une vision temporairement trouble."
}
},
{
"question": {
"sr": "Koliko dugo čuvati ulje u frižideru?",
"en": "How long to store the oil in the fridge?",
"de": "Wie lange das Öl im Kühlschrank lagern?",
"fr": "Combien de temps conserver l'huile au réfrigérateur?"
},
"answer": {
"sr": "Možete ga čuvati stalno u frižideru za osvežavajuću aplikaciju. Hladnoća dodatno smanjuje otok i daje prijatan osećaj pri nanošenju.",
"en": "You can store it permanently in the fridge for a refreshing application. The cold further reduces puffiness and gives a pleasant sensation when applying.",
"de": "Sie können es dauerhaft im Kühlschrank für eine erfrischende Anwendung lagern. Die Kälte reduziert weiter die Schwellung und gibt ein angenehmes Gefühl beim Auftragen.",
"fr": "Vous pouvez la conserver en permanence au réfrigérateur pour une application rafraîchissante. Le froid réduit davantage le gonflement et procure une sensation agréable lors de l'application."
}
}
],
"seoKeywords": {
"sr": {
"primary": ["arganovo ulje za podočnjake", "prirodno uklanjanje podočnjaka", "ulje protiv podočnjaka"],
"secondary": ["nega predela očiju", "tamni krugovi", "otok oko očiju"],
"longTail": ["kako ukloniti podočnjake prirodno", "najbolje ulje za podočnjake", "prirodna nega očiju"]
},
"en": {
"primary": ["argan oil for under eye bags", "natural eye bag remedy", "oil for puffy eyes"],
"secondary": ["eye area care", "dark circles", "eye puffiness"],
"longTail": ["how to remove eye bags naturally", "best oil for under eye bags", "natural eye care"]
},
"de": {
"primary": ["Arganöl für Augenringe", "natürliche Augenring-Behandlung", "Öl für geschwollene Augen"],
"secondary": ["Augenbereichspflege", "Augenringe", "Augenschwellung"],
"longTail": ["Augenringe natürlich entfernen", "bestes Öl für Augenringe", "natürliche Augenpflege"]
},
"fr": {
"primary": ["huile d'argan cernes", "remède naturel cernes", "huile pour poches sous les yeux"],
"secondary": ["soin contour des yeux", "cernes", "poches sous les yeux"],
"longTail": ["comment enlever les cernes naturellement", "meilleure huile pour cernes", "soin yeux naturel"]
}
},
"relatedPages": {
"otherOilsForSameConcern": [
"best-cucumber-oil-for-under-eye-bags",
"best-almond-oil-for-under-eye-bags"
],
"sameOilForOtherConcerns": [
"best-argan-oil-for-dry-skin",
"best-argan-oil-for-hair"
]
}
}

View File

@@ -0,0 +1,233 @@
{
"slug": "best-rosehip-oil-for-wrinkles",
"oilSlug": "rosehip-oil",
"concernSlug": "wrinkles",
"pageTitle": {
"sr": "Najbolje ulje divlje ruže protiv bora",
"en": "Best Rosehip Oil for Wrinkles",
"de": "Bestes Hagebuttenöl gegen Falten",
"fr": "Meilleure huile de rose musquée pour les rides"
},
"metaTitle": {
"sr": "Najbolje ulje divlje ruže protiv bora | Prirodno rešenje za bore | ManoonOils",
"en": "Best Rosehip Oil for Wrinkles | Natural Anti-Aging Solution | ManoonOils",
"de": "Bestes Hagebuttenöl gegen Falten | Natürliche Anti-Aging-Lösung | ManoonOils",
"fr": "Meilleure huile de rose musquée pour les rides | Solution anti-âge naturelle | ManoonOils"
},
"metaDescription": {
"sr": "Otkrijte zašto je ulje divlje ruže najbolji prirodni izbor protiv bora. Bogato vitaminom A i omega kiselinama za glatku, mladalačku kožu.",
"en": "Discover why rosehip oil is the best natural choice for wrinkles. Rich in vitamin A and omega fatty acids for smooth, youthful skin.",
"de": "Entdecken Sie, warum Hagebuttenöl die beste natürliche Wahl gegen Falten ist. Reich an Vitamin A und Omega-Fettsäuren für glatte, jugendliche Haut.",
"fr": "Découvrez pourquoi l'huile de rose musquée est le meilleur choix naturel contre les rides. Riche en vitamine A et acides gras oméga pour une peau lisse et jeune."
},
"oilName": {
"sr": "Ulje divlje ruže",
"en": "Rosehip Oil",
"de": "Hagebuttenöl",
"fr": "Huile de rose musquée"
},
"concernName": {
"sr": "Bore",
"en": "Wrinkles",
"de": "Falten",
"fr": "Rides"
},
"whyThisWorks": {
"sr": "Ulje divlje ruže sadrži prirodni trans-retinoičnu kiselinu, formu vitamina A koja stimuliše proizvodnju kolagena i ubrzava obnavljanje ćelija. Njegova jedinstvena kombinacija esencijalnih masnih kiselina prodire duboko u kožu, popravljajući oštećenu kožnu barijeru i vraćajući elastičnost.",
"en": "Rosehip oil contains natural trans-retinoic acid, a form of vitamin A that stimulates collagen production and accelerates cell renewal. Its unique blend of essential fatty acids penetrates deep into the skin, repairing damaged skin barriers and restoring elasticity.",
"de": "Hagebuttenöl enthält natürliche Trans-Retinsäure, eine Form von Vitamin A, die die Kollagenproduktion stimuliert und die Zellerneuerung beschleunigt. Seine einzigartige Mischung essenzieller Fettsäuren dringt tief in die Haut ein, repariert beschädigte Hautbarrieren und stellt die Elastizität wieder her.",
"fr": "L'huile de rose musquée contient de l'acide trans-rétinoïque naturel, une forme de vitamine A qui stimule la production de collagène et accélère le renouvellement cellulaire. Son mélange unique d'acides gras essentiels pénètre en profondeur dans la peau, répare les barrières cutanées endommagées et restaure l'élasticité."
},
"keyBenefits": {
"sr": [
"Stimuliše prirodnu proizvodnju kolagena",
"Smanjuje dubinu postojećih bora",
"Prevencija nastanka novih bora",
"Poboljšava teksturu kože",
"Ujednačava ten kože",
"Intenzivno hidratizira bez zagušivanja pora"
],
"en": [
"Stimulates natural collagen production",
"Reduces depth of existing wrinkles",
"Prevents formation of new wrinkles",
"Improves skin texture",
"Evens out skin tone",
"Intensely hydrates without clogging pores"
],
"de": [
"Stimuliert die natürliche Kollagenproduktion",
"Reduziert die Tiefe bestehender Falten",
"Verhindert die Bildung neuer Falten",
"Verbessert die Hauttextur",
"Ebnert den Teint aus",
"Intensiv feuchtigkeitsspendend ohne Poren zu verstopfen"
],
"fr": [
"Stimule la production naturelle de collagène",
"Réduit la profondeur des rides existantes",
"Prévient la formation de nouvelles rides",
"Améliore la texture de la peau",
"Unifie le teint",
"Hydrate intensément sans boucher les pores"
]
},
"howToApply": {
"sr": [
"Nanesite 2-3 kapi na očišćeno lice i vrat",
"Blago utapkajte prstima, ne trljajte",
"Fokusirajte se na područja sa borama",
"Koristite uveče za najbolje rezultate",
"Možete mešati sa hidratantnom kremom",
"Budite dosledni - rezultati za 4-6 nedelja"
],
"en": [
"Apply 2-3 drops to cleansed face and neck",
"Gently pat with fingertips, don't rub",
"Focus on wrinkle-prone areas",
"Use in the evening for best results",
"Can be mixed with moisturizer",
"Be consistent - results in 4-6 weeks"
],
"de": [
"2-3 Tropfen auf gereinigtes Gesicht und Hals auftragen",
"Sanft mit den Fingerspitzen klopfen, nicht reiben",
"Konzentrieren Sie sich auf faltenanfällige Bereiche",
"Verwenden Sie abends für beste Ergebnisse",
"Kann mit Feuchtigkeitscreme gemischt werden",
"Seien Sie konsistent - Ergebnisse nach 4-6 Wochen"
],
"fr": [
"Appliquez 2-3 gouttes sur le visage et le cou nettoyés",
"Tapotez délicatement du bout des doigts, ne frottez pas",
"Concentrez-vous sur les zones à risque de rides",
"Utilisez le soir pour de meilleurs résultats",
"Peut être mélangé avec une crème hydratante",
"Soyez constant - résultats en 4-6 semaines"
]
},
"expectedResults": {
"sr": "Većina korisnica primećuje prve rezultate nakon 2-3 nedelje redovne upotrebe - koža je mekša i hidratizovanija. Za vidljivo smanjenje bora potrebno je 6-8 nedelja dosledne upotrebe. Najbolji rezultati se postižu nakon 3 meseca.",
"en": "Most users notice first results after 2-3 weeks of regular use - skin feels softer and more hydrated. For visible wrinkle reduction, 6-8 weeks of consistent use is needed. Best results are achieved after 3 months.",
"de": "Die meisten Benutzer bemerken erste Ergebnisse nach 2-3 Wochen regelmäßiger Anwendung - die Haut fühlt sich weicher und hydratierter an. Für eine sichtbare Faltenreduktion sind 6-8 Wochen konsequenter Anwendung erforderlich. Die besten Ergebnisse werden nach 3 Monaten erzielt.",
"fr": "La plupart des utilisateurs remarquent les premiers résultats après 2-3 semaines d'utilisation régulière - la peau est plus douce et hydratée. Pour une réduction visible des rides, 6-8 semaines d'utilisation constante sont nécessaires. Les meilleurs résultats sont obtenus après 3 mois."
},
"timeframe": {
"sr": "2-3 nedelje za hidrataciju, 6-8 nedelja za bore, 3 meseca za transformaciju",
"en": "2-3 weeks for hydration, 6-8 weeks for wrinkles, 3 months for transformation",
"de": "2-3 Wochen für Feuchtigkeit, 6-8 Wochen für Falten, 3 Monate für Transformation",
"fr": "2-3 semaines pour l'hydratation, 6-8 semaines pour les rides, 3 mois pour la transformation"
},
"complementaryIngredients": [
"vitamin-e",
"hyaluronic-acid",
"niacinamide",
"squalane"
],
"productsToShow": [
"Manoon 7",
"Manoon Lux"
],
"customerResults": [
{
"quote": {
"sr": "Posle 2 meseca korišćenja, moje bore oko očiju su znatno manje vidljive. Koža je neverovatno meka i sjajna!",
"en": "After 2 months of use, my eye wrinkles are significantly less visible. My skin is incredibly soft and glowing!",
"de": "Nach 2 Monaten Anwendung sind meine Augenfalten deutlich weniger sichtbar. Meine Haut ist unglaublich weich und strahlend!",
"fr": "Après 2 mois d'utilisation, mes rides des yeux sont significativement moins visibles. Ma peau est incroyablement douce et éclatante!"
},
"name": "Marija P.",
"age": 52,
"timeframe": "2 months"
},
{
"quote": {
"sr": "Najbolje ulje koje sam ikada probala za bore. Rezultati su stvarni, ne lažna obećanja.",
"en": "Best oil I've ever tried for wrinkles. The results are real, not fake promises.",
"de": "Bestes Öl, das ich je gegen Falten ausprobiert habe. Die Ergebnisse sind real, keine leeren Versprechen.",
"fr": "Meilleure huile que j'ai jamais essayée contre les rides. Les résultats sont réels, pas de fausses promesses."
},
"name": "Ana K.",
"age": 45,
"timeframe": "6 weeks"
}
],
"faqs": [
{
"question": {
"sr": "Koliko često treba koristiti ulje divlje ruže protiv bora?",
"en": "How often should I use rosehip oil for wrinkles?",
"de": "Wie oft sollte ich Hagebuttenöl gegen Falten verwenden?",
"fr": "À quelle fréquence dois-je utiliser l'huile de rose musquée contre les rides?"
},
"answer": {
"sr": "Preporučujemo svakodnevnu upotrebu uveče na očišćenom licu. Za intenzivnju negu, možete ga koristiti i ujutru, ali uvek nanesite zaštitni faktor nakon toga.",
"en": "We recommend daily use in the evening on cleansed face. For intensive care, you can use it in the morning too, but always apply sunscreen afterwards.",
"de": "Wir empfehlen die tägliche Anwendung abends auf gereinigtem Gesicht. Für intensive Pflege können Sie es auch morgens verwenden, aber tragen Sie danach immer Sonnenschutz auf.",
"fr": "Nous recommandons une utilisation quotidienne le soir sur le visage nettoyé. Pour des soins intensifs, vous pouvez l'utiliser le matin aussi, mais appliquez toujours un écran solaire après."
}
},
{
"question": {
"sr": "Da li ulje divlje ruže izaziva iritaciju?",
"en": "Does rosehip oil cause irritation?",
"de": "Verursacht Hagebuttenöl Reizungen?",
"fr": "L'huile de rose musquée cause-t-elle des irritations?"
},
"answer": {
"sr": "Ulje divlje ruže je generalno blago i prikladno za sve tipove kože. Ipak, sadrži prirodne forme vitamina A, pa preporučujemo testiranje na malom delu kože prvo.",
"en": "Rosehip oil is generally mild and suitable for all skin types. However, it contains natural forms of vitamin A, so we recommend testing on a small skin area first.",
"de": "Hagebuttenöl ist im Allgemeinen mild und für alle Hauttypen geeignet. Es enthält jedoch natürliche Formen von Vitamin A, daher empfehlen wir, es zuerst an einer kleinen Hautstelle zu testen.",
"fr": "L'huile de rose musquée est généralement douce et adaptée à tous les types de peau. Cependant, elle contient des formes naturelles de vitamine A, nous recommandons donc de tester sur une petite zone de peau d'abord."
}
},
{
"question": {
"sr": "Kada mogu očekivati prve rezultate?",
"en": "When can I expect first results?",
"de": "Wann kann ich erste Ergebnisse erwarten?",
"fr": "Quand puis-je attendre les premiers résultats?"
},
"answer": {
"sr": "Prve rezultate u vidu hidratacije i mekoće kože možete očekivati već nakon 2-3 nedelje. Za vidljivo smanjenje bora potrebno je 6-8 nedelja redovne upotrebe.",
"en": "You can expect first results in terms of hydration and skin softness after just 2-3 weeks. For visible wrinkle reduction, 6-8 weeks of regular use is needed.",
"de": "Sie können erste Ergebnisse in Bezug auf Feuchtigkeit und Hautweichheit bereits nach 2-3 Wochen erwarten. Für eine sichtbare Faltenreduktion sind 6-8 Wochen regelmäßige Anwendung erforderlich.",
"fr": "Vous pouvez attendre les premiers résultats en termes d'hydratation et de douceur de la peau après seulement 2-3 semaines. Pour une réduction visible des rides, 6-8 semaines d'utilisation régulière sont nécessaires."
}
}
],
"seoKeywords": {
"sr": {
"primary": ["ulje divlje ruže protiv bora", "prirodno rešenje za bore", "najbolje ulje za bore"],
"secondary": ["anti-aging ulje", "prirodni retinol", "serum protiv starenja"],
"longTail": ["kako ukloniti bore prirodnim putem", "ulje divlje ruže iskustva", "najbolji serum za bore posle 40"]
},
"en": {
"primary": ["rosehip oil for wrinkles", "natural wrinkle solution", "best oil for wrinkles"],
"secondary": ["anti-aging oil", "natural retinol", "anti-aging serum"],
"longTail": ["how to remove wrinkles naturally", "rosehip oil before and after", "best wrinkle serum over 40"]
},
"de": {
"primary": ["Hagebuttenöl gegen Falten", "natürliche Faltenlösung", "bestes Öl gegen Falten"],
"secondary": ["Anti-Aging-Öl", "natürliches Retinol", "Anti-Aging-Serum"],
"longTail": ["Falten natürlich entfernen", "Hagebuttenöl Vorher Nachher", "bestes Falten-Serum über 40"]
},
"fr": {
"primary": ["huile de rose musquée rides", "solution naturelle rides", "meilleure huile anti-rides"],
"secondary": ["huile anti-âge", "rétinol naturel", "sérum anti-âge"],
"longTail": ["comment effacer les rides naturellement", "huile de rose musquée avant après", "meilleur sérum anti-rides après 40 ans"]
}
},
"relatedPages": {
"otherOilsForSameConcern": [
"best-argan-oil-for-wrinkles",
"best-marula-oil-for-wrinkles",
"best-pomegranate-oil-for-wrinkles"
],
"sameOilForOtherConcerns": [
"best-rosehip-oil-for-scars",
"best-rosehip-oil-for-hyperpigmentation",
"best-rosehip-oil-for-dry-skin"
]
}
}

View File

@@ -0,0 +1,206 @@
{
"slug": "best-sea-buckthorn-oil-for-hyperpigmentation",
"oilSlug": "sea-buckthorn-oil",
"concernSlug": "hyperpigmentation",
"pageTitle": {
"sr": "Najbolje ulje rakitovca za hiperpigmentaciju",
"en": "Best Sea Buckthorn Oil for Hyperpigmentation",
"de": "Bestes Sanddornöl für Hyperpigmentierung",
"fr": "Meilleure huile d'argousier pour l'hyperpigmentation"
},
"metaTitle": {
"sr": "Najbolje ulje rakitovca za hiperpigmentaciju | Prirodno izbeljivanje | ManoonOils",
"en": "Best Sea Buckthorn Oil for Hyperpigmentation | Natural Brightening | ManoonOils",
"de": "Bestes Sanddornöl für Hyperpigmentierung | Natürliche Aufhellung | ManoonOils",
"fr": "Meilleure huile d'argousier pour l'hyperpigmentation | Éclaircissement naturel | ManoonOils"
},
"metaDescription": {
"sr": "Otkrijte moć ulja rakitovca protiv tamnih fleka. Sa 12 puta više vitamina C od narandže za sjajnu, ujednačenu boju kože.",
"en": "Discover the power of sea buckthorn oil against dark spots. With 12x more vitamin C than oranges for bright, even skin tone.",
"de": "Entdecken Sie die Kraft von Sanddornöl gegen dunkle Flecken. Mit 12x mehr Vitamin C als Orangen für einen hellen, ebenen Teint.",
"fr": "Découvrez la puissance de l'huile d'argousier contre les taches sombres. Avec 12x plus de vitamine C que les oranges pour un teint lumineux et uniforme."
},
"oilName": {
"sr": "Ulje rakitovca",
"en": "Sea Buckthorn Oil",
"de": "Sanddornöl",
"fr": "Huile d'argousier"
},
"concernName": {
"sr": "Hiperpigmentacija",
"en": "Hyperpigmentation",
"de": "Hyperpigmentierung",
"fr": "Hyperpigmentation"
},
"whyThisWorks": {
"sr": "Ulje rakitovca je jedno od najbogatijih prirodnih izvora vitamina C na svetu - čak 12 puta više od narandže! Ova izuzetna koncentracija antioksidanasa inhibira proizvodnju melanina na mestima gde je pretjerana, postepeno izbeljujući tamne fleke. Dodatno, beta-karoten i omega masne kiseline ubrzavaju obnavljanje ćelija, dovodeći do ujednačenog tena.",
"en": "Sea buckthorn oil is one of the richest natural sources of vitamin C in the world - 12 times more than oranges! This exceptional antioxidant concentration inhibits melanin production where it's excessive, gradually lightening dark spots. Additionally, beta-carotene and omega fatty acids accelerate cell renewal, leading to an even skin tone.",
"de": "Sanddornöl ist eine der reichsten natürlichen Quellen für Vitamin C der Welt - 12-mal mehr als Orangen! Diese außergewöhnliche Antioxidantienkonzentration hemmt die Melaninproduktion dort, wo sie übermäßig ist, und hellt dunkle Flecken allmählich auf. Zusätzlich beschleunigen Beta-Karotin und Omega-Fettsäuren die Zellerneuerung, was zu einem ebenen Teint führt.",
"fr": "L'huile d'argousier est l'une des sources naturelles les plus riches en vitamine C au monde - 12 fois plus que les oranges ! Cette concentration exceptionnelle d'antioxydants inhibe la production de mélanine là où elle est excessive, éclaircissant progressivement les taches sombres. De plus, le bêta-carotène et les acides gras oméga accélèrent le renouvellement cellulaire, conduisant à un teint uniforme."
},
"keyBenefits": {
"sr": [
"Sadrži 12x više vitamina C od narandže",
"Prirodno inhibira proizvodnju melanina",
"Postepeno svetli tamne fleke i pege",
"Prevencija novih pigmentnih promena",
"Daje koži zdrav sjaj",
"Ujednačava neujednačen ten"
],
"en": [
"Contains 12x more vitamin C than oranges",
"Naturally inhibits melanin production",
"Gradually lightens dark spots and freckles",
"Prevents new pigmentation issues",
"Gives skin a healthy glow",
"Evens out uneven skin tone"
],
"de": [
"Enthält 12x mehr Vitamin C als Orangen",
"Hemmtt natürlich die Melaninproduktion",
"Helllt dunkle Flecken und Sommersprossen allmählich auf",
"Verhindert neue Pigmentierungsprobleme",
"Gibt der Haut einen gesunden Glanz",
"Ebnert unebenen Teint aus"
],
"fr": [
"Contient 12x plus de vitamine C que les oranges",
"Inhibe naturellement la production de mélanine",
"Éclaircit progressivement les taches sombres et les taches de rousseur",
"Prévient les nouveaux problèmes de pigmentation",
"Donne à la peau un éclat santé",
"Unifie le teint inégal"
]
},
"howToApply": {
"sr": [
"Nanesite samo na područja sa hiperpigmentacijom",
"Koristite kao 'tretman tačkasto' ili po celom licu",
"Mešajte sa nosiocem ulja (jojoba ili badem) 1:1",
"Koristite uveče - vit. C je fotosenzitivna",
"Uvek nanesite zaštitni faktor ujutru",
"Budite strpljivi - rezultati za 6-8 nedelja"
],
"en": [
"Apply only to hyperpigmented areas",
"Use as 'spot treatment' or all over face",
"Mix with carrier oil (jojoba or almond) 1:1",
"Use in evening - vit. C is photosensitive",
"Always apply sunscreen in the morning",
"Be patient - results in 6-8 weeks"
],
"de": [
"Nur auf hyperpigmentierte Bereiche auftragen",
"Als 'Fleckenbehandlung' oder im ganzen Gesicht verwenden",
"Mit Trägeröl (Jojoba oder Mandel) 1:1 mischen",
"Abends verwenden - Vit. C ist lichtempfindlich",
"Morgens immer Sonnenschutz auftragen",
"Geduldig sein - Ergebnisse nach 6-8 Wochen"
],
"fr": [
"Appliquer uniquement sur les zones hyperpigmentées",
"Utiliser comme 'traitement ciblé' ou sur tout le visage",
"Mélanger avec huile de support (jojoba ou amande) 1:1",
"Utiliser le soir - vit. C est photosensible",
"Toujours appliquer de la crème solaire le matin",
"Soyez patient - résultats en 6-8 semaines"
]
},
"expectedResults": {
"sr": "Zbog snažnog dejstva, ulje rakitovca zahteva strpljenje. Prve promene u sjaju kože videćete nakon 2-3 nedelje. Za vidljivo izbeljivanje tamnih fleka potrebno je 6-8 nedelja, a za kompletnu transformaciju tena 3-4 meseca dosledne upotrebe. Ključno je koristiti zaštitni faktor svakog dana.",
"en": "Due to its powerful effect, sea buckthorn oil requires patience. You'll see first changes in skin glow after 2-3 weeks. For visible lightening of dark spots, 6-8 weeks is needed, and for complete skin tone transformation, 3-4 months of consistent use. Daily sunscreen is crucial.",
"de": "Aufgrund seiner starken Wirkung erfordert Sanddornöl Geduld. Erste Veränderungen im Hautglanz sehen Sie nach 2-3 Wochen. Für eine sichtbare Aufhellung dunkler Flecken sind 6-8 Wochen erforderlich, und für eine komplette Teint-Transformation 3-4 Monate konsequenter Anwendung. Täglicher Sonnenschutz ist entscheidend.",
"fr": "En raison de son effet puissant, l'huile d'argousier demande de la patience. Vous verrez les premiers changements d'éclat de la peau après 2-3 semaines. Pour un éclaircissement visible des taches sombres, 6-8 semaines sont nécessaires, et pour une transformation complète du teint, 3-4 mois d'utilisation constante. Une protection solaire quotidienne est cruciale."
},
"timeframe": {
"sr": "2-3 nedelje za sjaj, 6-8 nedelja za tamne fleke, 3-4 meseca za transformaciju",
"en": "2-3 weeks for glow, 6-8 weeks for dark spots, 3-4 months for transformation",
"de": "2-3 Wochen für Glanz, 6-8 Wochen für dunkle Flecken, 3-4 Monate für Transformation",
"fr": "2-3 semaines pour l'éclat, 6-8 semaines pour les taches sombres, 3-4 mois pour la transformation"
},
"complementaryIngredients": [
"vitamin-c",
"niacinamide",
"licorice-root",
"kojic-acid"
],
"productsToShow": [
"Manoon Bright",
"Manoon 7"
],
"customerResults": [
{
"quote": {
"sr": "Posle godina borbe sa tamnim flekama od akni, konačno sam pronašla rešenje. Moja koža nikada nije izgledala bolje!",
"en": "After years of battling dark spots from acne, I finally found a solution. My skin has never looked better!",
"de": "Nach Jahren des Kampfes gegen dunkle Flecken von Akne habe ich endlich eine Lösung gefunden. Meine Haut hat noch nie besser ausgesehen!",
"fr": "Après des années de lutte contre les taches sombres dues à l'acné, j'ai finalement trouvé une solution. Ma peau n'a jamais été aussi belle!"
},
"name": "Sofija R.",
"age": 34,
"timeframe": "3 months"
}
],
"faqs": [
{
"question": {
"sr": "Da li ulje rakitovca boji kožu narandžasto?",
"en": "Does sea buckthorn oil stain skin orange?",
"de": "Färbt Sanddornöl die Haut orange?",
"fr": "L'huile d'argousier tache-t-elle la peau en orange?"
},
"answer": {
"sr": "Čisto ulje rakitovca ima intenzivnu narandžastu boju zbog beta-karotena. Preporučujemo mešanje sa nosiocem ulja (jojoba ili badem) u odnosu 1:1 ili 1:2 da biste izbegli privremeno bojenje kože.",
"en": "Pure sea buckthorn oil has an intense orange color due to beta-carotene. We recommend mixing with a carrier oil (jojoba or almond) in a 1:1 or 1:2 ratio to avoid temporary skin staining.",
"de": "Reines Sanddornöl hat aufgrund von Beta-Karotin eine intensive orangefarbene Farbe. Wir empfehlen, es mit Trägeröl (Jojoba oder Mandel) im Verhältnis 1:1 oder 1:2 zu mischen, um vorübergehende Hautfärbung zu vermeiden.",
"fr": "L'huile d'argousier pure a une couleur orange intense due au bêta-carotène. Nous recommandons de la mélanger avec une huile de support (jojoba ou amande) dans un ratio 1:1 ou 1:2 pour éviter la coloration temporaire de la peau."
}
},
{
"question": {
"sr": "Mogu li ga koristiti ujutru?",
"en": "Can I use it in the morning?",
"de": "Kann ich es morgens verwenden?",
"fr": "Puis-je l'utiliser le matin?"
},
"answer": {
"sr": "Ne preporučujemo jutarnju upotrebu jer visoka koncentracija vitamina C može učiniti kožu osetljivijom na sunce. Uvek koristite uveče i nanesite SPF 30+ ujutru.",
"en": "We don't recommend morning use as the high vitamin C concentration can make skin more sensitive to sun. Always use in the evening and apply SPF 30+ in the morning.",
"de": "Wir empfehlen keine morgendliche Anwendung, da die hohe Vitamin C-Konzentration die Haut sonnenempfindlicher machen kann. Verwenden Sie es immer abends und tragen Sie morgens LSF 30+ auf.",
"fr": "Nous ne recommandons pas l'utilisation le matin car la haute concentration en vitamine C peut rendre la peau plus sensible au soleil. Utilisez toujours le soir et appliquez SPF 30+ le matin."
}
}
],
"seoKeywords": {
"sr": {
"primary": ["ulje rakitovca za hiperpigmentaciju", "prirodno izbeljivanje kože", "ulje protiv tamnih fleka"],
"secondary": ["pege", "neujednačen ten", "prirodno posvetljavanje"],
"longTail": ["kako ukloniti tamne fleke", "najbolje ulje za pege", "prirodno rešenje za hiperpigmentaciju"]
},
"en": {
"primary": ["sea buckthorn oil hyperpigmentation", "natural skin brightening", "oil for dark spots"],
"secondary": ["freckles", "uneven skin tone", "natural lightening"],
"longTail": ["how to remove dark spots", "best oil for freckles", "natural hyperpigmentation solution"]
},
"de": {
"primary": ["Sanddornöl Hyperpigmentierung", "natürliche Hautaufhellung", "Öl für dunkle Flecken"],
"secondary": ["Sommersprossen", "unebener Teint", "natürliche Aufhellung"],
"longTail": ["dunkle Flecken entfernen", "bestes Öl für Sommersprossen", "natürliche Hyperpigmentierungslösung"]
},
"fr": {
"primary": ["huile d'argousier hyperpigmentation", "éclaircissement naturel", "huile pour taches sombres"],
"secondary": ["taches de rousseur", "teint inégal", "éclaircissement naturel"],
"longTail": ["comment enlever les taches sombres", "meilleure huile pour taches de rousseur", "solution naturelle hyperpigmentation"]
}
},
"relatedPages": {
"otherOilsForSameConcern": [
"best-licorice-oil-for-hyperpigmentation",
"best-vitamin-c-oil-for-hyperpigmentation"
],
"sameOilForOtherConcerns": [
"best-sea-buckthorn-oil-for-aging",
"best-sea-buckthorn-oil-for-dry-skin"
]
}
}

View File

@@ -0,0 +1,503 @@
# Programmatic SEO Plan for ManoonOils
## Executive Summary
Create 100+ SEO-optimized landing pages from structured datasets to capture high-intent search traffic and convert visitors into serum buyers.
---
## Dataset Ideas (7 Core Categories)
### 1. **Ingredient Benefits Database** ⭐ Highest Priority
**Dataset Size:** 50-100 ingredients × 4 locales = 200-400 pages
**Data Structure:**
```typescript
interface Ingredient {
slug: string; // "rosehip-oil"
name: {
sr: "Ulje divlje ruže";
en: "Rosehip Oil";
de: "Hagebuttenöl";
fr: "Huile de rose musquée";
};
benefits: string[]; // ["anti-aging", "hydration", "scars"]
skinTypes: string[]; // ["dry", "mature", "sensitive"]
scientificName: string;
origin: string;
extractionMethod: string;
keyCompounds: string[]; // ["vitamin A", "omega-3", "antioxidants"]
usageInstructions: string;
complementaryIngredients: string[]; // ["vitamin-e", "jojoba-oil"]
relatedProducts: string[]; // Product slugs to recommend
faqs: FAQ[];
seoKeywords: {
primary: string;
secondary: string[];
longTail: string[];
};
}
```
**Page Template:** `/ingredients/[slug]`
- Hero: Ingredient name + key benefit
- Scientific overview
- Benefits for skin (with icons)
- How to use (with video placeholder)
- "Best for" skin types
- Related Manoon products (product cards)
- FAQ schema markup
- CTA: "Shop serums with [ingredient]"
**Example Pages:**
- `/ingredients/rosehip-oil` - "Rosehip Oil for Anti-Aging: Benefits & How to Use"
- `/ingredients/bakuchiol` - "Bakuchiol: Natural Retinol Alternative"
- `/ingredients/sea-buckthorn` - "Sea Buckthorn Oil: Vitamin C Powerhouse"
---
### 2. **Skin Concern Solutions** ⭐ Highest Priority
**Dataset Size:** 20-30 concerns × 4 locales = 80-120 pages
**Data Structure:**
```typescript
interface SkinConcern {
slug: string; // "fine-lines"
name: {
sr: "Bore i linije";
en: "Fine Lines & Wrinkles";
de: "Feine Linien";
fr: "Rides et ridules";
};
description: string;
causes: string[];
bestIngredients: string[]; // Links to ingredient pages
recommendedRoutine: {
morning: string[];
evening: string[];
};
relatedProducts: string[];
beforeAfterImages: boolean;
testimonials: Testimonial[];
seoKeywords: SEOKeywords;
}
```
**Page Template:** `/concerns/[slug]`
- Empathy hook: "Struggling with [concern]?"
- Explain the problem
- Best ingredients (linking to ingredient pages)
- Recommended products
- Customer results/testimonials
- Free guide download (lead capture)
- CTA: "Start your transformation"
**Example Pages:**
- `/concerns/fine-lines` - "How to Reduce Fine Lines Naturally"
- `/concerns/hyperpigmentation` - "Dark Spots: Causes & Natural Solutions"
- `/concerns/dull-skin` - "Get Your Glow Back: Dull Skin Remedies"
---
### 3. **Ingredient Comparison Matrix**
**Dataset Size:** 50 ingredient pairs = 50 comparison pages
**Data Structure:**
```typescript
interface IngredientComparison {
slug: string; // "retinol-vs-bakuchiol"
ingredientA: string; // Reference to ingredient
ingredientB: string;
comparisonPoints: {
effectiveness: string;
gentleness: string;
price: string;
availability: string;
bestFor: string[];
};
winner: string | "tie";
recommendation: string; // "Choose X if..., Choose Y if..."
relatedProducts: {
a: string[];
b: string[];
};
}
```
**Page Template:** `/compare/[slug]`
- Head-to-head comparison table
- Which is better for what
- Product recommendations for both
- "Can't decide? Try our quiz"
- CTA: Shop both options
**Example Pages:**
- `/compare/retinol-vs-bakuchiol`
- `/compare/vitamin-c-vs-niacinamide`
- `/compare/rosehip-vs-argan-oil`
---
### 4. **Seasonal Skincare Guides**
**Dataset Size:** 4 seasons × 5 climates × 4 locales = 80 pages
**Data Structure:**
```typescript
interface SeasonalGuide {
slug: string; // "winter-skincare-routine"
season: "winter" | "spring" | "summer" | "autumn";
climate: "cold" | "dry" | "humid" | "temperate" | "tropical";
title: LocalizedString;
challenges: string[];
recommendedIngredients: string[];
routine: {
morning: RoutineStep[];
evening: RoutineStep[];
};
productBundle: string[];
tips: string[];
}
```
**Page Template:** `/guides/seasonal/[slug]`
- Season-specific challenges
- Ingredient recommendations
- Step-by-step routine
- Product bundle suggestion
- "Get the seasonal routine set" CTA
**Example Pages:**
- `/guides/seasonal/winter-skincare-routine`
- `/guides/seasonal/summer-anti-aging`
- `/guides/seasonal/spring-skin-renewal`
---
### 5. **Age-Specific Routines**
**Dataset Size:** 6 age groups × 4 locales = 24 pages
**Data Structure:**
```typescript
interface AgeRoutine {
slug: string; // "skincare-routine-30s"
ageRange: string; // "20s", "30s", "40s", "50s", "60s+"
title: LocalizedString;
skinChanges: string[];
keyConcerns: string[];
recommendedIngredients: string[];
routine: DailyRoutine;
productRecommendations: string[];
preventionTips: string[];
}
```
**Page Template:** `/routines/age/[slug]`
- "Best skincare routine for your [age]s"
- What happens to skin at this age
- Key ingredients to start using
- Morning & evening routine
- Product recommendations
- "Shop the [age]s routine bundle"
**Example Pages:**
- `/routines/age/skincare-routine-30s`
- `/routines/age/anti-aging-routine-40s`
- `/routines/age/mature-skin-care-50s`
---
### 6. **Skin Type Hubs**
**Dataset Size:** 6 skin types × 4 locales = 24 pages
**Data Structure:**
```typescript
interface SkinType {
slug: string; // "dry-skin"
name: LocalizedString;
characteristics: string[];
causes: string[];
ingredientsToLookFor: string[];
ingredientsToAvoid: string[];
recommendedProducts: string[];
routine: DailyRoutine;
tips: string[];
}
```
**Page Template:** `/skin-types/[slug]`
- Quiz: "Do you have [skin type]?"
- Characteristics checklist
- Best ingredients (with links)
- Complete routine
- Products specifically for this type
- CTA: "Build your [type] routine"
**Example Pages:**
- `/skin-types/dry-skin`
- `/skin-types/sensitive-skin`
- `/skin-types/combination-skin`
---
### 7. **Geographic/Climate-Specific**
**Dataset Size:** 20 regions × 4 seasons = 80 pages
**Data Structure:**
```typescript
interface ClimateGuide {
slug: string; // "skincare-for-cold-climates"
region: string;
climate: string;
challenges: string[];
recommendedIngredients: string[];
routineModifications: string;
productBundle: string[];
localTestimonials?: Testimonial[];
}
```
**Page Template:** `/climate/[slug]`
- "Skincare for [climate] climates"
- Local skin challenges
- Best ingredients for this climate
- Modified routine
- "Customers in [region] love..."
**Example Pages:**
- `/climate/skincare-for-cold-climates`
- `/climate/skincare-for-humid-climates`
- `/climate/skincare-for-arid-climates`
---
## Data Storage Strategy
### Option A: JSON Files (Recommended for MVP)
```
data/
├── ingredients/
│ ├── rosehip-oil.json
│ ├── bakuchiol.json
│ └── ...
├── concerns/
│ ├── fine-lines.json
│ ├── hyperpigmentation.json
│ └── ...
├── comparisons/
│ ├── retinol-vs-bakuchiol.json
│ └── ...
└── locales/
├── sr/
├── en/
├── de/
└── fr/
```
**Pros:**
- Easy to version control
- Simple to edit
- Fast to implement
- Works with Next.js static generation
### Option B: Headless CMS (Strapi/Sanity)
**Pros:**
- Non-technical team can edit
- Rich media support
- Relationships between entities
### Option C: Database (PostgreSQL/MongoDB)
**Pros:**
- Dynamic content
- User-generated content ready
- Advanced filtering
---
## Technical Implementation
### URL Structure
```
/ingredients/[slug] # Ingredient deep-dives
/concerns/[slug] # Problem-solving pages
/compare/[slug] # Comparison pages
/guides/seasonal/[slug] # Seasonal content
/routines/age/[slug] # Age-specific routines
/skin-types/[slug] # Skin type hubs
/climate/[slug] # Climate guides
```
### Page Generation (Next.js)
```typescript
// app/ingredients/[slug]/page.tsx
export async function generateStaticParams() {
const ingredients = await getAllIngredients();
return ingredients.map((i) => ({ slug: i.slug }));
}
export default async function IngredientPage({
params: { slug, locale }
}) {
const ingredient = await getIngredient(slug, locale);
return <IngredientTemplate data={ingredient} />;
}
```
### SEO Template Fields (Per Page)
```typescript
interface SEOTemplate {
title: string; // "Rosehip Oil for Anti-Aging | Benefits & Uses | ManoonOils"
metaDescription: string; // 155 chars with keywords
canonical: string; // Full URL
ogTitle: string;
ogDescription: string;
ogImage: string; // Dynamic OG image with ingredient
keywords: string[];
faqSchema: FAQPageSchema;
productSchema?: ProductSchema;
breadcrumb: BreadcrumbItem[];
}
```
---
## Content Templates
### Ingredient Page Template
```
H1: [Ingredient Name] for [Primary Benefit]: Complete Guide
Hero Section:
- Large ingredient image
- Key benefits (3 icons)
- CTA: "Shop [ingredient] serums"
H2: What is [Ingredient]?
- Scientific explanation
- Origin & extraction
- Key compounds
H2: Benefits of [Ingredient] for Skin
- H3: Anti-aging properties
- H3: Hydration benefits
- H3: Additional benefits
H2: Best Skin Types for [Ingredient]
- Visual skin type selector
H2: How to Use [Ingredient] in Your Routine
- Morning routine
- Evening routine
- What to pair with (links to comparisons)
H2: Our [Ingredient] Products
- Product cards with prices
- "Shop all [ingredient] products"
H2: Frequently Asked Questions
- FAQ schema markup
- 5-7 common questions
Related Content:
- Compare with similar ingredients
- Read about skin concerns it treats
CTA: "Start your [ingredient] routine"
```
---
## Conversion Strategy
### Lead Magnets (Email Capture)
1. **"The Natural Anti-Aging Guide"** - PDF download
2. **"Ingredient Compatibility Chart"** - Interactive tool
3. **"Personalized Routine Quiz"** - Email results
4. **"Seasonal Skincare Calendar"** - Year-long guide
### Product CTAs
1. **Primary:** "Shop [ingredient] serums" → Category page
2. **Secondary:** "Get the complete routine" → Bundle offer
3. **Tertiary:** "Take the skin quiz" → Lead capture
### Cross-Selling
- "Customers who viewed [ingredient] also bought..."
- "Complete your routine with..."
- "Pair with [complementary ingredient] for best results"
---
## Expected Traffic & ROI
### Traffic Estimates (6-month projection)
| Dataset | Pages | Avg Monthly Searches/Page | Est. Monthly Traffic |
|---------|-------|---------------------------|---------------------|
| Ingredients | 100 | 500 | 5,000 |
| Concerns | 50 | 1,000 | 10,000 |
| Comparisons | 50 | 800 | 8,000 |
| Seasonal | 80 | 300 | 6,000 |
| Age Routines | 24 | 600 | 3,000 |
| Skin Types | 24 | 700 | 3,000 |
| Climate | 80 | 200 | 2,000 |
| **TOTAL** | **408** | **-** | **37,000** |
### Conversion Targets
- **Organic CTR:** 3-5% (industry average)
- **Page-to-Product CTR:** 15-20%
- **Product-to-Purchase:** 2-3%
- **Estimated Monthly Revenue:** €15,000-30,000 (at €50 AOV)
---
## Implementation Timeline
### Phase 1: Foundation (Weeks 1-2)
- [ ] Set up data structure
- [ ] Create 10 priority ingredient pages
- [ ] Build reusable templates
- [ ] Implement JSON-LD schemas
### Phase 2: Core Content (Weeks 3-6)
- [ ] Create 50 ingredient pages
- [ ] Create 20 concern pages
- [ ] Build comparison tool
- [ ] Add lead magnets
### Phase 3: Scale (Weeks 7-10)
- [ ] Generate all 400+ pages
- [ ] Implement internal linking
- [ ] Add dynamic OG images
- [ ] A/B test CTAs
### Phase 4: Optimize (Weeks 11-12)
- [ ] Analyze top performers
- [ ] Update underperformers
- [ ] Add user-generated content
- [ ] Expand winning categories
---
## Success Metrics
### SEO Metrics
- **Organic traffic:** 37,000+/month by month 6
- **Keyword rankings:** Top 10 for 100+ keywords
- **Featured snippets:** Capture 20+ position 0
- **Domain authority:** Increase from current baseline
### Business Metrics
- **Revenue from organic:** €15,000-30,000/month
- **Email list growth:** 1,000+ subscribers/month
- **Customer acquisition cost:** Lower than paid ads
- **Lifetime value:** Higher (organic customers retain better)
---
## Next Steps
1. **Approve dataset priorities** - Which categories to start with?
2. **Create data structure** - Set up JSON/CMS schemas
3. **Build 3 sample pages** - One from each priority category
4. **Test & iterate** - Measure performance before scaling
5. **Full production** - Generate all 400+ pages
Want me to start building the data structure and first sample pages?

View File

@@ -13,96 +13,16 @@ spec:
labels:
app: storefront
spec:
initContainers:
- name: clone
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
imagePullSecrets:
- name: ghcr-pull-secret
containers:
- name: storefront
image: node:20-slim
workingDir: /workspace
image: ghcr.io/unchainedio/manoon-headless:latest # {"": "flux-system:manoon-headless"}
imagePullPolicy: Always
command:
- npm
- start
- node
- server.js
workingDir: /app
ports:
- containerPort: 3000
env:
@@ -132,6 +52,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
@@ -157,10 +83,3 @@ spec:
port: 3000
periodSeconds: 5
failureThreshold: 3
volumeMounts:
- name: workspace
mountPath: /workspace
volumes:
- name: workspace
emptyDir:
sizeLimit: 2Gi

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,7 +3,5 @@ kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- middleware.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
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,10 +52,6 @@ const nextConfig: NextConfig = {
source: "/api/session-replay/record/:id",
destination: `${rybbitHost}/api/session-replay/record/:id`,
},
{
source: "/api/op/track",
destination: `${openpanelUrl}/track`,
},
];
},
images: {
@@ -69,7 +87,7 @@ const nextConfig: NextConfig = {
],
},
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion"],
optimizePackageImports: ["lucide-react", "framer-motion", "clsx", "motion"],
},
};

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="lazyOnload"
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

@@ -0,0 +1,106 @@
import {
getOilForConcernPage,
getAllSolutionSlugs,
getLocalizedString,
getLocalizedKeywords
} from "@/lib/programmatic-seo/dataLoader";
import { getProducts } from "@/lib/saleor";
import { OilForConcernPageTemplate } from "@/components/programmatic-seo/OilForConcernPage";
import { FAQSchema } from "@/components/programmatic-seo/FAQSchema";
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{ locale: string; slug: string }>;
}
export async function generateStaticParams() {
return await getAllSolutionSlugs();
}
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const page = await getOilForConcernPage(slug);
if (!page) {
return {
title: "Page Not Found",
};
}
const metaTitle = getLocalizedString(page.metaTitle, validLocale);
const metaDescription = getLocalizedString(page.metaDescription, validLocale);
const keywords = getLocalizedKeywords(page.seoKeywords, validLocale);
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
const canonicalUrl = `${baseUrl}${localePrefix}/solutions/${page.slug}`;
return {
title: metaTitle,
description: metaDescription,
keywords: keywords.join(", "),
alternates: {
canonical: canonicalUrl,
languages: {
"sr": `${baseUrl}/solutions/${page.slug}`,
"en": `${baseUrl}/en/solutions/${page.slug}`,
"de": `${baseUrl}/de/solutions/${page.slug}`,
"fr": `${baseUrl}/fr/solutions/${page.slug}`,
},
},
openGraph: {
title: metaTitle,
description: metaDescription,
type: "article",
url: canonicalUrl,
images: [{
url: `${baseUrl}/og-image.jpg`,
width: 1200,
height: 630,
alt: metaTitle,
}],
locale: validLocale,
},
twitter: {
card: "summary_large_image",
title: metaTitle,
description: metaDescription,
images: [`${baseUrl}/og-image.jpg`],
},
};
}
export default async function SolutionPage({ params }: PageProps) {
const { locale, slug } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const [page, products] = await Promise.all([
getOilForConcernPage(slug),
getProducts(validLocale === "sr" ? "SR" : "EN", 4)
]);
if (!page) {
notFound();
}
const basePath = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
const faqQuestions = page.faqs.map((faq) => ({
question: getLocalizedString(faq.question, validLocale),
answer: getLocalizedString(faq.answer, validLocale),
}));
return (
<>
<FAQSchema questions={faqQuestions} />
<OilForConcernPageTemplate
page={page}
locale={validLocale as Locale}
basePath={basePath}
products={products}
/>
</>
);
}

View File

@@ -0,0 +1,154 @@
import { Metadata } from "next";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronRight, Search } from "lucide-react";
import { getAllOilForConcernPages, getLocalizedString } from "@/lib/programmatic-seo/dataLoader";
type Params = Promise<{ locale: string }>;
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Solutions.ByConcern" });
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
function groupByConcern(pages: Awaited<ReturnType<typeof getAllOilForConcernPages>>) {
const concerns = new Map<string, typeof pages>();
pages.forEach((page) => {
const concernSlug = page.concernSlug;
if (!concerns.has(concernSlug)) {
concerns.set(concernSlug, []);
}
concerns.get(concernSlug)?.push(page);
});
return concerns;
}
interface ConcernCardProps {
concernSlug: string;
concernName: string;
oilCount: number;
topOils: string[];
locale: string;
}
function ConcernCard({ concernSlug, concernName, oilCount, topOils, locale }: ConcernCardProps) {
return (
<div className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group">
<h3 className="text-lg font-medium text-[#1a1a1a] mb-2">
{concernName}
</h3>
<p className="text-sm text-[#666666] mb-4">
{oilCount} {oilCount === 1 ? "oil solution" : "oil solutions"} available
</p>
<div className="space-y-2 mb-4">
{topOils.slice(0, 3).map((oilName) => (
<div key={oilName} className="flex items-center gap-2 text-sm text-[#666666]">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400" />
{oilName}
</div>
))}
</div>
<Link
href={`/${locale}/solutions/by-concern/${concernSlug}`}
className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors"
>
View All Solutions
<ChevronRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
</Link>
</div>
);
}
export default async function ByConcernPage({
params,
}: {
params: Params;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Solutions" });
const pageT = await getTranslations({ locale, namespace: "Solutions.ByConcern" });
const pages = await getAllOilForConcernPages();
const concernsMap = groupByConcern(pages);
const concernsList = Array.from(concernsMap.entries())
.map(([slug, pages]) => ({
slug,
name: getLocalizedString(pages[0].concernName, locale),
oilCount: pages.length,
topOils: pages.slice(0, 3).map((p) => getLocalizedString(p.oilName, locale)),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return (
<div className="min-h-screen bg-white">
<section className="pt-32 pb-16 lg:pt-40 lg:pb-24">
<div className="container">
<nav className="flex items-center gap-2 text-sm text-[#666666] mb-8">
<Link href={`/${locale}`} className="hover:text-black transition-colors">
{t("breadcrumb.home")}
</Link>
<ChevronRight className="w-4 h-4" />
<Link href={`/${locale}/solutions`} className="hover:text-black transition-colors">
{t("breadcrumb.solutions")}
</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-[#1a1a1a]">{t("breadcrumb.byConcern")}</span>
</nav>
<div className="max-w-3xl mb-12">
<h1 className="text-4xl lg:text-5xl font-medium tracking-tight text-[#1a1a1a] mb-6">
{pageT("title")}
</h1>
<p className="text-lg text-[#666666] leading-relaxed">
{pageT("subtitle")}
</p>
</div>
<div className="bg-[#fafafa] border border-[#e5e5e5] rounded-lg p-6 mb-12">
<div className="flex items-center gap-3 text-[#666666]">
<Search className="w-5 h-5" />
<span className="text-sm">
{pageT("stats.availableConcerns", { count: concernsList.length })}
</span>
<span className="text-[#e5e5e5]">|</span>
<span className="text-sm">
{pageT("stats.totalSolutions", { count: pages.length })}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{concernsList.map((concern) => (
<ConcernCard
key={concern.slug}
concernSlug={concern.slug}
concernName={concern.name}
oilCount={concern.oilCount}
topOils={concern.topOils}
locale={locale}
/>
))}
</div>
{concernsList.length === 0 && (
<div className="text-center py-16">
<p className="text-[#666666]">{pageT("noResults")}</p>
</div>
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { Metadata } from "next";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronRight, Droplets } from "lucide-react";
import { getAllOilForConcernPages, getLocalizedString } from "@/lib/programmatic-seo/dataLoader";
type Params = Promise<{ locale: string }>;
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Solutions.ByOil" });
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
function groupByOil(pages: Awaited<ReturnType<typeof getAllOilForConcernPages>>) {
const oils = new Map<string, typeof pages>();
pages.forEach((page) => {
const oilSlug = page.oilSlug;
if (!oils.has(oilSlug)) {
oils.set(oilSlug, []);
}
oils.get(oilSlug)?.push(page);
});
return oils;
}
interface OilCardProps {
oilSlug: string;
oilName: string;
concernCount: number;
topConcerns: string[];
locale: string;
}
function OilCard({ oilSlug, oilName, concernCount, topConcerns, locale }: OilCardProps) {
return (
<div className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
<Droplets className="w-5 h-5 text-amber-700" />
</div>
<h3 className="text-lg font-medium text-[#1a1a1a]">
{oilName}
</h3>
</div>
<p className="text-sm text-[#666666] mb-4">
{concernCount} {concernCount === 1 ? "concern solution" : "concern solutions"} available
</p>
<div className="space-y-2 mb-4">
<p className="text-xs uppercase tracking-wider text-[#999999] font-medium">
Best for:
</p>
{topConcerns.slice(0, 3).map((concernName) => (
<div key={concernName} className="flex items-center gap-2 text-sm text-[#666666]">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
{concernName}
</div>
))}
</div>
<Link
href={`/${locale}/solutions/by-oil/${oilSlug}`}
className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors"
>
Explore Oil Solutions
<ChevronRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
</Link>
</div>
);
}
export default async function ByOilPage({
params,
}: {
params: Params;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Solutions" });
const pageT = await getTranslations({ locale, namespace: "Solutions.ByOil" });
const pages = await getAllOilForConcernPages();
const oilsMap = groupByOil(pages);
const oilsList = Array.from(oilsMap.entries())
.map(([slug, pages]) => ({
slug,
name: getLocalizedString(pages[0].oilName, locale),
concernCount: pages.length,
topConcerns: pages.slice(0, 3).map((p) => getLocalizedString(p.concernName, locale)),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return (
<div className="min-h-screen bg-white">
<section className="pt-32 pb-16 lg:pt-40 lg:pb-24">
<div className="container">
<nav className="flex items-center gap-2 text-sm text-[#666666] mb-8">
<Link href={`/${locale}`} className="hover:text-black transition-colors">
{t("breadcrumb.home")}
</Link>
<ChevronRight className="w-4 h-4" />
<Link href={`/${locale}/solutions`} className="hover:text-black transition-colors">
{t("breadcrumb.solutions")}
</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-[#1a1a1a]">{t("breadcrumb.byOil")}</span>
</nav>
<div className="max-w-3xl mb-12">
<h1 className="text-4xl lg:text-5xl font-medium tracking-tight text-[#1a1a1a] mb-6">
{pageT("title")}
</h1>
<p className="text-lg text-[#666666] leading-relaxed">
{pageT("subtitle")}
</p>
</div>
<div className="bg-gradient-to-r from-amber-50 to-emerald-50 border border-[#e5e5e5] rounded-lg p-6 mb-12">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-white flex items-center justify-center shadow-sm">
<Droplets className="w-6 h-6 text-amber-600" />
</div>
<div>
<p className="text-sm text-[#666666]">
{pageT("stats.availableOils", { count: oilsList.length })}
</p>
<p className="text-sm text-[#666666]">
{pageT("stats.totalSolutions", { count: pages.length })}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{oilsList.map((oil) => (
<OilCard
key={oil.slug}
oilSlug={oil.slug}
oilName={oil.name}
concernCount={oil.concernCount}
topConcerns={oil.topConcerns}
locale={locale}
/>
))}
</div>
{oilsList.length === 0 && (
<div className="text-center py-16">
<p className="text-[#666666]">{pageT("noResults")}</p>
</div>
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { Metadata } from "next";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronRight, Sparkles, Heart, Leaf, Sun, Moon, Clock, Globe, Users, Droplets, ArrowRight } from "lucide-react";
type Params = Promise<{ locale: string }>;
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Solutions.Hub" });
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
interface CategoryCardProps {
title: string;
description: string;
href: string;
icon: React.ReactNode;
priority?: boolean;
}
function CategoryCard({ title, description, href, icon, priority }: CategoryCardProps) {
return (
<Link
href={href}
className={`group block p-6 lg:p-8 border border-[#e5e5e5] rounded-lg hover:border-black transition-all duration-300 hover:shadow-lg ${
priority ? "bg-gradient-to-br from-amber-50/50 to-white" : "bg-white"
}`}
>
<div className="flex items-start gap-4">
<div className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center ${
priority ? "bg-amber-100 text-amber-700" : "bg-[#f5f5f5] text-[#666666] group-hover:bg-black group-hover:text-white"
} transition-colors`}>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-medium text-[#1a1a1a] group-hover:text-black transition-colors">
{title}
</h3>
{priority && (
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wider font-medium bg-amber-100 text-amber-700 rounded-full">
Popular
</span>
)}
</div>
<p className="text-sm text-[#666666] leading-relaxed mb-4">
{description}
</p>
<span className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors">
{priority ? "Explore Solutions" : "Learn More"}
<ArrowRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
</span>
</div>
</div>
</Link>
);
}
interface QuickLinkProps {
title: string;
href: string;
count?: number;
}
function QuickLink({ title, href, count }: QuickLinkProps) {
return (
<Link
href={href}
className="flex items-center justify-between p-4 border-b border-[#e5e5e5] hover:bg-[#fafafa] transition-colors group"
>
<span className="text-[#1a1a1a] group-hover:text-black transition-colors">
{title}
</span>
<div className="flex items-center gap-2">
{count !== undefined && (
<span className="text-xs text-[#999999]">{count} solutions</span>
)}
<ChevronRight className="w-4 h-4 text-[#999999] group-hover:text-black transition-colors" />
</div>
</Link>
);
}
export default async function SolutionsHubPage({
params,
}: {
params: Params;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Solutions" });
const hubT = await getTranslations({ locale, namespace: "Solutions.Hub" });
const categories = [
{
title: hubT("categories.oilForConcern.title"),
description: hubT("categories.oilForConcern.description"),
href: `/${locale}/solutions/by-concern`,
icon: <Droplets className="w-5 h-5" />,
priority: true,
},
{
title: hubT("categories.ageSkinRoutine.title"),
description: hubT("categories.ageSkinRoutine.description"),
href: `/${locale}/solutions/age-skin-routine`,
icon: <Clock className="w-5 h-5" />,
},
{
title: hubT("categories.ingredientPairings.title"),
description: hubT("categories.ingredientPairings.description"),
href: `/${locale}/solutions/ingredient-pairings`,
icon: <Sparkles className="w-5 h-5" />,
},
{
title: hubT("categories.bodyPartConcerns.title"),
description: hubT("categories.bodyPartConcerns.description"),
href: `/${locale}/solutions/body-part-concerns`,
icon: <Heart className="w-5 h-5" />,
},
{
title: hubT("categories.oilComparisons.title"),
description: hubT("categories.oilComparisons.description"),
href: `/${locale}/solutions/oil-comparisons`,
icon: <Users className="w-5 h-5" />,
},
{
title: hubT("categories.routineStepSkinType.title"),
description: hubT("categories.routineStepSkinType.description"),
href: `/${locale}/solutions/routine-step-skin-type`,
icon: <Leaf className="w-5 h-5" />,
},
{
title: hubT("categories.seasonalSkincare.title"),
description: hubT("categories.seasonalSkincare.description"),
href: `/${locale}/solutions/seasonal-skincare`,
icon: <Sun className="w-5 h-5" />,
},
{
title: hubT("categories.timeOfDayConcerns.title"),
description: hubT("categories.timeOfDayConcerns.description"),
href: `/${locale}/solutions/time-of-day-concerns`,
icon: <Moon className="w-5 h-5" />,
},
{
title: hubT("categories.naturalAlternatives.title"),
description: hubT("categories.naturalAlternatives.description"),
href: `/${locale}/solutions/natural-alternatives`,
icon: <Leaf className="w-5 h-5" />,
},
{
title: hubT("categories.culturalBeautySecrets.title"),
description: hubT("categories.culturalBeautySecrets.description"),
href: `/${locale}/solutions/cultural-beauty-secrets`,
icon: <Globe className="w-5 h-5" />,
},
];
return (
<div className="min-h-screen bg-white">
<section className="pt-32 pb-16 lg:pt-40 lg:pb-24">
<div className="container">
<nav className="flex items-center gap-2 text-sm text-[#666666] mb-8">
<Link href={`/${locale}`} className="hover:text-black transition-colors">
{t("breadcrumb.home")}
</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-[#1a1a1a]">{t("breadcrumb.solutions")}</span>
</nav>
<div className="max-w-3xl">
<h1 className="text-4xl lg:text-5xl font-medium tracking-tight text-[#1a1a1a] mb-6">
{hubT("title")}
</h1>
<p className="text-lg text-[#666666] leading-relaxed">
{hubT("subtitle")}
</p>
</div>
</div>
</section>
<section className="pb-16 lg:pb-24">
<div className="container">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 lg:gap-6">
{categories.map((category) => (
<CategoryCard key={category.href} {...category} />
))}
</div>
</div>
</section>
<section className="pb-16 lg:pb-24">
<div className="container">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
<div className="border border-[#e5e5e5] rounded-lg overflow-hidden">
<div className="p-6 bg-[#fafafa] border-b border-[#e5e5e5]">
<h2 className="text-lg font-medium text-[#1a1a1a]">
{hubT("quickAccess.byConcern")}
</h2>
<p className="text-sm text-[#666666] mt-1">
{hubT("quickAccess.byConcernDesc")}
</p>
</div>
<div className="divide-y divide-[#e5e5e5]">
<QuickLink
title={hubT("quickAccess.links.wrinkles")}
href={`/${locale}/solutions/by-concern/wrinkles`}
/>
<QuickLink
title={hubT("quickAccess.links.acne")}
href={`/${locale}/solutions/by-concern/acne`}
/>
<QuickLink
title={hubT("quickAccess.links.drySkin")}
href={`/${locale}/solutions/by-concern/dry-skin`}
/>
<QuickLink
title={hubT("quickAccess.links.darkSpots")}
href={`/${locale}/solutions/by-concern/dark-spots`}
/>
<QuickLink
title={hubT("quickAccess.links.viewAll")}
href={`/${locale}/solutions/by-concern`}
/>
</div>
</div>
<div className="border border-[#e5e5e5] rounded-lg overflow-hidden">
<div className="p-6 bg-[#fafafa] border-b border-[#e5e5e5]">
<h2 className="text-lg font-medium text-[#1a1a1a]">
{hubT("quickAccess.byOil")}
</h2>
<p className="text-sm text-[#666666] mt-1">
{hubT("quickAccess.byOilDesc")}
</p>
</div>
<div className="divide-y divide-[#e5e5e5]">
<QuickLink
title={hubT("quickAccess.links.rosehipOil")}
href={`/${locale}/solutions/by-oil/rosehip-oil`}
/>
<QuickLink
title={hubT("quickAccess.links.arganOil")}
href={`/${locale}/solutions/by-oil/argan-oil`}
/>
<QuickLink
title={hubT("quickAccess.links.jojobaOil")}
href={`/${locale}/solutions/by-oil/jojoba-oil`}
/>
<QuickLink
title={hubT("quickAccess.links.seaBuckthornOil")}
href={`/${locale}/solutions/by-oil/sea-buckthorn-oil`}
/>
<QuickLink
title={hubT("quickAccess.links.viewAll")}
href={`/${locale}/solutions/by-oil`}
/>
</div>
</div>
</div>
</div>
</section>
<section className="pb-16 lg:pb-24">
<div className="container">
<div className="bg-[#1a1a1a] rounded-2xl p-8 lg:p-12 text-center">
<h2 className="text-2xl lg:text-3xl font-medium text-white mb-4">
{hubT("cta.title")}
</h2>
<p className="text-[#999999] max-w-xl mx-auto mb-8">
{hubT("cta.description")}
</p>
<Link
href={`/${locale}/products`}
className="inline-flex items-center justify-center px-8 py-3 bg-white text-[#1a1a1a] font-medium rounded-full hover:bg-[#f5f5f5] transition-colors"
>
{hubT("cta.button")}
</Link>
</div>
</div>
</section>
</div>
);
}

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,65 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const OPENPANEL_API_URL = process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api";
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"openpanel-client-id": process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
};
if (process.env.OPENPANEL_CLIENT_SECRET) {
headers["openpanel-client-secret"] = process.env.OPENPANEL_CLIENT_SECRET;
}
const response = await fetch(`${OPENPANEL_API_URL}/track`, {
method: "POST",
headers,
body,
});
const data = await response.text();
return new NextResponse(data, {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("[OpenPanel Proxy] Error:", error);
return new NextResponse(JSON.stringify({ error: "Proxy error" }), {
status: 500,
});
}
}
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const path = url.searchParams.get("path") || "";
try {
const response = await fetch(`${OPENPANEL_API_URL}/track/${path}`, {
method: "GET",
headers: {
"openpanel-client-id": process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
},
});
const data = await response.text();
return new NextResponse(data, {
status: response.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("[OpenPanel Proxy] Error:", error);
return new NextResponse(JSON.stringify({ error: "Proxy error" }), {
status: 500,
});
}
}

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

@@ -1,5 +1,6 @@
import { MetadataRoute } from "next";
import { getProducts, filterOutBundles } from "@/lib/saleor";
import { getAllOilForConcernPages } from "@/lib/programmatic-seo/dataLoader";
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
@@ -105,5 +106,35 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
}
}
return [...staticPages, ...productUrls];
let solutionPages: any[] = [];
try {
solutionPages = await getAllOilForConcernPages();
} catch (e) {
console.log("Failed to fetch solution pages for sitemap during build");
}
const solutionUrls: SitemapEntry[] = [];
for (const page of solutionPages) {
const hreflangs: Record<string, string> = {};
for (const locale of SUPPORTED_LOCALES) {
const path = locale === "sr" ? `/solutions/${page.slug}` : `/${locale}/solutions/${page.slug}`;
hreflangs[locale] = `${baseUrl}${path}`;
}
for (const locale of SUPPORTED_LOCALES) {
const localePrefix = locale === "sr" ? "" : `/${locale}`;
solutionUrls.push({
url: `${baseUrl}${localePrefix}/solutions/${page.slug}`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.7,
alternates: {
languages: hreflangs,
},
});
}
}
return [...staticPages, ...productUrls, ...solutionUrls];
}

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,108 @@
"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 contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
const text = await response.text();
if (text && text.trim()) {
const data = JSON.parse(text);
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,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

@@ -21,6 +21,12 @@ export default function Footer({ locale = "sr" }: FooterProps) {
{ label: t("skinCare"), href: `${localePath}/products` },
{ label: t("giftSets"), href: `${localePath}/products` },
],
solutions: [
{ label: t("allSolutions"), href: `${localePath}/solutions` },
{ label: t("byConcern"), href: `${localePath}/solutions/by-concern` },
{ label: t("byOil"), href: `${localePath}/solutions/by-oil` },
{ label: t("skincareGuide"), href: `${localePath}/solutions` },
],
about: [
{ label: t("ourStory"), href: `${localePath}/about` },
{ label: t("process"), href: `${localePath}/about` },
@@ -74,7 +80,7 @@ export default function Footer({ locale = "sr" }: FooterProps) {
</div>
<div className="lg:col-span-8">
<div className="grid grid-cols-2 md:grid-cols-3 gap-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
<div className="flex flex-col">
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
{t("shop")}
@@ -93,6 +99,24 @@ export default function Footer({ locale = "sr" }: FooterProps) {
</ul>
</div>
<div className="flex flex-col">
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
{t("solutions")}
</h4>
<ul className="space-y-3">
{footerLinks.solutions.map((link) => (
<li key={link.label}>
<Link
href={link.href}
className="text-sm text-[#666666] hover:text-black transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div className="flex flex-col">
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
{t("about")}

View File

@@ -38,6 +38,7 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
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]">

View File

@@ -0,0 +1,16 @@
import { generateFAQPageSchema } from "@/lib/programmatic-seo/schema";
interface FAQSchemaProps {
questions: Array<{ question: string; answer: string }>;
}
export function FAQSchema({ questions }: FAQSchemaProps) {
const schema = generateFAQPageSchema(questions);
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}

View File

@@ -0,0 +1,157 @@
import type { Locale } from "@/lib/i18n/locales";
import type { OilForConcernPage } from "@/lib/programmatic-seo/types";
import type { Product } from "@/types/saleor";
import { getLocalizedString, getLocalizedArray } from "@/lib/programmatic-seo/dataLoader";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductReviews from "@/components/product/ProductReviews";
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import ProductsGrid from "./ProductsGrid";
import Link from "next/link";
import { ArrowRight, Check, Clock, Droplets } from "lucide-react";
interface OilForConcernPageProps {
page: OilForConcernPage;
locale: Locale;
basePath: string;
products: Product[];
}
export function OilForConcernPageTemplate({ page, locale, basePath, products }: OilForConcernPageProps) {
const pageTitle = getLocalizedString(page.pageTitle, locale);
const oilName = getLocalizedString(page.oilName, locale);
const concernName = getLocalizedString(page.concernName, locale);
const whyThisWorks = getLocalizedString(page.whyThisWorks, locale);
const keyBenefits = getLocalizedArray(page.keyBenefits, locale);
const howToApply = getLocalizedArray(page.howToApply, locale);
const expectedResults = getLocalizedString(page.expectedResults, locale);
const timeframe = getLocalizedString(page.timeframe, locale);
const productsHref = locale === "sr" ? "/products" : `/${locale}/products`;
return (
<>
<Header locale={locale} />
<main className="min-h-screen bg-white">
<section className="bg-[#FAF9F7] pt-[180px] lg:pt-[200px] pb-16">
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-[#E8DFD0] rounded-full mb-6">
<Droplets className="w-4 h-4 text-[#8B7355]" />
<span className="text-sm text-[#5C4D3C] font-medium">
{locale === "sr" ? "Prirodno rešenje" :
locale === "de" ? "Natürliche Lösung" :
locale === "fr" ? "Solution naturelle" : "Natural Solution"}
</span>
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-light text-[#1A1A1A] mb-6 leading-tight">
{pageTitle}
</h1>
<p className="text-lg text-[#666666] max-w-2xl mx-auto mb-8">
{whyThisWorks.substring(0, 150)}...
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href={productsHref}
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-[#1A1A1A] text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
>
{locale === "sr" ? "Kupi proizvode sa " :
locale === "de" ? "Produkte mit " :
locale === "fr" ? "Acheter des produits avec " : "Shop products with "}
{oilName}
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
</section>
<ProductReviews locale={locale} productName={oilName} />
<section className="py-16 lg:py-24">
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div>
<h2 className="text-3xl md:text-4xl font-light text-[#1A1A1A] mb-6">
{locale === "sr" ? "Zašto " :
locale === "de" ? "Warum " :
locale === "fr" ? "Pourquoi " : "Why "}
{oilName}
{locale === "sr" ? " deluje protiv " :
locale === "de" ? " gegen " :
locale === "fr" ? " contre " : " works for "}
{concernName.toLowerCase()}
</h2>
<p className="text-[#666666] text-lg leading-relaxed mb-8">
{whyThisWorks}
</p>
<div className="flex items-center gap-3 p-4 bg-[#F8F7F5] rounded-lg">
<Clock className="w-5 h-5 text-[#8B7355]" />
<div>
<span className="text-sm font-medium text-[#1A1A1A]">
{locale === "sr" ? "Vreme rezultata: " :
locale === "de" ? "Zeit bis zum Ergebnis: " :
locale === "fr" ? "Délai des résultats: " : "Results timeframe: "}
</span>
<span className="text-sm text-[#666666]">{timeframe}</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{keyBenefits.slice(0, 4).map((benefit, index) => (
<div key={index} className="p-6 bg-[#FAF9F7] rounded-lg">
<Check className="w-6 h-6 text-[#8B7355] mb-3" />
<p className="text-[#1A1A1A] font-medium">{benefit}</p>
</div>
))}
</div>
</div>
</div>
</section>
<BeforeAfterGallery />
<section className="py-16 lg:py-24">
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl md:text-4xl font-light text-[#1A1A1A] text-center mb-12">
{locale === "sr" ? "Kako koristiti" :
locale === "de" ? "Anwendung" :
locale === "fr" ? "Comment utiliser" : "How to use"}
</h2>
<div className="max-w-3xl mx-auto">
<div className="space-y-6">
{howToApply.map((step, index) => (
<div key={index} className="flex gap-4 items-start">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-[#1A1A1A] text-white flex items-center justify-center font-medium">
{index + 1}
</div>
<div className="pt-2">
<p className="text-[#1A1A1A] text-lg">{step}</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
<section className="py-16 lg:py-24 bg-[#FAF9F7]">
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl md:text-4xl font-light text-[#1A1A1A] mb-6">
{locale === "sr" ? "Šta možete očekivati" :
locale === "de" ? "Was Sie erwarten können" :
locale === "fr" ? "Ce que vous pouvez attendre" : "What to expect"}
</h2>
<p className="text-lg text-[#666666] leading-relaxed">
{expectedResults}
</p>
</div>
</div>
</section>
<ProductsGrid products={products} locale={locale} />
</main>
<Footer locale={locale} />
</>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import { useState } from "react";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { useAnalytics } from "@/lib/analytics";
import type { Product } from "@/types/saleor";
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
import { useTranslations } from "next-intl";
import Link from "next/link";
interface ProductsGridProps {
products: Product[];
locale: string;
}
function ProductCardWithAddToCart({ product, index, locale }: { product: Product; index: number; locale: string }) {
const t = useTranslations("ProductCard");
const tProduct = useTranslations("Product");
const [isAdding, setIsAdding] = useState(false);
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
const { trackAddToCart } = useAnalytics();
const image = getProductImage(product);
const price = getProductPrice(product);
const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
const localized = getLocalizedProduct(product, saleorLocale);
const variant = product.variants?.[0];
const isAvailable = (variant?.quantityAvailable || 0) > 0;
const productHref = locale === "sr" ? `/products/${localized.slug}` : `/${locale}/products/${localized.slug}`;
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!variant?.id) return;
if (isValidLocale(locale)) {
setLanguageCode(locale);
}
setIsAdding(true);
try {
await addLine(variant.id, 1);
const priceAmount = variant?.pricing?.price?.gross?.amount || 0;
const currency = variant?.pricing?.price?.gross?.currency || "RSD";
trackAddToCart({
id: product.id,
name: localized.name,
price: priceAmount,
currency,
quantity: 1,
variant: variant.name,
});
openCart();
} finally {
setIsAdding(false);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="group"
>
<Link href={productHref} className="block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? (
<Image
src={image}
alt={localized.name}
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]">
<span className="text-sm">{t("noImage")}</span>
</div>
)}
{!isAvailable && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
<span className="text-sm uppercase tracking-[0.1em] text-[#666666]">
{t("outOfStock")}
</span>
</div>
)}
</div>
</Link>
<div className="text-center">
<Link href={productHref}>
<h3 className="text-[15px] font-medium text-[#1a1a1a] mb-1 group-hover:text-[#666666] transition-colors line-clamp-1">
{localized.name}
</h3>
</Link>
<p className="text-[14px] text-[#666666] mb-3">
{price || t("contactForPrice")}
</p>
{isAvailable ? (
<button
onClick={handleAddToCart}
disabled={isAdding}
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAdding ? tProduct("adding") : tProduct("addToCart")}
</button>
) : (
<div className="w-full py-3 bg-[#f8f9fa] text-[#666666] text-xs uppercase tracking-[0.1em]">
{t("outOfStock")}
</div>
)}
</div>
</motion.div>
);
}
export default function ProductsGrid({ products, locale }: ProductsGridProps) {
const t = useTranslations("Solutions");
const validProducts = products.filter(p => p && p.id);
return (
<section className="py-16 lg:py-24 bg-[#1A1A1A]">
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-light text-white mb-4">
{t("completeYourRoutine")}
</h2>
<p className="text-[#999999] max-w-2xl mx-auto">
{t("discoverProducts")}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
{validProducts.map((product, index) => (
<ProductCardWithAddToCart
key={product.id}
product={product}
index={index}
locale={locale}
/>
))}
</div>
</div>
</section>
);
}

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,57 @@
"use client";
import Link from "next/link";
import { ChevronRight, Home } from "lucide-react";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
locale: string;
showHome?: boolean;
}
export default function Breadcrumb({ items, locale, showHome = true }: BreadcrumbProps) {
const allItems = showHome
? [{ label: "Home", href: `/${locale}` }, ...items]
: items;
return (
<nav className="flex items-center gap-2 text-sm text-[#666666]" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 flex-wrap">
{allItems.map((item, index) => {
const isLast = index === allItems.length - 1;
return (
<li key={index} className="flex items-center gap-2">
{index > 0 && <ChevronRight className="w-4 h-4 flex-shrink-0" />}
{isLast || !item.href ? (
<span className={isLast ? "text-[#1a1a1a]" : ""} aria-current={isLast ? "page" : undefined}>
{index === 0 && showHome ? (
<Home className="w-4 h-4" />
) : (
item.label
)}
</span>
) : (
<Link
href={item.href}
className="hover:text-black transition-colors"
>
{index === 0 && showHome ? (
<Home className="w-4 h-4" />
) : (
item.label
)}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}

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",
@@ -158,6 +180,11 @@
"hairCare": "Haarpflege",
"skinCare": "Hautpflege",
"giftSets": "Geschenksets",
"solutions": "Lösungen",
"allSolutions": "Alle Lösungen",
"byConcern": "Nach Problem",
"byOil": "Nach Öl",
"skincareGuide": "Hautpflege-Guide",
"about": "Über uns",
"ourStory": "Unsere Geschichte",
"process": "Prozess",
@@ -288,6 +315,10 @@
"quickAdd": "Schnell hinzufügen",
"contactForPrice": "Preis anfragen"
},
"Product": {
"adding": "Wird hinzugefügt...",
"addToCart": "In den Warenkorb"
},
"ProductDetail": {
"home": "Startseite",
"outOfStock": "Nicht auf Lager",
@@ -417,5 +448,107 @@
"description": "Bezahlen Sie per Banküberweisung",
"comingSoon": "Demnächst verfügbar"
}
},
"Solutions": {
"breadcrumb": {
"home": "Startseite",
"solutions": "Lösungen",
"byConcern": "Nach Problem",
"byOil": "Nach Öl"
},
"Hub": {
"metaTitle": "Natürliche Hautpflege-Lösungen | ManoonOils",
"metaDescription": "Entdecken Sie natürliche Öl-Lösungen für jedes Hautproblem. Durchsuchen Sie nach Problem, Öltyp oder erkunden Sie unsere umfassenden Hautpflege-Guides.",
"title": "Natürliche Hautpflege-Lösungen",
"subtitle": "Entdecken Sie die perfekten natürlichen Öl-Lösungen für Ihre Hautprobleme. Unsere fachkundig erstellten Guides helfen Ihnen, die richtigen Öle für Falten, Akne, Trockenheit und mehr zu finden.",
"categories": {
"oilForConcern": {
"title": "Öl für Problem",
"description": "Finden Sie die besten natürlichen Öle für spezifische Hautprobleme wie Falten, Akne, dunkle Flecken und mehr."
},
"ageSkinRoutine": {
"title": "Alter-Haut Routine",
"description": "Personalisierte Hautpflege-Routinen basierend auf Ihrem Alter und Hauttyp für optimale Ergebnisse."
},
"ingredientPairings": {
"title": "Inhaltsstoff-Kombinationen",
"description": "Lernen Sie, welche natürlichen Inhaltsstoffe am besten zusammenwirken für verbesserte Hautpflege-Vorteile."
},
"bodyPartConcerns": {
"title": "Körperteil-Probleme",
"description": "Gezielte Lösungen für spezifische Körperbereiche wie Gesicht, Hals, Hände und mehr."
},
"oilComparisons": {
"title": "Öl-Vergleiche",
"description": "Vergleiche nebeneinander, um Ihnen bei der Wahl zwischen verschiedenen natürlichen Ölen zu helfen."
},
"routineStepSkinType": {
"title": "Routine nach Hauttyp",
"description": "Schritt-für-Schritt-Anleitung, zugeschnitten auf Ihren spezifischen Hauttyp und Ihre Probleme."
},
"seasonalSkincare": {
"title": "Saisonale Hautpflege",
"description": "Passen Sie Ihre Routine den Jahreszeiten an für gesunde Haut das ganze Jahr über."
},
"timeOfDayConcerns": {
"title": "Tageszeit",
"description": "Morgen- und Abend-Hautpflege-Routinen für maximale Wirksamkeit."
},
"naturalAlternatives": {
"title": "Natürliche Alternativen",
"description": "Entdecken Sie natürliche Alternativen zu synthetischen Hautpflege-Inhaltsstoffen."
},
"culturalBeautySecrets": {
"title": "Kulturelle Schönheitsgeheimnisse",
"description": "Uralte Schönheitsweisheit aus der ganzen Welt mit natürlichen Ölen."
}
},
"quickAccess": {
"byConcern": "Nach Problem durchsuchen",
"byConcernDesc": "Finden Sie Lösungen für Ihre spezifischen Hautprobleme",
"byOil": "Nach Öl durchsuchen",
"byOilDesc": "Erkunden Sie die Vorteile verschiedener natürlicher Öle",
"links": {
"wrinkles": "Falten & Aging",
"acne": "Akne & Unreinheiten",
"drySkin": "Trockene Haut",
"darkSpots": "Dunkle Flecken",
"viewAll": "Alle ansehen →",
"rosehipOil": "Hagebuttenöl",
"arganOil": "Arganöl",
"jojobaOil": "Jojobaöl",
"seaBuckthornOil": "Sanddornöl"
}
},
"cta": {
"title": "Bereit, Ihre Haut zu verwandeln?",
"description": "Durchstöbern Sie unsere Kollektion von Premium-Naturölen und beginnen Sie noch heute Ihre Reise zu gesünderer, strahlender Haut.",
"button": "Naturöle kaufen"
}
},
"ByConcern": {
"metaTitle": "Hautpflege-Lösungen nach Problem | ManoonOils",
"metaDescription": "Durchsuchen Sie natürliche Öl-Lösungen nach Hautproblem organisiert. Finden Sie das perfekte Heilmittel für Falten, Akne, Trockenheit und mehr.",
"title": "Lösungen nach Problem",
"subtitle": "Erkunden Sie unsere umfassende Kollektion natürlicher Öl-Lösungen, organisiert nach Hautproblem, um Ihnen zu helfen, genau das zu finden, was Sie brauchen.",
"stats": {
"availableConcerns": "{count} Hautprobleme abgedeckt",
"totalSolutions": "{count} fachkundig kuratierte Lösungen"
},
"noResults": "Keine Probleme gefunden. Bitte schauen Sie später für neue Lösungen vorbei."
},
"ByOil": {
"metaTitle": "Hautpflege-Lösungen nach Öl | ManoonOils",
"metaDescription": "Entdecken Sie die Vorteile verschiedener natürlicher Öle für verschiedene Hautprobleme. Finden Sie heraus, welches Öl das richtige für Sie ist.",
"title": "Lösungen nach Öl",
"subtitle": "Lernen Sie die einzigartigen Eigenschaften jedes natürlichen Öls kennen und entdecken Sie, welche am besten für Ihre Hautprobleme geeignet sind.",
"stats": {
"availableOils": "{count} natürliche Öle",
"totalSolutions": "{count} fachkundig kuratierte Lösungen"
},
"noResults": "Keine Öle gefunden. Bitte schauen Sie später für neue Lösungen vorbei."
},
"completeYourRoutine": "Vervollständigen Sie Ihre Routine",
"discoverProducts": "Entdecken Sie unsere Premium-Produkte mit natürlichen Inhaltsstoffen"
}
}

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",
@@ -297,6 +319,11 @@
"hairCare": "Hair Care",
"skinCare": "Skin Care",
"giftSets": "Gift Sets",
"solutions": "Solutions",
"allSolutions": "All Solutions",
"byConcern": "By Concern",
"byOil": "By Oil",
"skincareGuide": "Skincare Guide",
"about": "About",
"ourStory": "Our Story",
"process": "Process",
@@ -317,6 +344,10 @@
"quickAdd": "Quick Add",
"contactForPrice": "Contact for price"
},
"Product": {
"adding": "Adding...",
"addToCart": "Add to Cart"
},
"ProductDetail": {
"home": "Home",
"outOfStock": "Out of Stock",
@@ -464,5 +495,115 @@
"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."
},
"Solutions": {
"breadcrumb": {
"home": "Home",
"solutions": "Solutions",
"byConcern": "By Concern",
"byOil": "By Oil"
},
"Hub": {
"metaTitle": "Natural Skincare Solutions | ManoonOils",
"metaDescription": "Discover natural oil solutions for every skin concern. Browse by concern, oil type, or explore our comprehensive skincare guides.",
"title": "Natural Skincare Solutions",
"subtitle": "Discover the perfect natural oil solutions for your skin concerns. Our expertly crafted guides help you find the right oils for wrinkles, acne, dryness, and more.",
"categories": {
"oilForConcern": {
"title": "Oil for Concern",
"description": "Find the best natural oils for specific skin concerns like wrinkles, acne, dark spots, and more."
},
"ageSkinRoutine": {
"title": "Age-Skin Routine",
"description": "Personalized skincare routines based on your age and skin type for optimal results."
},
"ingredientPairings": {
"title": "Ingredient Pairings",
"description": "Learn which natural ingredients work best together for enhanced skincare benefits."
},
"bodyPartConcerns": {
"title": "Body Part Concerns",
"description": "Targeted solutions for specific body areas like face, neck, hands, and more."
},
"oilComparisons": {
"title": "Oil Comparisons",
"description": "Side-by-side comparisons to help you choose between different natural oils."
},
"routineStepSkinType": {
"title": "Routine Step by Skin Type",
"description": "Step-by-step guidance tailored to your specific skin type and concerns."
},
"seasonalSkincare": {
"title": "Seasonal Skincare",
"description": "Adjust your routine with the seasons for year-round healthy skin."
},
"timeOfDayConcerns": {
"title": "Time of Day Concerns",
"description": "Morning and evening skincare routines for maximum effectiveness."
},
"naturalAlternatives": {
"title": "Natural Alternatives",
"description": "Discover natural alternatives to synthetic skincare ingredients."
},
"culturalBeautySecrets": {
"title": "Cultural Beauty Secrets",
"description": "Ancient beauty wisdom from around the world using natural oils."
}
},
"quickAccess": {
"byConcern": "Browse by Concern",
"byConcernDesc": "Find solutions for your specific skin concerns",
"byOil": "Browse by Oil",
"byOilDesc": "Explore benefits of different natural oils",
"links": {
"wrinkles": "Wrinkles & Aging",
"acne": "Acne & Blemishes",
"drySkin": "Dry Skin",
"darkSpots": "Dark Spots",
"viewAll": "View All →",
"rosehipOil": "Rosehip Oil",
"arganOil": "Argan Oil",
"jojobaOil": "Jojoba Oil",
"seaBuckthornOil": "Sea Buckthorn Oil"
}
},
"cta": {
"title": "Ready to Transform Your Skin?",
"description": "Browse our collection of premium natural oils and start your journey to healthier, more radiant skin today.",
"button": "Shop Natural Oils"
}
},
"ByConcern": {
"metaTitle": "Skincare Solutions by Concern | ManoonOils",
"metaDescription": "Browse natural oil solutions organized by skin concern. Find the perfect remedy for wrinkles, acne, dryness, and more.",
"title": "Solutions by Concern",
"subtitle": "Explore our comprehensive collection of natural oil solutions, organized by skin concern to help you find exactly what you need.",
"stats": {
"availableConcerns": "{count} skin concerns covered",
"totalSolutions": "{count} expert-curated solutions"
},
"noResults": "No concerns found. Please check back later for new solutions."
},
"ByOil": {
"metaTitle": "Skincare Solutions by Oil | ManoonOils",
"metaDescription": "Discover the benefits of different natural oils for various skin concerns. Find which oil is right for you.",
"title": "Solutions by Oil",
"subtitle": "Learn about the unique properties of each natural oil and discover which ones are best suited for your skin concerns.",
"stats": {
"availableOils": "{count} natural oils",
"totalSolutions": "{count} expert-curated solutions"
},
"noResults": "No oils found. Please check back later for new solutions."
},
"completeYourRoutine": "Complete Your Routine",
"discoverProducts": "Discover our premium products with natural ingredients"
}
}

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",
@@ -158,6 +180,11 @@
"hairCare": "Soins Capillaires",
"skinCare": "Soins Cutanés",
"giftSets": "Coffrets Cadeaux",
"solutions": "Solutions",
"allSolutions": "Toutes les Solutions",
"byConcern": "Par Problème",
"byOil": "Par Huile",
"skincareGuide": "Guide Soins",
"about": "À Propos",
"ourStory": "Notre Histoire",
"process": "Processus",
@@ -288,6 +315,10 @@
"quickAdd": "Ajout Rapide",
"contactForPrice": "Contacter pour le prix"
},
"Product": {
"adding": "Ajout en cours...",
"addToCart": "Ajouter au Panier"
},
"ProductDetail": {
"home": "Accueil",
"outOfStock": "Rupture de Stock",
@@ -417,5 +448,107 @@
"description": "Payez par virement bancaire",
"comingSoon": "Bientôt disponible"
}
},
"Solutions": {
"breadcrumb": {
"home": "Accueil",
"solutions": "Solutions",
"byConcern": "Par Problème",
"byOil": "Par Huile"
},
"Hub": {
"metaTitle": "Solutions Naturelles pour la Peau | ManoonOils",
"metaDescription": "Découvrez les solutions à base d'huiles naturelles pour chaque problème de peau. Parcourez par problème, type d'huile ou explorez nos guides complets de soins.",
"title": "Solutions Naturelles pour la Peau",
"subtitle": "Découvrez les solutions d'huiles naturelles parfaites pour vos problèmes de peau. Nos guides créés par des experts vous aident à trouver les bonnes huiles pour les rides, l'acné, la sécheresse et plus encore.",
"categories": {
"oilForConcern": {
"title": "Huile pour Problème",
"description": "Trouvez les meilleures huiles naturelles pour des problèmes de peau spécifiques comme les rides, l'acné, les taches sombres et plus encore."
},
"ageSkinRoutine": {
"title": "Routine Âge-Peau",
"description": "Routines de soins personnalisées basées sur votre âge et type de peau pour des résultats optimaux."
},
"ingredientPairings": {
"title": "Associations d'Ingrédients",
"description": "Apprenez quels ingrédients naturels fonctionnent le mieux ensemble pour des bienfaits améliorés."
},
"bodyPartConcerns": {
"title": "Problèmes par Partie du Corps",
"description": "Solutions ciblées pour des zones spécifiques comme le visage, le cou, les mains et plus encore."
},
"oilComparisons": {
"title": "Comparaisons d'Huiles",
"description": "Comparaisons côte à côte pour vous aider à choisir entre différentes huiles naturelles."
},
"routineStepSkinType": {
"title": "Routine par Type de Peau",
"description": "Guide étape par étape adapté à votre type de peau spécifique et à vos problèmes."
},
"seasonalSkincare": {
"title": "Soins Saisonniers",
"description": "Adaptez votre routine aux saisons pour une peau saine toute l'année."
},
"timeOfDayConcerns": {
"title": "Moment de la Journée",
"description": "Routines de soins matinales et du soir pour une efficacité maximale."
},
"naturalAlternatives": {
"title": "Alternatives Naturelles",
"description": "Découvrez des alternatives naturelles aux ingrédients synthétiques de soins."
},
"culturalBeautySecrets": {
"title": "Secrets de Beauté Culturels",
"description": "Sagesse beauté ancestrale du monde entier utilisant des huiles naturelles."
}
},
"quickAccess": {
"byConcern": "Parcourir par Problème",
"byConcernDesc": "Trouvez des solutions pour vos problèmes de peau spécifiques",
"byOil": "Parcourir par Huile",
"byOilDesc": "Explorez les bienfaits des différentes huiles naturelles",
"links": {
"wrinkles": "Rides & Vieillissement",
"acne": "Acné & Imperfections",
"drySkin": "Peau Sèche",
"darkSpots": "Taches Sombres",
"viewAll": "Voir Tout →",
"rosehipOil": "Huile de Rose Musquée",
"arganOil": "Huile d'Argan",
"jojobaOil": "Huile de Jojoba",
"seaBuckthornOil": "Huile d'Argousier"
}
},
"cta": {
"title": "Prêt à Transformer Votre Peau?",
"description": "Parcourez notre collection d'huiles naturelles premium et commencez votre voyage vers une peau plus saine et éclatante dès aujourd'hui.",
"button": "Acheter les Huiles Naturelles"
}
},
"ByConcern": {
"metaTitle": "Solutions Soins par Problème | ManoonOils",
"metaDescription": "Parcourez les solutions d'huiles naturelles organisées par problème de peau. Trouvez le remède parfait pour les rides, l'acné, la sécheresse et plus encore.",
"title": "Solutions par Problème",
"subtitle": "Explorez notre collection complète de solutions d'huiles naturelles, organisées par problème de peau pour vous aider à trouver exactement ce dont vous avez besoin.",
"stats": {
"availableConcerns": "{count} problèmes de peau couverts",
"totalSolutions": "{count} solutions sélectionnées par des experts"
},
"noResults": "Aucun problème trouvé. Veuillez vérifier plus tard pour de nouvelles solutions."
},
"ByOil": {
"metaTitle": "Solutions Soins par Huile | ManoonOils",
"metaDescription": "Découvrez les bienfaits des différentes huiles naturelles pour divers problèmes de peau. Trouvez quelle huile est la bonne pour vous.",
"title": "Solutions par Huile",
"subtitle": "Apprenez les propriétés uniques de chaque huile naturelle et découvrez lesquelles conviennent le mieux à vos problèmes de peau.",
"stats": {
"availableOils": "{count} huiles naturelles",
"totalSolutions": "{count} solutions sélectionnées par des experts"
},
"noResults": "Aucune huile trouvée. Veuillez vérifier plus tard pour de nouvelles solutions."
},
"completeYourRoutine": "Complétez votre routine",
"discoverProducts": "Découvrez nos produits premium aux ingrédients naturels"
}
}

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",
@@ -297,6 +319,11 @@
"hairCare": "Nega kose",
"skinCare": "Nega kože",
"giftSets": "Poklon setovi",
"solutions": "Rešenja",
"allSolutions": "Sva rešenja",
"byConcern": "Po problemu",
"byOil": "Po ulju",
"skincareGuide": "Vodič za negu",
"about": "O nama",
"ourStory": "Naša priča",
"process": "Proces",
@@ -313,10 +340,14 @@
},
"ProductCard": {
"noImage": "Nema slike",
"outOfStock": "Nema na stanju",
"outOfStock": "Nema na zalihama",
"quickAdd": "Brzo dodavanje",
"contactForPrice": "Kontaktirajte za cenu"
},
"Product": {
"adding": "Dodavanje...",
"addToCart": "Dodaj u korpu"
},
"ProductDetail": {
"home": "Početna",
"outOfStock": "Nema na stanju",
@@ -463,5 +494,115 @@
"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ć."
},
"Solutions": {
"breadcrumb": {
"home": "Početna",
"solutions": "Rešenja",
"byConcern": "Po problemu",
"byOil": "Po ulju"
},
"Hub": {
"metaTitle": "Prirodna rešenja za negu kože | ManoonOils",
"metaDescription": "Otkrijte prirodna uljana rešenja za svaki problem kože. Pretražujte po problemu, vrsti ulja ili istražite naše sveobuhvatne vodiče za negu kože.",
"title": "Prirodna rešenja za negu kože",
"subtitle": "Otkrijte savršena prirodna uljana rešenja za vaše probleme sa kožom. Naši stručno izrađeni vodiči pomažu vam da pronađete prava ulja za bore, akne, suvu kožu i još mnogo toga.",
"categories": {
"oilForConcern": {
"title": "Ulje za problem",
"description": "Pronađite najbolja prirodna ulja za specifične probleme kože poput bora, akni, tamnih fleka i još mnogo toga."
},
"ageSkinRoutine": {
"title": "Rutina prema uzrastu",
"description": "Personalizovane rutine nege kože na osnovu vašeg uzrasta i tipa kože za optimalne rezultate."
},
"ingredientPairings": {
"title": "Kombinacije sastojaka",
"description": "Saznajte koji prirodni sastojci najbolje rade zajedno za poboljšane koristi za kožu."
},
"bodyPartConcerns": {
"title": "Problemi po delovima tela",
"description": "Ciljana rešenja za specifične delove tela poput lica, vrata, ruku i još mnogo toga."
},
"oilComparisons": {
"title": "Poređenje ulja",
"description": "Poređenja jedno pored drugog da vam pomognu da izaberete između različitih prirodnih ulja."
},
"routineStepSkinType": {
"title": "Rutina prema tipu kože",
"description": "Vodič korak po korak prilagođen vašem specifičnom tipu kože i problemima."
},
"seasonalSkincare": {
"title": "Sezonska nega kože",
"description": "Prilagodite svoju rutinu godišnjim dobima za zdravu kožu tokom cele godine."
},
"timeOfDayConcerns": {
"title": "Vreme dana",
"description": "Jutarnje i večernje rutine nege kože za maksimalnu efikasnost."
},
"naturalAlternatives": {
"title": "Prirodne alternative",
"description": "Otkrijte prirodne alternative sintetičkim sastojcima za negu kože."
},
"culturalBeautySecrets": {
"title": "Kulturne tajne lepote",
"description": "Drevna mudrost lepote iz celog sveta korišćenjem prirodnih ulja."
}
},
"quickAccess": {
"byConcern": "Pretraži po problemu",
"byConcernDesc": "Pronađi rešenja za svoje probleme sa kožom",
"byOil": "Pretraži po ulju",
"byOilDesc": "Istraži prednosti različitih prirodnih ulja",
"links": {
"wrinkles": "Bore i starenje",
"acne": "Akne i nesavršenstva",
"drySkin": "Suva koža",
"darkSpots": "Tamne fleke",
"viewAll": "Pogledaj sve →",
"rosehipOil": "Ulje divlje ruže",
"arganOil": "Arganovo ulje",
"jojobaOil": "Jojoba ulje",
"seaBuckthornOil": "Ulje pasjeg trna"
}
},
"cta": {
"title": "Spremni za transformaciju kože?",
"description": "Pregledajte našu kolekciju premium prirodnih ulja i započnite svoje putovanje ka zdravijoj, sjajnijoj koži već danas.",
"button": "Kupi prirodna ulja"
}
},
"ByConcern": {
"metaTitle": "Rešenja za negu kože po problemu | ManoonOils",
"metaDescription": "Pregledajte prirodna uljana rešenja organizovana po problemima kože. Pronađite savršen lek za bore, akne, suvu kožu i još mnogo toga.",
"title": "Rešenja po problemu",
"subtitle": "Istražite našu sveobuhvatnu kolekciju prirodnih uljanih rešenja, organizovanih po problemima kože da vam pomognemo da pronađete tačno ono što vam treba.",
"stats": {
"availableConcerns": "{count} problema kože pokriveno",
"totalSolutions": "{count} stručno odabranih rešenja"
},
"noResults": "Nema pronađenih problema. Proverite ponovo kasnije za nova rešenja."
},
"ByOil": {
"metaTitle": "Rešenja za negu kože po ulju | ManoonOils",
"metaDescription": "Otkrijte prednosti različitih prirodnih ulja za različite probleme kože. Pronađite koje ulje je pravo za vas.",
"title": "Rešenja po ulju",
"subtitle": "Saznajte o jedinstvenim svojstvima svakog prirodnog ulja i otkrijte koja su najpogodnija za vaše probleme sa kožom.",
"stats": {
"availableOils": "{count} prirodnih ulja",
"totalSolutions": "{count} stručno odabranih rešenja"
},
"noResults": "Nema pronađenih ulja. Proverite ponovo kasnije za nova rešenja."
},
"completeYourRoutine": "Dovršite svoju rutinu",
"discoverProducts": "Otkrijte naše premium proizvode sa prirodnim sastojcima"
}
}

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

View File

@@ -0,0 +1,79 @@
import { readFile } from "fs/promises";
import { join } from "path";
import type {
OilForConcernPage,
LocalizedSEOKeywords
} from "./types";
const DATA_DIR = join(process.cwd(), "data");
export function getLocalizedString(
localized: { sr: string; en: string; de: string; fr: string },
locale: string
): string {
return localized[locale as keyof typeof localized] || localized.en;
}
export function getLocalizedArray(
localized: { sr: string[]; en: string[]; de: string[]; fr: string[] },
locale: string
): string[] {
return localized[locale as keyof typeof localized] || localized.en;
}
export function getLocalizedKeywords(
seoKeywords: LocalizedSEOKeywords,
locale: string
): string[] {
const keywords = seoKeywords[locale as keyof LocalizedSEOKeywords] || seoKeywords.en;
return [...keywords.primary, ...keywords.secondary, ...keywords.longTail];
}
export async function getOilForConcernPage(slug: string): Promise<OilForConcernPage | null> {
try {
const filePath = join(DATA_DIR, "oil-for-concern", `${slug}.json`);
const content = await readFile(filePath, "utf-8");
return JSON.parse(content) as OilForConcernPage;
} catch (error) {
console.error(`Failed to load oil-for-concern page: ${slug}`, error);
return null;
}
}
export async function getAllOilForConcernSlugs(): Promise<string[]> {
try {
const { readdir } = await import("fs/promises");
const dirPath = join(DATA_DIR, "oil-for-concern");
const files = await readdir(dirPath);
return files
.filter((file) => file.endsWith(".json"))
.map((file) => file.replace(".json", ""));
} catch (error) {
console.error("Failed to load oil-for-concern slugs:", error);
return [];
}
}
export async function getAllOilForConcernPages(): Promise<OilForConcernPage[]> {
const slugs = await getAllOilForConcernSlugs();
const pages = await Promise.all(
slugs.map((slug) => getOilForConcernPage(slug))
);
return pages.filter((page): page is OilForConcernPage => page !== null);
}
export async function getAllSolutionSlugs(): Promise<Array<{ locale: string; slug: string }>> {
const slugs = await getAllOilForConcernSlugs();
const result: Array<{ locale: string; slug: string }> = [];
for (const slug of slugs) {
for (const locale of ["sr", "en", "de", "fr"] as const) {
result.push({
locale,
slug
});
}
}
return result;
}

View File

@@ -0,0 +1,30 @@
export interface FAQPageSchema {
"@context": "https://schema.org";
"@type": "FAQPage";
mainEntity: Array<{
"@type": "Question";
name: string;
acceptedAnswer: {
"@type": "Answer";
text: string;
};
}>;
}
export function generateFAQPageSchema(
questions: Array<{ question: string; answer: string }>
): FAQPageSchema {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: questions.map((q) => ({
"@type": "Question",
name: q.question,
acceptedAnswer: {
"@type": "Answer",
text: q.answer,
},
})),
};
}

View File

@@ -0,0 +1,388 @@
export interface LocalizedString {
sr: string;
en: string;
de: string;
fr: string;
}
export interface LocalizedArray {
sr: string[];
en: string[];
de: string[];
fr: string[];
}
export interface SEOKeywords {
primary: string[];
secondary: string[];
longTail: string[];
}
export interface LocalizedSEOKeywords {
sr: SEOKeywords;
en: SEOKeywords;
de: SEOKeywords;
fr: SEOKeywords;
}
export interface FAQ {
question: LocalizedString;
answer: LocalizedString;
}
export interface BreadcrumbItem {
name: string;
url: string;
}
export interface RoutineStep {
step: number;
name: LocalizedString;
description: LocalizedString;
products: string[];
technique?: LocalizedString;
}
export interface ComparisonPoint {
category: LocalizedString;
oilAWins: boolean;
oilBWins: boolean;
explanation: LocalizedString;
}
export interface Testimonial {
quote: LocalizedString;
name: string;
age?: number;
skinType?: string;
timeframe?: string;
location?: string;
}
export interface OilForConcernPage {
slug: string;
oilSlug: string;
concernSlug: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
oilName: LocalizedString;
concernName: LocalizedString;
whyThisWorks: LocalizedString;
keyBenefits: LocalizedArray;
howToApply: LocalizedArray;
expectedResults: LocalizedString;
timeframe: LocalizedString;
complementaryIngredients: string[];
productsToShow: string[];
customerResults: Testimonial[];
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
relatedPages: {
otherOilsForSameConcern: string[];
sameOilForOtherConcerns: string[];
};
}
export interface AgeSkinTypeRoutinePage {
slug: string;
ageRange: string;
skinType: string;
season?: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
ageRangeLabel: LocalizedString;
skinTypeLabel: LocalizedString;
seasonLabel?: LocalizedString;
skinChangesAtThisAge: LocalizedArray;
whyThisRoutineWorks: LocalizedString;
morningRoutine: RoutineStep[];
eveningRoutine: RoutineStep[];
weeklyTreatments: RoutineStep[];
keyIngredients: string[];
productsToAvoid: LocalizedArray;
expectedResults: LocalizedString;
timeframe: LocalizedString;
productBundle: string[];
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
}
export interface IngredientPairingPage {
slug: string;
ingredientA: string;
ingredientB: string;
benefitSlug: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
ingredientAName: LocalizedString;
ingredientBName: LocalizedString;
benefitName: LocalizedString;
whyTheyWorkTogether: LocalizedString;
synergyExplanation: LocalizedString;
ingredientAContribution: LocalizedString;
ingredientBContribution: LocalizedString;
howToMix: LocalizedArray;
applicationSteps: LocalizedArray;
bestTimeToUse: LocalizedString;
skinTypesBestFor: string[];
whoShouldAvoid: LocalizedArray;
productsWithBoth: string[];
diyRecipe?: LocalizedString;
expectedResults: LocalizedString;
timeframe: LocalizedString;
customerTestimonials: Testimonial[];
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
alternativePairings: string[];
}
export interface BodyPartConcernPage {
slug: string;
bodyPart: string;
concernSlug: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
bodyPartName: LocalizedString;
concernName: LocalizedString;
whyThisAreaIsDifferent: LocalizedString;
commonCauses: LocalizedArray;
topOilsForThisArea: {
oilSlug: string;
reason: LocalizedString;
rank: number;
}[];
applicationTechnique: LocalizedString;
massageSteps: LocalizedArray;
frequency: LocalizedString;
complementaryTreatments: LocalizedArray;
lifestyleTips: LocalizedArray;
productsSpecificallyForArea: string[];
beforeAfterGallery: boolean;
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
}
export interface OilComparisonPage {
slug: string;
oilA: string;
oilB: string;
concernSlug: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
oilAName: LocalizedString;
oilBName: LocalizedString;
concernName: LocalizedString;
quickVerdict: LocalizedString;
winner: string | "tie" | "depends";
comparisonTable: ComparisonPoint[];
oilAPros: LocalizedArray;
oilACons: LocalizedArray;
oilBPros: LocalizedArray;
oilBCons: LocalizedArray;
chooseOilAIf: LocalizedArray;
chooseOilBIf: LocalizedArray;
detailedComparison: LocalizedString;
priceComparison: LocalizedString;
effectivenessComparison: LocalizedString;
gentlenessComparison: LocalizedString;
bestProducts: {
oilA: string[];
oilB: string[];
};
canYouUseBoth: LocalizedString;
howToCombine: LocalizedArray;
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
relatedComparisons: string[];
}
export interface RoutineStepSkinTypePage {
slug: string;
routineStep: string;
skinType: string;
concernSlug: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
routineStepName: LocalizedString;
skinTypeName: LocalizedString;
concernName: LocalizedString;
whyThisStepMatters: LocalizedString;
whatToLookFor: LocalizedArray;
ingredientsToAvoid: LocalizedArray;
topRecommendations: {
productName: string;
productSlug: string;
whyItWorks: LocalizedString;
keyIngredients: string[];
bestFor: LocalizedString;
rank: number;
}[];
applicationTips: LocalizedArray;
commonMistakes: LocalizedArray;
fullRoutineContext: {
previousStep?: LocalizedString;
nextStep?: LocalizedString;
};
expectedResults: LocalizedString;
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
}
export interface SeasonalSkincarePage {
slug: string;
season: string;
target: string;
targetType: "skinType" | "ageRange" | "concern";
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
seasonName: LocalizedString;
targetName: LocalizedString;
seasonalChallenges: LocalizedArray;
howSkinChanges: LocalizedString;
routineAdjustments: {
add: LocalizedArray;
remove: LocalizedArray;
modify: LocalizedArray;
};
morningRoutine: RoutineStep[];
eveningRoutine: RoutineStep[];
keyIngredients: string[];
ingredientsToAvoidThisSeason: LocalizedArray;
lifestyleTips: LocalizedArray;
nutritionTips: LocalizedArray;
seasonalProductBundle: string[];
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
}
export interface TimeOfDayConcernPage {
slug: string;
timeOfDay: string;
productType: string;
concernSlug: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
timeOfDayName: LocalizedString;
productTypeName: LocalizedString;
concernName: LocalizedString;
whyTimingMatters: LocalizedString;
skinBehaviorAtThisTime: LocalizedString;
whatHappensOvernight?: LocalizedString;
whyMorningRoutineMatters?: LocalizedString;
keyIngredientsToLookFor: LocalizedArray;
ingredientsToAvoid: LocalizedArray;
topRecommendations: {
productName: string;
productSlug: string;
whyItWorks: LocalizedString;
keyIngredients: string[];
rank: number;
}[];
applicationTips: LocalizedArray;
layeringOrder: LocalizedArray;
complementaryProducts: string[];
expectedResults: LocalizedString;
timeframe: LocalizedString;
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
relatedTimeSpecificPages: string[];
}
export interface NaturalAlternativePage {
slug: string;
syntheticIngredient: string;
concernSlug: string;
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
syntheticName: LocalizedString;
concernName: LocalizedString;
whyPeopleWantAlternatives: LocalizedString;
syntheticHowItWorks: LocalizedString;
syntheticSideEffects: LocalizedArray;
naturalAlternativeName: LocalizedString;
naturalAlternativeSlug: string;
howNaturalAlternativeWorks: LocalizedString;
effectivenessComparison: LocalizedString;
timelineComparison: LocalizedString;
gentlenessComparison: LocalizedString;
costComparison: LocalizedString;
whoShouldSwitch: LocalizedArray;
transitionGuide: LocalizedArray;
whatToExpect: LocalizedString;
bestProductsWithNaturalAlternative: string[];
diyOption?: LocalizedString;
customerStories: Testimonial[];
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
otherNaturalAlternatives: string[];
}
export interface CulturalBeautySecretPage {
slug: string;
region: string;
focus: string;
focusType: "ingredient" | "technique" | "ritual";
pageTitle: LocalizedString;
metaTitle: LocalizedString;
metaDescription: LocalizedString;
regionName: LocalizedString;
focusName: LocalizedString;
culturalBackground: LocalizedString;
historicalContext: LocalizedString;
traditionalUse: LocalizedString;
modernScienceValidation: LocalizedString;
keyIngredients: string[];
traditionalPreparation: LocalizedString;
modernAdaptation: LocalizedString;
applicationRitual: LocalizedArray;
bestTimeToUse: LocalizedString;
skinTypesBestFor: string[];
expectedResults: LocalizedString;
timeframe: LocalizedString;
productsInspiredByTradition: string[];
whereSourceFrom: LocalizedString;
sustainabilityNotes: LocalizedString;
customerExperiences: Testimonial[];
faqs: FAQ[];
seoKeywords: LocalizedSEOKeywords;
relatedCulturalPages: string[];
}
export type ProgrammaticPageType =
| "oil-for-concern"
| "age-skin-routine"
| "ingredient-pairing"
| "body-part-concern"
| "oil-comparison"
| "routine-step-skin"
| "seasonal-skincare"
| "time-of-day-concern"
| "natural-alternative"
| "cultural-beauty-secret";
export type ProgrammaticPage =
| OilForConcernPage
| AgeSkinTypeRoutinePage
| IngredientPairingPage
| BodyPartConcernPage
| OilComparisonPage
| RoutineStepSkinTypePage
| SeasonalSkincarePage
| TimeOfDayConcernPage
| NaturalAlternativePage
| CulturalBeautySecretPage;
export interface DataLoader<T> {
getAll(): Promise<T[]>;
getBySlug(slug: string): Promise<T | null>;
getBySlugs(slugs: string[]): Promise<T[]>;
}

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const url = request.nextUrl.pathname;