15 Commits

Author SHA1 Message Date
Unchained
2b9d8fa7d5 fix: add OpenPanel proxy routes for script and tracking 2026-03-31 13:34:37 +02:00
Unchained
1c5ec1a271 fix: remove framer-motion from HeroVideo for instant content visibility 2026-03-31 13:22:45 +02:00
Unchained
8eb9f24b33 feat(performance): Core Web Vitals optimizations
- Font optimization: Replace @font-face with next/font/google (DM Sans, Inter) for faster font loading and no render-blocking
- Image optimization: Add Unsplash to remotePatterns, configure AVIF/WebP formats, add device/image sizes
- Convert native <img> tags to next/image with proper sizing and priority for LCP images
- Add optimizePackageImports for lucide-react and framer-motion to reduce bundle size
- Fix CLS: Urgency message uses fixed min-height instead of animated height
- Fix CLS: ProductCard quick-add button uses opacity instead of translate for hover
- Convert HeroVideo scroll indicator to CSS animation
- Script loading: Rybbit uses lazyOnload strategy for better INP
2026-03-31 12:03:34 +02:00
Unchained
d4039c6e3b feat(analytics): complete Rybbit tracking integration
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add Rybbit server-side tracking to analytics-server.ts for order completion and revenue
- Add trackNewsletterSignup to analytics.ts and wire up NewsletterSection
- Add cart tracking to CartDrawer (cart view, remove from cart)
- All ecommerce events now track to both OpenPanel and Rybbit
2026-03-31 05:53:53 +02:00
Unchained
bbe618f22d fix(analytics): add session-replay record endpoint
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 05:34:21 +02:00
Unchained
cfb98a457f fix(analytics): add replay.js rewrite for Rybbit session replay
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 05:30:55 +02:00
Unchained
97479d542b fix(analytics): add tracking-config rewrite for Rybbit
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 05:27:27 +02:00
Unchained
56c05cc8fc feat(analytics): add Rybbit proxy rewrites and env vars
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add Next.js rewrites to proxy /api/script.js and /api/track through self-hosted Rybbit
- This bypasses ad blockers that would block rybbit.nodecrew.me directly
- Add NEXT_PUBLIC_RYBBIT_HOST and NEXT_PUBLIC_RYBBIT_SITE_ID env vars to K8s deployment
2026-03-31 05:17:57 +02:00
Unchained
511c3078c5 fix: update all fallback URLs from dev.manoonoils.com to manoonoils.com
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 00:53:37 +02:00
Unchained
44091fc72a fix: inline Rybbit config to avoid client directive in server component
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-31 00:50:33 +02:00
Unchained
b3efebd3e4 feat: integrate Rybbit analytics alongside OpenPanel
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add RybbitService for tracking e-commerce events
- Update useAnalytics hook to track with both OpenPanel and Rybbit
- Add Rybbit script to layout for page view tracking
- Track all applicable store events: product views, cart, checkout, orders, search, etc.
2026-03-31 00:38:38 +02:00
Unchained
044aefae94 fix: remove dev.manoonoils.com from ingress and update OpenPanel API URL
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Remove dev.manoonoils.com from storefront ingress to prevent cross-domain tracking issues
- Use environment variable for OpenPanel API URL in route handler
- Fixes session state conflicts from multiple domains
2026-03-30 20:40:17 +02:00
Unchained
36915a3f75 feat: add OAuth 2.0 support for GSC monitoring
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Updated monitor.py to support both OAuth and Service Account
- Created setup-oauth-local.py for easy local authorization
- Created cronjob-oauth.yaml for OAuth-based deployment
- Updated README with both authentication options
- OAuth is now the recommended method (no key file needed)
2026-03-30 17:56:49 +02:00
Unchained
771e9dc20b docs: add GSC monitoring quickstart guide
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-30 17:18:51 +02:00
Unchained
df915ca128 feat: add Google Search Console automated monitoring
- Python monitoring script for daily GSC reports
- Kubernetes CronJob for automated execution
- Tracks search analytics, crawl errors, and sitemap status
- Includes full setup documentation
2026-03-30 17:17:42 +02:00
42 changed files with 2750 additions and 242 deletions

0
EOF Normal file
View File

3
features.md Normal file
View File

@@ -0,0 +1,3 @@
programmatic seo
pop up and exit pop to grow emaillist connected with resend and mautic. want to always have my list growing and owned by me on my server
abandoned cart setup with sequences to get people back

View File

@@ -126,6 +126,10 @@ spec:
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
- name: OPENPANEL_API_URL
value: "https://op.nodecrew.me/api"
- name: NEXT_PUBLIC_RYBBIT_HOST
value: "https://rybbit.nodecrew.me"
- name: NEXT_PUBLIC_RYBBIT_SITE_ID
value: "1"
resources:
limits:
cpu: 500m

View File

@@ -8,7 +8,7 @@ spec:
- web
- websecure
routes:
- match: Host(`dev.manoonoils.com`) || Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
- match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
kind: Rule
services:
- name: storefront

View File

@@ -5,7 +5,35 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
output: 'standalone',
async rewrites() {
const rybbitHost = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
return [
{
source: "/api/script.js",
destination: `${rybbitHost}/api/script.js`,
},
{
source: "/api/track",
destination: `${rybbitHost}/api/track`,
},
{
source: "/api/site/tracking-config/:id",
destination: `${rybbitHost}/api/site/tracking-config/:id`,
},
{
source: "/api/replay.js",
destination: `${rybbitHost}/api/replay.js`,
},
{
source: "/api/session-replay/record/:id",
destination: `${rybbitHost}/api/session-replay/record/:id`,
},
];
},
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
remotePatterns: [
{
protocol: "https",
@@ -27,8 +55,16 @@ const nextConfig: NextConfig = {
hostname: "**.saleor.cloud",
pathname: "/**",
},
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
],
},
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion"],
},
};
export default withNextIntl(nextConfig);

40
public/debug-op.js Normal file
View File

@@ -0,0 +1,40 @@
// OpenPanel Debug Script
// Run this in browser console to test OpenPanel
(function debugOpenPanel() {
console.log('=== OpenPanel Debug ===');
// Check if OpenPanel is loaded
if (typeof window.op === 'undefined') {
console.error('❌ OpenPanel SDK not loaded (window.op is undefined)');
console.log('Script URL should be:', 'https://op.nodecrew.me/op1.js');
return;
}
console.log('✅ OpenPanel SDK loaded');
console.log('window.op:', window.op);
// Check client ID
const clientId = window.op._clientId || 'not set';
console.log('Client ID:', clientId);
// Try to track an event
console.log('Attempting to track test event...');
window.op.track('debug_test', { source: 'console', timestamp: new Date().toISOString() })
.then(() => console.log('✅ Track successful'))
.catch(err => console.error('❌ Track failed:', err));
// Check network requests
console.log('');
console.log('Check Network tab for requests to:');
console.log('- https://manoonoils.com/api/op/track');
console.log('- https://op.nodecrew.me/api/track');
// Common issues
console.log('');
console.log('Common issues:');
console.log('1. Ad blockers (try disabling uBlock/AdBlock)');
console.log('2. CORS errors (check console for red errors)');
console.log('3. Do Not Track enabled in browser');
console.log('4. Private/Incognito mode (some blockers active)');
})();

View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy monitoring script
COPY monitor.py .
# Create log directory
RUN mkdir -p /var/log/gsc-monitoring
# Run monitoring
CMD ["python", "monitor.py"]

View File

@@ -0,0 +1,121 @@
# Google Search Console Monitoring Setup
## ✅ What's Been Created
I've created a complete automated monitoring system in `scripts/gsc-monitoring/`:
### Files Created:
1. **monitor.py** - Python script that fetches GSC data
2. **requirements.txt** - Python dependencies
3. **Dockerfile** - Container image definition
4. **cronjob.yaml** - Kubernetes CronJob for daily runs
5. **README.md** - Full setup documentation
### What It Monitors:
- ✅ Search analytics (clicks, impressions, CTR, position)
- ✅ Top 5 search queries daily
- ✅ Crawl errors
- ✅ Sitemap status
- ✅ Runs daily at 9 AM UTC
---
## 🚀 Next Steps (Do These Now)
### Step 1: Create Google Cloud Project
1. Go to https://console.cloud.google.com
2. Create new project named `manoonoils-monitoring`
3. Enable "Google Search Console API" in APIs & Services → Library
### Step 2: Create Service Account
1. Go to IAM & Admin → Service Accounts
2. Create service account: `gsc-monitor`
3. Grant role: "Search Console Viewer" (or "Owner")
### Step 3: Download Key
1. Click on the service account → Keys tab
2. Add Key → Create New Key → JSON
3. **Download and save the JSON file**
### Step 4: Add to Search Console
1. Go to https://search.google.com/search-console
2. Select `manoonoils.com` property
3. Settings → Users and Permissions → Add User
4. Add the service account email from the JSON file
5. Permission level: "Full"
### Step 5: Deploy to Kubernetes
Run on your server:
```bash
# Copy the JSON key to your server
scp /path/to/downloaded-key.json doorwaysftw:/tmp/gsc-key.json
# Create the Kubernetes secret
ssh doorwaysftw "kubectl create secret generic gsc-service-account \
--namespace=manoonoils \
--from-file=service-account.json=/tmp/gsc-key.json"
# Deploy the monitoring CronJob
ssh doorwaysftw "kubectl apply -f -" < scripts/gsc-monitoring/cronjob.yaml
# Verify it's scheduled
ssh doorwaysftw "kubectl get cronjob gsc-monitoring -n manoonoils"
```
---
## 📊 Viewing Reports
### Check Latest Report:
```bash
ssh doorwaysftw "kubectl create job --from=cronjob/gsc-monitoring gsc-manual-test -n manoonoils
sleep 10
kubectl logs job/gsc-manual-test -n manoonoils
kubectl delete job gsc-manual-test -n manoonoils"
```
### Reports include:
- Total clicks & impressions (last 7 days)
- Average CTR and position
- Top 5 search queries
- Crawl errors summary
- Sitemap status
---
## 🔒 Security
- Service account has **read-only** access to GSC
- Credentials stored as Kubernetes Secret
- JSON key never committed to git
- Rotate key every 90 days
---
## 📚 Full Documentation
See `scripts/gsc-monitoring/README.md` for:
- Detailed setup instructions
- Troubleshooting guide
- Updating the monitor
- Changing schedule
---
## ⏱️ Timeline
**Setup time:** 10-15 minutes
**First report:** After setup (manual run) or next day (automatic)
**Data availability:** 48-72 hours after setup (Google processes data)
---
## ❓ Questions?
The README.md has full troubleshooting. Common issues:
- "User does not have permission" → Wait 5-10 min after adding to GSC
- "Site not found" → Verify URL in monitor.py matches exactly
**Ready to proceed?** Start with Step 1 above!

View File

@@ -0,0 +1,261 @@
# Google Search Console Monitoring Setup Guide
## Overview
This setup creates an automated monitoring system for Google Search Console that runs daily and generates reports.
## Prerequisites
1. Google Cloud account
2. Access to Google Search Console for manoonoils.com
3. kubectl access to your Kubernetes cluster
## Authentication Methods
Choose one of the following authentication methods:
### Option A: OAuth 2.0 (Recommended - No Service Account Key)
This is the **easiest method** if you can't create service account keys.
#### Step 1: Enable Search Console API
1. Go to https://console.cloud.google.com
2. Create/select project: `manoonoils-monitoring`
3. Go to **APIs & Services → Library**
4. Search: "Google Search Console API"
5. Click: **Enable**
#### Step 2: Create OAuth Credentials
1. Go to **APIs & Services → Credentials**
2. Click: **Create Credentials → OAuth client ID**
3. Click: **Configure Consent Screen**
4. User Type: **External**
5. Fill in:
- App name: `ManoonOils GSC Monitor`
- User support email: your email
- Developer contact: your email
6. Click: **Save and Continue** (3 times)
7. Click: **Back to Dashboard**
8. Back on Credentials page
9. Click: **Create Credentials → OAuth client ID**
10. Application type: **Desktop app**
11. Name: `GSC Desktop Client`
12. Click: **Create**
13. Click: **DOWNLOAD JSON**
#### Step 3: Run Local Authorization
On your local machine (laptop):
```bash
# Go to the monitoring directory
cd scripts/gsc-monitoring
# Install dependencies
pip3 install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client
# Run the OAuth setup
python3 setup-oauth-local.py
```
This will:
- Open a browser for you to authorize the app
- Generate a `gsc-oauth-credentials.json` file
- The refresh token never expires!
#### Step 4: Deploy to Kubernetes
```bash
# Copy the credentials to server
scp gsc-oauth-credentials.json doorwaysftw:/tmp/
# Create the secret
ssh doorwaysftw "kubectl create secret generic gsc-oauth-credentials \
--namespace=manoonoils \
--from-file=oauth-credentials.json=/tmp/gsc-oauth-credentials.json"
# Deploy the monitoring
ssh doorwaysftw "kubectl apply -f -" < cronjob-oauth.yaml
# Verify
ssh doorwaysftw "kubectl get cronjob gsc-monitoring-oauth -n manoonoils"
```
---
### Option B: Service Account (Requires Key Creation)
**Note:** This only works if you can create service account keys in Google Cloud.
## Setup Steps
### Step 1: Create Google Cloud Project
1. Go to https://console.cloud.google.com
2. Click "Create Project" (or select existing)
3. Name it: `manoonoils-monitoring`
4. Note the Project ID
### Step 2: Enable Search Console API
1. In your project, go to "APIs & Services" → "Library"
2. Search for "Google Search Console API"
3. Click "Enable"
### Step 3: Create Service Account
1. Go to "IAM & Admin" → "Service Accounts"
2. Click "Create Service Account"
3. Name: `gsc-monitor`
4. Description: `Monitoring service for Google Search Console`
5. Click "Create and Continue"
6. Role: Select "Search Console Viewer" (or "Owner" if not available)
7. Click "Done"
### Step 4: Create and Download Key
1. Click on the service account you just created
2. Go to "Keys" tab
3. Click "Add Key" → "Create New Key"
4. Select "JSON" format
5. Click "Create" - this downloads the key file
6. **SAVE THIS FILE SECURELY** - you cannot download it again!
### Step 5: Add Service Account to Search Console
1. Go to https://search.google.com/search-console
2. Select your property: `manoonoils.com`
3. Click "Settings" (gear icon) → "Users and Permissions"
4. Click "Add User"
5. Enter the service account email (from the JSON key file, looks like: `gsc-monitor@manoonoils-monitoring.iam.gserviceaccount.com`)
6. Permission level: "Full"
7. Click "Add"
### Step 6: Store Credentials in Kubernetes
On your server (doorwaysftw), run:
```bash
# Copy the JSON key file to the server
scp /path/to/service-account-key.json doorwaysftw:/tmp/
# Create the secret in Kubernetes
ssh doorwaysftw "kubectl create secret generic gsc-service-account \
--namespace=manoonoils \
--from-file=service-account.json=/tmp/service-account-key.json"
# Verify the secret was created
ssh doorwaysftw "kubectl get secret gsc-service-account -n manoonoils"
```
### Step 7: Build and Deploy
```bash
# Build the Docker image
cd scripts/gsc-monitoring
docker build -t gcr.io/manoonoils/gsc-monitoring:latest .
# Push to registry (or use local registry)
docker push gcr.io/manoonoils/gsc-monitoring:latest
# Deploy to Kubernetes
kubectl apply -f cronjob.yaml
# Verify it's running
kubectl get cronjob gsc-monitoring -n manoonoils
```
### Step 8: Test Manually
```bash
# Run a manual test
kubectl create job --from=cronjob/gsc-monitoring gsc-test -n manoonoils
# Check the logs
kubectl logs job/gsc-test -n manoonoils
# Delete the test job when done
kubectl delete job gsc-test -n manoonoils
```
## What It Monitors
### Daily Reports Include:
1. **Search Analytics** (Last 7 Days)
- Total clicks and impressions
- Average CTR and position
- Top 5 search queries
2. **Crawl Errors**
- Number of errors by type
- Platform-specific issues
3. **Sitemap Status**
- Sitemap processing status
- Warnings and errors
## Viewing Reports
Reports are saved to `/var/log/gsc-monitoring/` in the pod and can be accessed:
```bash
# Get pod name
POD=$(kubectl get pods -n manoonoils -l job-name=gsc-monitoring -o name | head -1)
# View latest report
kubectl exec $POD -n manoonoils -- cat /var/log/gsc-monitoring/$(kubectl exec $POD -n manoonoils -- ls -t /var/log/gsc-monitoring/ | head -1)
```
Or set up log aggregation with your preferred tool.
## Schedule
The monitoring runs daily at **9:00 AM UTC**. To change:
```bash
# Edit the cronjob
kubectl edit cronjob gsc-monitoring -n manoonoils
# Change the schedule field (cron format)
# Examples:
# "0 */6 * * *" # Every 6 hours
# "0 0 * * 0" # Weekly on Sunday
```
## Troubleshooting
### "Service account key file not found"
- Verify the secret was created: `kubectl get secret gsc-service-account -n manoonoils`
- Check the key is mounted: `kubectl exec deploy/gsc-monitoring -n manoonoils -- ls -la /etc/gsc-monitoring/`
### "User does not have permission"
- Verify the service account email was added to GSC with "Full" permissions
- Wait 5-10 minutes for permissions to propagate
### "Site not found"
- Verify the SITE_URL in `monitor.py` matches exactly (with trailing slash)
- Check: https://search.google.com/search-console
## Security Notes
- The service account JSON key is stored as a Kubernetes Secret
- The key has read-only access to Search Console data
- Rotate the key every 90 days for security
- Never commit the key file to git
## Updating the Monitor
To update the monitoring script:
1. Edit `monitor.py`
2. Rebuild the Docker image
3. Push to registry
4. Delete and recreate the CronJob:
```bash
kubectl delete cronjob gsc-monitoring -n manoonoils
kubectl apply -f cronjob.yaml
```
## Support
For issues or feature requests, check:
- Google Search Console API docs: https://developers.google.com/webmaster-tools/search-console-api-original/v3
- Google Cloud IAM docs: https://cloud.google.com/iam/docs

View File

@@ -0,0 +1,32 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: gsc-monitoring-oauth
namespace: manoonoils
spec:
schedule: "0 9 * * *" # Run daily at 9 AM UTC
jobTemplate:
spec:
template:
spec:
containers:
- name: gsc-monitor
image: gcr.io/manoonoils/gsc-monitoring:latest
env:
- name: GSC_OAUTH_FILE
value: /etc/gsc-monitoring/oauth-credentials.json
- name: PYTHONUNBUFFERED
value: "1"
volumeMounts:
- name: gsc-oauth-credentials
mountPath: /etc/gsc-monitoring
readOnly: true
- name: logs
mountPath: /var/log/gsc-monitoring
volumes:
- name: gsc-oauth-credentials
secret:
secretName: gsc-oauth-credentials
- name: logs
emptyDir: {}
restartPolicy: OnFailure

View File

@@ -0,0 +1,45 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: gsc-monitoring
namespace: manoonoils
spec:
schedule: "0 9 * * *" # Run daily at 9 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: gsc-monitor
image: gcr.io/manoonoils/gsc-monitoring:latest
env:
- name: GSC_KEY_FILE
value: /etc/gsc-monitoring/service-account.json
- name: PYTHONUNBUFFERED
value: "1"
volumeMounts:
- name: gsc-credentials
mountPath: /etc/gsc-monitoring
readOnly: true
- name: logs
mountPath: /var/log/gsc-monitoring
volumes:
- name: gsc-credentials
secret:
secretName: gsc-service-account
- name: logs
emptyDir: {}
restartPolicy: OnFailure
---
apiVersion: v1
kind: Secret
metadata:
name: gsc-service-account
namespace: manoonoils
type: Opaque
stringData:
service-account.json: |
# PLACEHOLDER - Replace with actual service account JSON
# Run: kubectl create secret generic gsc-service-account \
# --namespace=manoonoils \
# --from-file=service-account.json=/path/to/your/service-account-key.json

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
Google Search Console Monitoring Script
Monitors search performance, crawl errors, and indexing status
Supports both:
1. Service Account (with JSON key file)
2. OAuth 2.0 (user authentication)
"""
import os
import json
import sys
from datetime import datetime, timedelta
from google.oauth2 import service_account
from google.oauth2.credentials import Credentials as OAuthCredentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
# Configuration
SITE_URL = "https://manoonoils.com/"
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
KEY_FILE = os.environ.get("GSC_KEY_FILE", "/etc/gsc-monitoring/service-account.json")
OAUTH_FILE = os.environ.get(
"GSC_OAUTH_FILE", "/etc/gsc-monitoring/oauth-credentials.json"
)
def get_service():
"""Authenticate and return Search Console service"""
# Try OAuth first
if os.path.exists(OAUTH_FILE):
print("Using OAuth authentication...")
with open(OAUTH_FILE, "r") as f:
creds_info = json.load(f)
creds = OAuthCredentials(
token=creds_info["token"],
refresh_token=creds_info["refresh_token"],
token_uri=creds_info["token_uri"],
client_id=creds_info["client_id"],
client_secret=creds_info["client_secret"],
scopes=creds_info["scopes"],
)
# Refresh if expired
if creds.expired:
creds.refresh(Request())
# Save updated credentials
creds_info["token"] = creds.token
with open(OAUTH_FILE, "w") as f:
json.dump(creds_info, f, indent=2)
return build("webmasters", "v3", credentials=creds)
# Fall back to service account
elif os.path.exists(KEY_FILE):
print("Using Service Account authentication...")
credentials = service_account.Credentials.from_service_account_file(
KEY_FILE, scopes=SCOPES
)
return build("webmasters", "v3", credentials=credentials)
else:
raise FileNotFoundError(
f"No credentials found. Please set up either:\n"
f" 1. OAuth: {OAUTH_FILE}\n"
f" 2. Service Account: {KEY_FILE}\n"
f"\nSee README.md for setup instructions."
)
def get_search_analytics(service, days=7):
"""Get search analytics data for the last N days"""
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
try:
request = {
"startDate": start_date,
"endDate": end_date,
"dimensions": ["query", "page"],
"rowLimit": 100,
}
response = (
service.searchanalytics().query(siteUrl=SITE_URL, body=request).execute()
)
return response.get("rows", [])
except HttpError as e:
print(f"Error fetching search analytics: {e}")
return []
def get_crawl_errors(service):
"""Get crawl errors summary"""
try:
response = service.urlcrawlerrorscounts().query(siteUrl=SITE_URL).execute()
return response.get("countPerTypes", [])
except HttpError as e:
print(f"Error fetching crawl errors: {e}")
return []
def get_sitemaps(service):
"""Get sitemap status"""
try:
response = service.sitemaps().list(siteUrl=SITE_URL).execute()
return response.get("sitemap", [])
except HttpError as e:
print(f"Error fetching sitemaps: {e}")
return []
def format_report(analytics, crawl_errors, sitemaps):
"""Format monitoring report"""
report = []
report.append("=" * 70)
report.append("GOOGLE SEARCH CONSOLE MONITORING REPORT")
report.append(f"Site: {SITE_URL}")
report.append(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
report.append("=" * 70)
# Search Analytics Summary
report.append("\n📊 SEARCH ANALYTICS (Last 7 Days)")
report.append("-" * 70)
if analytics:
total_clicks = sum(row["clicks"] for row in analytics)
total_impressions = sum(row["impressions"] for row in analytics)
avg_ctr = sum(row["ctr"] for row in analytics) / len(analytics) * 100
avg_position = sum(row["position"] for row in analytics) / len(analytics)
report.append(f"Total Clicks: {total_clicks:,}")
report.append(f"Total Impressions: {total_impressions:,}")
report.append(f"Average CTR: {avg_ctr:.2f}%")
report.append(f"Average Position: {avg_position:.1f}")
# Top 5 queries
report.append("\n🔍 Top 5 Queries:")
sorted_queries = sorted(analytics, key=lambda x: x["clicks"], reverse=True)[:5]
for i, row in enumerate(sorted_queries, 1):
query = row["keys"][0]
clicks = row["clicks"]
impressions = row["impressions"]
report.append(
f' {i}. "{query}" - {clicks} clicks, {impressions} impressions'
)
else:
report.append("No search analytics data available yet (may take 48-72 hours)")
# Crawl Errors
report.append("\n🚨 CRAWL ERRORS")
report.append("-" * 70)
if crawl_errors:
total_errors = sum(error.get("count", 0) for error in crawl_errors)
if total_errors > 0:
report.append(f"⚠️ Total Errors: {total_errors}")
for error in crawl_errors:
error_type = error.get("platform", "Unknown")
category = error.get("category", "Unknown")
count = error.get("count", 0)
if count > 0:
report.append(f" - {error_type} / {category}: {count}")
else:
report.append("✅ No crawl errors detected!")
else:
report.append("✅ No crawl errors detected!")
# Sitemaps
report.append("\n🗺️ SITEMAPS")
report.append("-" * 70)
if sitemaps:
for sitemap in sitemaps:
path = sitemap.get("path", "Unknown")
is_pending = sitemap.get("isPending", False)
is_sitemap_index = sitemap.get("isSitemapIndex", False)
status = "⏳ Pending" if is_pending else "✅ Processed"
report.append(f" {path}")
report.append(f" Status: {status}")
if not is_sitemap_index and "warnings" in sitemap:
report.append(f" Warnings: {sitemap['warnings']}")
if not is_sitemap_index and "errors" in sitemap:
report.append(f" Errors: {sitemap['errors']} ⚠️")
else:
report.append(
"⚠️ No sitemaps found. Submit your sitemap to Google Search Console!"
)
report.append("\n" + "=" * 70)
return "\n".join(report)
def main():
"""Main monitoring function"""
print("🔍 Starting Google Search Console monitoring...")
try:
service = get_service()
# Gather data
analytics = get_search_analytics(service)
crawl_errors = get_crawl_errors(service)
sitemaps = get_sitemaps(service)
# Generate and print report
report = format_report(analytics, crawl_errors, sitemaps)
print(report)
# Save report to file
report_file = f"/var/log/gsc-monitoring/report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
os.makedirs(os.path.dirname(report_file), exist_ok=True)
with open(report_file, "w") as f:
f.write(report)
print(f"\n💾 Report saved to: {report_file}")
except FileNotFoundError as e:
print(f"{e}")
sys.exit(1)
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,4 @@
google-auth>=2.22.0
google-auth-oauthlib>=1.0.0
google-auth-httplib2>=0.1.1
google-api-python-client>=2.95.0

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
OAuth Setup for Google Search Console Monitoring
Run this locally (not on the server) to generate OAuth credentials
"""
import os
import json
import webbrowser
from pathlib import Path
def setup_oauth():
"""Interactive OAuth setup"""
print("=" * 70)
print("GOOGLE SEARCH CONSOLE - OAUTH 2.0 SETUP")
print("=" * 70)
print()
print("This method uses OAuth 2.0 (no service account key needed)")
print("You'll authenticate once with your Google account.")
print()
# Step 1: Enable API
print("STEP 1: Enable Search Console API")
print("-" * 70)
print("1. Go to: https://console.cloud.google.com")
print("2. Create/select project: manoonoils-monitoring")
print("3. Go to: APIs & Services → Library")
print("4. Search: 'Google Search Console API'")
print("5. Click: Enable")
print()
input("Press Enter when you've enabled the API...")
# Step 2: Create OAuth credentials
print()
print("STEP 2: Create OAuth Credentials")
print("-" * 70)
print("1. Go to: APIs & Services → Credentials")
print("2. Click: Create Credentials → OAuth client ID")
print("3. Click: Configure Consent Screen")
print("4. User Type: External")
print("5. App name: ManoonOils GSC Monitor")
print("6. User support email: your-email@manoonoils.com")
print("7. Developer contact: your-email@manoonoils.com")
print("8. Click: Save and Continue (3 times)")
print("9. Click: Back to Dashboard")
print()
print("10. Back on Credentials page:")
print("11. Click: Create Credentials → OAuth client ID")
print("12. Application type: Desktop app")
print("13. Name: GSC Desktop Client")
print("14. Click: Create")
print("15. Click: DOWNLOAD JSON")
print()
# Get the file path
json_path = input("Enter the path to the downloaded JSON file: ").strip()
if not os.path.exists(json_path):
print(f"❌ File not found: {json_path}")
return
# Load credentials
with open(json_path, "r") as f:
client_config = json.load(f)
# Step 3: Install dependencies and run auth
print()
print("STEP 3: Install Dependencies")
print("-" * 70)
print("Run these commands:")
print()
print(
" pip3 install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client"
)
print()
input("Press Enter after installing...")
# Step 4: Authorization
print()
print("STEP 4: Authorize Application")
print("-" * 70)
print("Running authorization...")
# Import here so we can check if installed
try:
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import pickle
except ImportError:
print("❌ Please install the required packages first (Step 3)")
return
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
# Create flow
flow = InstalledAppFlow.from_client_secrets_file(
json_path,
SCOPES,
redirect_uri="urn:ietf:wg:oauth:2.0:oob", # For console-based auth
)
# Get authorization URL
auth_url, _ = flow.authorization_url(prompt="consent")
print()
print("📱 Open this URL in your browser:")
print(auth_url)
print()
# Try to open browser automatically
try:
webbrowser.open(auth_url)
print("(Browser should open automatically)")
except:
pass
# Get the code
print()
code = input("Enter the authorization code from the browser: ").strip()
# Exchange code for credentials
flow.fetch_token(code=code)
creds = flow.credentials
# Save credentials
creds_info = {
"token": creds.token,
"refresh_token": creds.refresh_token,
"token_uri": creds.token_uri,
"client_id": creds.client_id,
"client_secret": creds.client_secret,
"scopes": creds.scopes,
}
output_file = "gsc-oauth-credentials.json"
with open(output_file, "w") as f:
json.dump(creds_info, f, indent=2)
print()
print("=" * 70)
print("✅ SUCCESS! OAuth credentials saved to:", output_file)
print("=" * 70)
print()
print("NEXT STEPS:")
print("1. Copy this file to your server:")
print(f" scp {output_file} doorwaysftw:/tmp/")
print()
print("2. Create Kubernetes secret:")
print(" ssh doorwaysftw")
print(" kubectl create secret generic gsc-oauth-credentials \\")
print(" --namespace=manoonoils \\")
print(" --from-file=oauth-credentials.json=/tmp/gsc-oauth-credentials.json")
print()
print("3. Deploy monitoring:")
print(" kubectl apply -f scripts/gsc-monitoring/cronjob-oauth.yaml")
print()
print("Your refresh token is valid indefinitely (until revoked).")
print("The monitoring will run automatically every day!")
if __name__ == "__main__":
setup_oauth()

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Google Search Console OAuth Setup Script
Generates OAuth credentials and stores refresh token
"""
import os
import json
import sys
from pathlib import Path
def create_oauth_credentials():
"""Guide user through OAuth setup"""
print("=" * 70)
print("GOOGLE SEARCH CONSOLE - OAUTH SETUP (No Service Account Key Needed)")
print("=" * 70)
print()
print("This method uses OAuth 2.0 instead of service account keys.")
print("You'll authenticate once with your Google account.")
print()
# Step 1: Create credentials
print("STEP 1: Create OAuth Credentials")
print("-" * 70)
print("1. Go to: https://console.cloud.google.com")
print("2. Select/create project: manoonoils-monitoring")
print("3. Go to: APIs & Services → Credentials")
print("4. Click: Create Credentials → OAuth client ID")
print("5. Application type: Desktop app")
print("6. Name: GSC Monitor")
print("7. Click Create")
print("8. Download the JSON file (client_secret_*.json)")
print()
input("Press Enter when you have downloaded the credentials file...")
# Step 2: Get credentials file path
print()
print("STEP 2: Upload Credentials")
print("-" * 70)
print("Copy the downloaded file to this server:")
print()
print(" scp /path/to/client_secret_*.json doorwaysftw:/tmp/gsc-credentials.json")
print()
input("Press Enter after uploading...")
# Step 3: Run authorization
print()
print("STEP 3: Authorize Application")
print("-" * 70)
print("Running authorization flow...")
print()
# Create auth script
auth_script = """#!/usr/bin/env python3
import os
import json
import pickle
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
SCOPES = ['https://www.googleapis.com/auth/webmasters.readonly']
CREDS_FILE = '/tmp/gsc-credentials.json'
TOKEN_FILE = '/tmp/gsc-token.pickle'
def main():
creds = None
if os.path.exists(TOKEN_FILE):
with open(TOKEN_FILE, 'rb') as token:
creds = pickle.load(token)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(
CREDS_FILE, SCOPES)
creds = flow.run_local_server(port=0)
with open(TOKEN_FILE, 'wb') as token:
pickle.dump(creds, token)
print("\\n✅ Authorization successful!")
print(f"Token saved to: {TOKEN_FILE}")
# Save credentials info
creds_info = {
'token': creds.token,
'refresh_token': creds.refresh_token,
'token_uri': creds.token_uri,
'client_id': creds.client_id,
'client_secret': creds.client_secret,
'scopes': creds.scopes
}
with open('/tmp/gsc-token.json', 'w') as f:
json.dump(creds_info, f, indent=2)
print(f"Credentials saved to: /tmp/gsc-token.json")
print("\\nYou can now deploy the monitoring system!")
if __name__ == '__main__':
main()
"""
# Save and run auth script
with open("/tmp/gsc-auth.py", "w") as f:
f.write(auth_script)
print("Authorization script created at: /tmp/gsc-auth.py")
print()
print("Run this on the server to authorize:")
print()
print(" ssh doorwaysftw")
print(" cd /tmp")
print(" python3 gsc-auth.py")
print()
print("This will open a browser for you to authorize the app.")
print("If running on a remote server without browser, use SSH tunnel:")
print()
print(" ssh -L 8080:localhost:8080 doorwaysftw")
print(" Then run python3 gsc-auth.py")
print()
def main():
create_oauth_credentials()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env node
/**
* Test script for checkout shipping cost calculation
* Creates a checkout via API and verifies totalPrice includes shipping
*/
const SALEOR_API_URL = process.env.NEXT_PUBLIC_SALEOR_API_URL || 'https://api.manoonoils.com/graphql/';
// Test data
const TEST_VARIANT_ID = 'UHJvZHVjdFZhcmlhbnQ6Mjk0'; // Replace with actual variant ID
const TEST_EMAIL = 'test@example.com';
const TEST_SHIPPING_ADDRESS = {
firstName: 'Test',
lastName: 'User',
streetAddress1: '123 Test Street',
city: 'Belgrade',
postalCode: '11000',
country: 'RS',
phone: '+38160123456'
};
async function saleorFetch(query, variables = {}, token = null) {
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `JWT ${token}`;
}
const response = await fetch(SALEOR_API_URL, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
}
return result.data;
}
async function testCheckoutWithShipping() {
console.log('🧪 Testing checkout shipping cost calculation...\n');
try {
// Step 1: Create checkout
console.log('Step 1: Creating checkout...');
const checkoutCreateMutation = `
mutation CheckoutCreate($input: CheckoutCreateInput!) {
checkoutCreate(input: $input) {
checkout {
id
token
totalPrice {
gross {
amount
currency
}
}
subtotalPrice {
gross {
amount
currency
}
}
}
errors {
field
message
}
}
}
`;
const checkoutResult = await saleorFetch(checkoutCreateMutation, {
input: {
channel: 'default-channel',
email: TEST_EMAIL,
lines: [],
languageCode: 'SR'
}
});
if (checkoutResult.checkoutCreate.errors?.length > 0) {
throw new Error(`Checkout creation failed: ${checkoutResult.checkoutCreate.errors[0].message}`);
}
const checkout = checkoutResult.checkoutCreate.checkout;
console.log(`✅ Checkout created: ${checkout.id}`);
console.log(` Token: ${checkout.token}`);
console.log(` Initial total: ${checkout.totalPrice.gross.amount} ${checkout.totalPrice.gross.currency}\n`);
// Step 2: Add product to checkout
console.log('Step 2: Adding product to checkout...');
const linesAddMutation = `
mutation CheckoutLinesAdd($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lines) {
checkout {
id
totalPrice {
gross {
amount
currency
}
}
subtotalPrice {
gross {
amount
currency
}
}
lines {
id
quantity
totalPrice {
gross {
amount
}
}
}
}
errors {
field
message
}
}
}
`;
// First, let's query for available products to get a real variant ID
console.log(' Querying available products...');
const productsQuery = `
query Products {
products(channel: "default-channel", first: 1) {
edges {
node {
id
name
variants {
id
name
}
}
}
}
}
`;
const productsResult = await saleorFetch(productsQuery);
const product = productsResult.products.edges[0]?.node;
if (!product || !product.variants?.[0]) {
throw new Error('No products found in store');
}
const variantId = product.variants[0].id;
console.log(` Product: ${product.name}, Variant: ${product.variants[0].name}`);
const linesResult = await saleorFetch(linesAddMutation, {
checkoutId: checkout.id,
lines: [{ variantId, quantity: 1 }]
});
if (linesResult.checkoutLinesAdd.errors?.length > 0) {
throw new Error(`Adding lines failed: ${linesResult.checkoutLinesAdd.errors[0].message}`);
}
const checkoutWithLines = linesResult.checkoutLinesAdd.checkout;
const productTotal = checkoutWithLines.totalPrice.gross.amount;
console.log(`✅ Product added (qty: 1)`);
console.log(` Product total: ${productTotal} RSD\n`);
// Step 3: Set shipping address
console.log('Step 3: Setting shipping address...');
const shippingAddressMutation = `
mutation CheckoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
checkout {
id
shippingMethods {
id
name
price {
amount
currency
}
}
}
errors {
field
message
}
}
}
`;
const shippingResult = await saleorFetch(shippingAddressMutation, {
checkoutId: checkout.id,
shippingAddress: TEST_SHIPPING_ADDRESS
});
if (shippingResult.checkoutShippingAddressUpdate.errors?.length > 0) {
throw new Error(`Setting shipping address failed: ${shippingResult.checkoutShippingAddressUpdate.errors[0].message}`);
}
const availableMethods = shippingResult.checkoutShippingAddressUpdate.checkout.shippingMethods;
console.log(`✅ Shipping address set`);
console.log(` Available shipping methods: ${availableMethods.length}`);
if (availableMethods.length === 0) {
console.log(' ⚠️ No shipping methods available for this address/region');
return;
}
availableMethods.forEach((method, i) => {
console.log(` [${i + 1}] ${method.name}: ${method.price.amount} ${method.price.currency}`);
});
console.log('');
// Step 4: Set shipping method
const selectedMethod = availableMethods[0];
console.log(`Step 4: Selecting shipping method: ${selectedMethod.name} (${selectedMethod.price.amount} RSD)...`);
const shippingMethodMutation = `
mutation CheckoutShippingMethodUpdate($checkoutId: ID!, $shippingMethodId: ID!) {
checkoutShippingMethodUpdate(checkoutId: $checkoutId, shippingMethodId: $shippingMethodId) {
checkout {
id
totalPrice {
gross {
amount
currency
}
}
subtotalPrice {
gross {
amount
currency
}
}
shippingPrice {
gross {
amount
currency
}
}
}
errors {
field
message
}
}
}
`;
const methodResult = await saleorFetch(shippingMethodMutation, {
checkoutId: checkout.id,
shippingMethodId: selectedMethod.id
});
if (methodResult.checkoutShippingMethodUpdate.errors?.length > 0) {
throw new Error(`Setting shipping method failed: ${methodResult.checkoutShippingMethodUpdate.errors[0].message}`);
}
const finalCheckout = methodResult.checkoutShippingMethodUpdate.checkout;
const subtotal = finalCheckout.subtotalPrice.gross.amount;
const shipping = finalCheckout.shippingPrice.gross.amount;
const finalTotal = finalCheckout.totalPrice.gross.amount;
const expectedTotal = subtotal + shipping;
console.log(`✅ Shipping method set`);
console.log(` Subtotal: ${subtotal} RSD`);
console.log(` Shipping: ${shipping} RSD`);
console.log(` Total: ${finalTotal} RSD`);
console.log(` Expected: ${expectedTotal} RSD`);
console.log('');
// Verification
console.log('📊 VERIFICATION:');
if (finalTotal === expectedTotal) {
console.log('✅ PASS: Total includes shipping cost correctly');
console.log(` ${subtotal} + ${shipping} = ${finalTotal}`);
} else {
console.log('❌ FAIL: Total does NOT include shipping cost');
console.log(` Expected: ${expectedTotal}, Got: ${finalTotal}`);
console.log(` Difference: ${expectedTotal - finalTotal}`);
}
// Cleanup - delete checkout
console.log('\n🧹 Cleaning up test checkout...');
// Note: Checkout deletion requires admin permissions
console.log(` Checkout ID for manual cleanup: ${checkout.id}`);
} catch (error) {
console.error('\n❌ Test failed:', error.message);
process.exit(1);
}
}
// Run the test
testCheckoutWithShipping();

View File

137
scripts/test-frontend.mjs Normal file
View File

@@ -0,0 +1,137 @@
const SALEOR_API_URL = 'https://api.manoonoils.com/graphql/';
async function saleorFetch(query, variables = {}) {
const response = await fetch(SALEOR_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const result = await response.json();
if (result.errors) {
console.error('GraphQL Errors:', JSON.stringify(result.errors, null, 2));
throw new Error(JSON.stringify(result.errors));
}
return result.data;
}
async function test() {
// Create checkout
const createResult = await saleorFetch(`
mutation {
checkoutCreate(input: {
channel: "default-channel"
email: "test@test.com"
lines: [{ variantId: "UHJvZHVjdFZhcmlhbnQ6Mjk0", quantity: 1 }]
languageCode: SR
}) {
checkout {
id
token
totalPrice { gross { amount } }
subtotalPrice { gross { amount } }
}
errors {
field
message
code
}
}
}
`);
if (createResult.checkoutCreate.errors?.length > 0) {
console.error('Checkout creation errors:', createResult.checkoutCreate.errors);
throw new Error('Checkout creation failed');
}
if (!createResult.checkoutCreate.checkout) {
console.error('Create result:', createResult);
throw new Error('Checkout creation returned null');
}
const checkout = createResult.checkoutCreate.checkout;
const token = checkout.token;
console.log('Created checkout:');
console.log(' ID:', checkout.id);
console.log(' Token:', token);
console.log(' Initial Total:', checkout.totalPrice.gross.amount);
// Set address
await saleorFetch(`
mutation {
checkoutShippingAddressUpdate(
checkoutId: "${checkout.id}"
shippingAddress: {
firstName: "Test"
lastName: "User"
streetAddress1: "123 Street"
city: "Belgrade"
postalCode: "11000"
country: "RS"
phone: "+38160123456"
}
) {
checkout {
shippingMethods { id name price { amount } }
}
}
}
`);
// Query by token (what refreshCheckout does)
const tokenQuery = await saleorFetch(`
query {
checkout(token: "${token}") {
id
token
totalPrice { gross { amount } }
subtotalPrice { gross { amount } }
shippingPrice { gross { amount } }
shippingMethods { id name price { amount } }
}
}
`);
console.log('\nQuery by token (before shipping method):');
console.log(' Total:', tokenQuery.checkout.totalPrice.gross.amount);
console.log(' Subtotal:', tokenQuery.checkout.subtotalPrice.gross.amount);
console.log(' Shipping:', tokenQuery.checkout.shippingPrice.gross.amount);
console.log(' Methods:', tokenQuery.checkout.shippingMethods.length);
if (tokenQuery.checkout.shippingMethods.length > 0) {
const methodId = tokenQuery.checkout.shippingMethods[0].id;
// Set shipping method
await saleorFetch(`
mutation {
checkoutShippingMethodUpdate(
checkoutId: "${checkout.id}"
shippingMethodId: "${methodId}"
) {
checkout {
totalPrice { gross { amount } }
subtotalPrice { gross { amount } }
shippingPrice { gross { amount } }
}
}
}
`);
// Query by token again (what should happen after refreshCheckout)
const afterMethod = await saleorFetch(`
query {
checkout(token: "${token}") {
totalPrice { gross { amount } }
subtotalPrice { gross { amount } }
shippingPrice { gross { amount } }
}
}
`);
console.log('\nQuery by token (AFTER shipping method):');
console.log(' Total:', afterMethod.checkout.totalPrice.gross.amount);
console.log(' Subtotal:', afterMethod.checkout.subtotalPrice.gross.amount);
console.log(' Shipping:', afterMethod.checkout.shippingPrice.gross.amount);
}
}
test().catch(console.error);

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
/**
* Complete API test simulating frontend checkout flow
* Tests every step the frontend takes
*/
const SALEOR_API_URL = 'https://api.manoonoils.com/graphql/';
async function saleorFetch(query, variables = {}) {
const response = await fetch(SALEOR_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.replace(/\n\s*/g, ' '), variables }),
});
const result = await response.json();
if (result.errors) {
console.error('GraphQL Error:', JSON.stringify(result.errors, null, 2));
throw new Error(result.errors[0].message);
}
return result.data;
}
async function runTest() {
console.log('🧪 TESTING FRONTEND CHECKOUT FLOW\n');
console.log('=' .repeat(50));
let checkoutId = null;
let checkoutToken = null;
let shippingMethodId = null;
try {
// STEP 1: Create checkout (like frontend does on first cart add)
console.log('\n📦 STEP 1: Create Checkout');
console.log('-'.repeat(50));
const createResult = await saleorFetch(`
mutation CheckoutCreate($input: CheckoutCreateInput!) {
checkoutCreate(input: $input) {
checkout {
id
token
totalPrice { gross { amount currency } }
subtotalPrice { gross { amount } }
}
errors { field message }
}
}
`, {
input: {
channel: "default-channel",
email: "test@test.com",
lines: [],
languageCode: "SR"
}
});
checkoutId = createResult.checkoutCreate.checkout.id;
checkoutToken = createResult.checkoutCreate.checkout.token;
console.log('✅ Checkout created');
console.log(' ID:', checkoutId);
console.log(' Token:', checkoutToken);
console.log(' Initial Total:', createResult.checkoutCreate.checkout.totalPrice.gross.amount, 'RSD');
// STEP 2: Add product (like frontend does)
console.log('\n🛒 STEP 2: Add Product to Cart');
console.log('-'.repeat(50));
// Get a valid variant first
const productsResult = await saleorFetch(`
query {
products(channel: "default-channel", first: 1) {
edges {
node {
variants { id name }
}
}
}
}
`);
const variantId = productsResult.products.edges[0].node.variants[0].id;
const addLineResult = await saleorFetch(`
mutation CheckoutLinesAdd($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lines) {
checkout {
id
token
totalPrice { gross { amount currency } }
subtotalPrice { gross { amount } }
}
errors { field message }
}
}
`, {
checkoutId: checkoutId,
lines: [{ variantId: variantId, quantity: 1 }]
});
const afterAdd = addLineResult.checkoutLinesAdd.checkout;
console.log('✅ Product added');
console.log(' Product Total:', afterAdd.totalPrice.gross.amount, 'RSD');
console.log(' Subtotal:', afterAdd.subtotalPrice.gross.amount, 'RSD');
// STEP 3: Refresh checkout by token (what refreshCheckout() does)
console.log('\n🔄 STEP 3: Refresh Checkout by Token');
console.log('-'.repeat(50));
console.log(' (This simulates what refreshCheckout() does in the store)');
const refreshResult = await saleorFetch(`
query GetCheckout($token: UUID!) {
checkout(token: $token) {
id
token
totalPrice { gross { amount currency } }
subtotalPrice { gross { amount } }
}
}
`, { token: checkoutToken });
console.log('✅ Refreshed checkout');
console.log(' Total from refresh:', refreshResult.checkout.totalPrice.gross.amount, 'RSD');
// STEP 4: Set shipping address
console.log('\n📍 STEP 4: Set Shipping Address');
console.log('-'.repeat(50));
const addressResult = await saleorFetch(`
mutation CheckoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
checkout {
id
shippingMethods { id name price { amount currency } }
}
errors { field message }
}
}
`, {
checkoutId: checkoutId,
shippingAddress: {
firstName: "Test",
lastName: "User",
streetAddress1: "123 Test Street",
city: "Belgrade",
postalCode: "11000",
country: "RS",
phone: "+38160123456"
}
});
const methods = addressResult.checkoutShippingAddressUpdate.checkout.shippingMethods;
console.log('✅ Address set');
console.log(' Available shipping methods:', methods.length);
if (methods.length === 0) {
console.log('❌ No shipping methods available!');
return;
}
methods.forEach((m, i) => {
console.log(` [${i+1}] ${m.name}: ${m.price.amount} ${m.price.currency}`);
});
shippingMethodId = methods[0].id;
const shippingPrice = methods[0].price.amount;
// STEP 5: Select shipping method (what happens when user clicks radio button)
console.log('\n🚚 STEP 5: Select Shipping Method');
console.log('-'.repeat(50));
console.log(` Selecting: ${methods[0].name} (${shippingPrice} RSD)`);
const methodResult = await saleorFetch(`
mutation CheckoutShippingMethodUpdate($checkoutId: ID!, $shippingMethodId: ID!) {
checkoutShippingMethodUpdate(checkoutId: $checkoutId, shippingMethodId: $shippingMethodId) {
checkout {
id
totalPrice { gross { amount currency } }
subtotalPrice { gross { amount } }
shippingPrice { gross { amount } }
}
errors { field message }
}
}
`, {
checkoutId: checkoutId,
shippingMethodId: shippingMethodId
});
const afterMethod = methodResult.checkoutShippingMethodUpdate.checkout;
console.log('✅ Shipping method set');
console.log(' Total:', afterMethod.totalPrice.gross.amount, 'RSD');
console.log(' Subtotal:', afterMethod.subtotalPrice.gross.amount, 'RSD');
console.log(' Shipping:', afterMethod.shippingPrice.gross.amount, 'RSD');
// STEP 6: Refresh checkout again (what refreshCheckout() does after setting method)
console.log('\n🔄 STEP 6: Refresh Checkout Again');
console.log('-'.repeat(50));
console.log(' (Simulating refreshCheckout() call in handleShippingMethodSelect)');
const finalRefresh = await saleorFetch(`
query GetCheckout($token: UUID!) {
checkout(token: $token) {
id
token
totalPrice { gross { amount currency } }
subtotalPrice { gross { amount } }
shippingPrice { gross { amount } }
}
}
`, { token: checkoutToken });
const final = finalRefresh.checkout;
console.log('✅ Final checkout state after refresh:');
console.log(' Total:', final.totalPrice.gross.amount, 'RSD');
console.log(' Subtotal:', final.subtotalPrice.gross.amount, 'RSD');
console.log(' Shipping:', final.shippingPrice.gross.amount, 'RSD');
// VERIFICATION
console.log('\n📊 VERIFICATION');
console.log('=' .repeat(50));
const expectedTotal = final.subtotalPrice.gross.amount + final.shippingPrice.gross.amount;
const actualTotal = final.totalPrice.gross.amount;
if (actualTotal === expectedTotal) {
console.log('✅ PASS: API returns correct total with shipping');
console.log(` ${final.subtotalPrice.gross.amount} + ${final.shippingPrice.gross.amount} = ${actualTotal}`);
} else {
console.log('❌ FAIL: API total does not include shipping');
console.log(` Expected: ${expectedTotal}, Got: ${actualTotal}`);
}
console.log('\n🔍 FRONTEND ISSUE ANALYSIS');
console.log('=' .repeat(50));
console.log('The API works correctly. The bug is in the frontend.');
console.log('');
console.log('What should happen:');
console.log(' 1. User selects shipping method → handleShippingMethodSelect()');
console.log(' 2. Calls checkoutService.updateShippingMethod() → API updates');
console.log(' 3. Calls refreshCheckout() → store updates with new checkout');
console.log(' 4. Component re-renders with new checkout.totalPrice');
console.log('');
console.log('Check browser console for:');
console.log(' - [Checkout Debug] logs showing totalPrice values');
console.log(' - Network tab showing the GraphQL mutation/refresh calls');
console.log(' - React DevTools showing if checkout object updates');
} catch (error) {
console.error('\n❌ Test failed:', error.message);
process.exit(1);
}
}
runTest();

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* Full order creation test via API
* Tests complete checkout flow including order completion
*/
const SALEOR_API_URL = 'https://api.manoonoils.com/graphql/';
async function saleorFetch(query, variables = {}) {
const response = await fetch(SALEOR_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.replace(/\n\s*/g, ' '), variables }),
});
const result = await response.json();
if (result.errors) {
console.error('GraphQL Error:', JSON.stringify(result.errors, null, 2));
throw new Error(result.errors[0].message);
}
return result.data;
}
async function runOrderTest() {
console.log('🧪 FULL ORDER CREATION TEST ON DEV BRANCH\n');
console.log('=' .repeat(60));
try {
// STEP 1: Create checkout
console.log('\n📦 STEP 1: Create Checkout');
const createResult = await saleorFetch(`
mutation CheckoutCreate($input: CheckoutCreateInput!) {
checkoutCreate(input: $input) {
checkout {
id
token
totalPrice { gross { amount currency } }
}
errors { field message }
}
}
`, {
input: {
channel: "default-channel",
email: "test-order@example.com",
lines: [],
languageCode: "SR"
}
});
const checkoutId = createResult.checkoutCreate.checkout.id;
console.log('✅ Checkout created:', checkoutId);
// STEP 2: Get product and add to cart
console.log('\n🛒 STEP 2: Add Product');
const productsResult = await saleorFetch(`
query {
products(channel: "default-channel", first: 1) {
edges { node { variants { id name } } }
}
}
`);
const variantId = productsResult.products.edges[0].node.variants[0].id;
await saleorFetch(`
mutation CheckoutLinesAdd($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lines) {
checkout { id }
errors { field message }
}
}
`, {
checkoutId: checkoutId,
lines: [{ variantId: variantId, quantity: 1 }]
});
console.log('✅ Product added');
// STEP 3: Update email
console.log('\n📧 STEP 3: Update Email');
await saleorFetch(`
mutation CheckoutEmailUpdate($checkoutId: ID!, $email: String!) {
checkoutEmailUpdate(checkoutId: $checkoutId, email: $email) {
checkout { id }
errors { field message }
}
}
`, { checkoutId: checkoutId, email: "test-order@example.com" });
console.log('✅ Email updated');
// STEP 4: Set shipping address
console.log('\n📍 STEP 4: Set Shipping Address');
await saleorFetch(`
mutation CheckoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
checkout {
id
shippingMethods { id name price { amount } }
}
errors { field message }
}
}
`, {
checkoutId: checkoutId,
shippingAddress: {
firstName: "Test",
lastName: "User",
streetAddress1: "123 Test Street",
city: "Belgrade",
postalCode: "11000",
country: "RS",
phone: "+38160123456"
}
});
// Get shipping methods
const methodsResult = await saleorFetch(`
query GetCheckout($token: UUID!) {
checkout(token: $token) {
shippingMethods { id name price { amount } }
}
}
`, { token: createResult.checkoutCreate.checkout.token });
const shippingMethodId = methodsResult.checkout.shippingMethods[0].id;
console.log('✅ Address set, shipping method available:', methodsResult.checkout.shippingMethods[0].name);
// STEP 5: Set billing address
console.log('\n💳 STEP 5: Set Billing Address');
await saleorFetch(`
mutation CheckoutBillingAddressUpdate($checkoutId: ID!, $billingAddress: AddressInput!) {
checkoutBillingAddressUpdate(checkoutId: $checkoutId, billingAddress: $billingAddress) {
checkout { id }
errors { field message }
}
}
`, {
checkoutId: checkoutId,
billingAddress: {
firstName: "Test",
lastName: "User",
streetAddress1: "123 Test Street",
city: "Belgrade",
postalCode: "11000",
country: "RS",
phone: "+38160123456"
}
});
console.log('✅ Billing address set');
// STEP 6: Select shipping method
console.log('\n🚚 STEP 6: Select Shipping Method');
await saleorFetch(`
mutation CheckoutShippingMethodUpdate($checkoutId: ID!, $shippingMethodId: ID!) {
checkoutShippingMethodUpdate(checkoutId: $checkoutId, shippingMethodId: $shippingMethodId) {
checkout {
id
totalPrice { gross { amount } }
subtotalPrice { gross { amount } }
shippingPrice { gross { amount } }
}
errors { field message }
}
}
`, { checkoutId: checkoutId, shippingMethodId: shippingMethodId });
console.log('✅ Shipping method selected');
// STEP 7: Complete checkout (create order)
console.log('\n✅ STEP 7: Complete Checkout (Create Order)');
console.log('-'.repeat(60));
const completeResult = await saleorFetch(`
mutation CheckoutComplete($checkoutId: ID!) {
checkoutComplete(checkoutId: $checkoutId) {
order {
id
number
status
created
total {
gross { amount currency }
}
subtotal {
gross { amount }
}
shippingPrice {
gross { amount }
}
}
errors { field message }
}
}
`, { checkoutId: checkoutId });
if (completeResult.checkoutComplete.errors?.length > 0) {
throw new Error(`Order creation failed: ${completeResult.checkoutComplete.errors[0].message}`);
}
const order = completeResult.checkoutComplete.order;
console.log('✅ ORDER CREATED SUCCESSFULLY!');
console.log('');
console.log('Order Details:');
console.log(' Order ID:', order.id);
console.log(' Order Number:', order.number);
console.log(' Status:', order.status);
console.log(' Created:', order.created);
console.log('');
console.log('Pricing:');
console.log(' Subtotal:', order.subtotal.gross.amount, 'RSD');
console.log(' Shipping:', order.shippingPrice.gross.amount, 'RSD');
console.log(' Total:', order.total.gross.amount, 'RSD');
// Verification
const expectedTotal = order.subtotal.gross.amount + order.shippingPrice.gross.amount;
console.log('');
console.log('📊 VERIFICATION:');
if (order.total.gross.amount === expectedTotal) {
console.log('✅ PASS: Order total includes shipping correctly');
console.log(` ${order.subtotal.gross.amount} + ${order.shippingPrice.gross.amount} = ${order.total.gross.amount}`);
} else {
console.log('❌ FAIL: Order total does not match expected');
}
console.log('');
console.log('🎉 DEV BRANCH TEST COMPLETE - ALL SYSTEMS GO!');
} catch (error) {
console.error('\n❌ Test failed:', error.message);
process.exit(1);
}
}
runOrderTest();

View File

@@ -3,7 +3,7 @@ import { vi } from "vitest";
// Mock environment variables
process.env.NEXT_PUBLIC_SALEOR_API_URL = "https://api.manoonoils.com/graphql/";
process.env.NEXT_PUBLIC_SITE_URL = "https://dev.manoonoils.com";
process.env.NEXT_PUBLIC_SITE_URL = "https://manoonoils.com";
process.env.DASHBOARD_URL = "https://dashboard.manoonoils.com";
process.env.RESEND_API_KEY = "test-api-key";
process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID = "test-client-id";

View File

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

View File

@@ -4,7 +4,7 @@ import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
import { getPageKeywords } from "@/lib/seo/keywords";
import ContactPageClient from "./ContactPageClient";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
interface ContactPageProps {
params: Promise<{ locale: string }>;

View File

@@ -3,8 +3,12 @@ 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";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
// Rybbit configuration
const RYBBIT_SITE_ID = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID || "1";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
export function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
@@ -46,13 +50,18 @@ export default async function LocaleLayout({
return (
<>
<OpenPanelComponent
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
trackScreenViews={true}
trackOutgoingLinks={true}
apiUrl="https://op.nodecrew.me/api"
scriptUrl="https://op.nodecrew.me/op1.js"
/>
<OpenPanelComponent
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
trackScreenViews={true}
trackOutgoingLinks={true}
apiUrl="/api/op"
scriptUrl="/api/op1"
/>
<Script
src="/api/script.js"
data-site-id={RYBBIT_SITE_ID}
strategy="lazyOnload"
/>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>

View File

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

View File

@@ -33,7 +33,7 @@ export async function generateStaticParams() {
return params;
}
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { locale, slug } = await params;

View File

@@ -9,7 +9,7 @@ import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/l
import { getPageKeywords } from "@/lib/seo/keywords";
import { Metadata } from "next";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
interface ProductsPageProps {
params: Promise<{ locale: string }>;

View File

@@ -1,5 +0,0 @@
import { createRouteHandler } from "@openpanel/nextjs/server";
export const { GET, POST } = createRouteHandler({
apiUrl: "https://op.nodecrew.me/api",
});

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
const OPENPANEL_API_URL = "https://op.nodecrew.me/api";
export async function POST(request: Request) {
try {
const body = await request.json();
const response = await fetch(`${OPENPANEL_API_URL}/track`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("[OpenPanel] Track error:", error);
return NextResponse.json({ error: "Failed to track event" }, { status: 500 });
}
}

24
src/app/api/op1/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
const OPENPANEL_SCRIPT_URL = "https://op.nodecrew.me/op1.js";
export async function GET(request: Request) {
const url = new URL(request.url);
const searchParams = url.search;
try {
const response = await fetch(`${OPENPANEL_SCRIPT_URL}${searchParams}`);
const content = await response.text();
return new NextResponse(content, {
status: 200,
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=86400, stale-while-revalidate=86400",
},
});
} catch (error) {
console.error("[OpenPanel] Failed to fetch script:", error);
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
}
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
return {
rules: [

View File

@@ -2,7 +2,7 @@ import { MetadataRoute } from "next";
import { getProducts, filterOutBundles } from "@/lib/saleor";
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
interface SitemapEntry {
url: string;

View File

@@ -8,6 +8,7 @@ import { X, Minus, Plus, Trash2, ShoppingBag } from "lucide-react";
import { useTranslations, useLocale } from "next-intl";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { formatPrice } from "@/lib/saleor";
import { useAnalytics } from "@/lib/analytics";
export default function CartDrawer() {
const t = useTranslations("Cart");
@@ -26,11 +27,13 @@ export default function CartDrawer() {
initCheckout,
clearError,
} = useSaleorCheckoutStore();
const { trackCartView, trackRemoveFromCart } = useAnalytics();
const lines = getLines();
const total = getTotal();
const lineCount = getLineCount();
const initializedRef = useRef(false);
const lastCartStateRef = useRef<{ count: number; total: number } | null>(null);
useEffect(() => {
if (!initializedRef.current && locale) {
@@ -52,6 +55,22 @@ export default function CartDrawer() {
};
}, [isOpen]);
useEffect(() => {
if (isOpen && lines.length > 0) {
const currentState = { count: lineCount, total };
if (!lastCartStateRef.current ||
lastCartStateRef.current.count !== currentState.count ||
lastCartStateRef.current.total !== currentState.total) {
trackCartView({
total,
currency: checkout?.totalPrice?.gross?.currency || "RSD",
item_count: lineCount,
});
lastCartStateRef.current = currentState;
}
}
}, [isOpen, lineCount, total]);
return (
<AnimatePresence>
{isOpen && (
@@ -181,7 +200,14 @@ export default function CartDrawer() {
</div>
<button
onClick={() => removeLine(line.id)}
onClick={() => {
trackRemoveFromCart({
id: line.variant.product.id,
name: line.variant.product.name,
quantity: line.quantity,
});
removeLine(line.id);
}}
disabled={isLoading}
className="p-2 text-[#999999] hover:text-red-500 transition-colors"
aria-label={t("removeItem")}

View File

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

View File

@@ -4,14 +4,17 @@ import { motion } from "framer-motion";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { ArrowRight } from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
export default function NewsletterSection() {
const t = useTranslations("Newsletter");
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const { trackNewsletterSignup } = useAnalytics();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
trackNewsletterSignup(email, "footer");
setStatus("success");
setEmail("");
};

View File

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

View File

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

View File

@@ -9,6 +9,11 @@ const op = new OpenPanel({
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
});
// Rybbit server-side tracking
const RYBBIT_HOST = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
const RYBBIT_API_KEY = process.env.RYBBIT_API_KEY;
const RYBBIT_SITE_ID = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID || "1";
export interface ServerOrderData {
orderId: string;
orderNumber: string;
@@ -26,6 +31,34 @@ export interface ServerEventData {
properties?: Record<string, any>;
}
async function trackRybbitServer(eventName: string, properties?: Record<string, any>) {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (RYBBIT_API_KEY) {
headers["Authorization"] = `Bearer ${RYBBIT_API_KEY}`;
}
const response = await fetch(`${RYBBIT_HOST}/api/track`, {
method: "POST",
headers,
body: JSON.stringify({
site_id: RYBBIT_SITE_ID,
type: "custom_event",
event_name: eventName,
properties: JSON.stringify(properties || {}),
}),
});
if (!response.ok) {
console.warn("[Rybbit Server] Track failed:", await response.text());
}
} catch (error) {
console.warn("[Rybbit Server] Track error:", error);
}
}
/**
* Server-side analytics tracking
* Called from API routes or Server Components
@@ -34,7 +67,7 @@ export async function trackOrderCompletedServer(data: ServerOrderData) {
try {
console.log("[Server Analytics] Tracking order:", data.orderNumber, "Total:", data.total);
// Track order event
// Track order event with OpenPanel
await op.track("order_completed", {
order_id: data.orderId,
order_number: data.orderNumber,
@@ -48,7 +81,7 @@ export async function trackOrderCompletedServer(data: ServerOrderData) {
source: "server",
});
// Track revenue (this is the important part!)
// Track revenue with OpenPanel
await op.revenue(data.total, {
currency: data.currency,
transaction_id: data.orderNumber,
@@ -56,6 +89,21 @@ export async function trackOrderCompletedServer(data: ServerOrderData) {
source: "server",
});
// Track conversion/revenue with Rybbit
await trackRybbitServer("order_completed", {
order_id: data.orderId,
order_number: data.orderNumber,
total: data.total,
currency: data.currency,
item_count: data.itemCount,
customer_email: data.customerEmail,
payment_method: data.paymentMethod,
shipping_cost: data.shippingCost,
coupon_code: data.couponCode,
revenue: data.total,
source: "server",
});
console.log("[Server Analytics] Order tracked successfully");
return { success: true };
} catch (error) {
@@ -74,6 +122,10 @@ export async function trackServerEvent(data: ServerEventData) {
...data.properties,
source: "server",
});
// Also track to Rybbit
await trackRybbitServer(data.event, data.properties);
return { success: true };
} catch (error) {
console.error("[Server Analytics] Event tracking failed:", error);

View File

@@ -2,11 +2,123 @@
import { useOpenPanel } from "@openpanel/nextjs";
import { useCallback } from "react";
import {
trackRybbitProductView,
trackRybbitAddToCart,
trackRybbitRemoveFromCart,
trackRybbitCheckoutStarted,
trackRybbitCheckoutStep,
trackRybbitOrderCompleted,
trackRybbitSearch,
trackRybbitExternalLink,
trackRybbitCartView,
trackRybbitWishlistAdd,
trackRybbitUserLogin,
trackRybbitUserRegister,
trackRybbitNewsletterSignup,
} from "@/lib/services/RybbitService";
export function useAnalytics() {
const op = useOpenPanel();
// Client-side tracking for user behavior
// 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;
@@ -14,19 +126,15 @@ export function useAnalytics() {
currency: string;
category?: string;
}) => {
try {
op.track("product_viewed", {
product_id: product.id,
product_name: product.name,
price: product.price,
currency: product.currency,
category: product.category,
source: "client",
});
} catch (e) {
console.error("[Client Analytics] Product view error:", e);
}
}, [op]);
trackDual("product_viewed", {
product_id: product.id,
product_name: product.name,
price: product.price,
currency: product.currency,
category: product.category,
source: "client",
});
}, [trackDual]);
const trackAddToCart = useCallback((product: {
id: string;
@@ -36,37 +144,42 @@ export function useAnalytics() {
quantity: number;
variant?: string;
}) => {
try {
op.track("add_to_cart", {
product_id: product.id,
product_name: product.name,
price: product.price,
currency: product.currency,
quantity: product.quantity,
variant: product.variant,
source: "client",
});
} catch (e) {
console.error("[Client Analytics] Add to cart error:", e);
}
}, [op]);
trackDual("add_to_cart", {
product_id: product.id,
product_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;
}) => {
try {
op.track("remove_from_cart", {
product_id: product.id,
product_name: product.name,
quantity: product.quantity,
source: "client",
});
} catch (e) {
console.error("[Client Analytics] Remove from cart error:", e);
}
}, [op]);
trackDual("remove_from_cart", {
product_id: product.id,
product_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,
currency: cart.currency,
item_count: cart.item_count,
source: "client",
});
}, [trackDual]);
const trackCheckoutStarted = useCallback((cart: {
total: number;
@@ -79,36 +192,23 @@ export function useAnalytics() {
price: number;
}>;
}) => {
try {
op.track("checkout_started", {
cart_total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
items: cart.items,
source: "client",
});
} catch (e) {
console.error("[Client Analytics] Checkout started error:", e);
}
}, [op]);
trackDual("checkout_started", {
cart_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>) => {
try {
op.track("checkout_step", {
step,
...data,
source: "client",
});
} catch (e) {
console.error("[Client Analytics] Checkout step error:", e);
}
}, [op]);
trackDual("checkout_step", {
step,
...data,
source: "client",
});
}, [trackDual]);
/**
* DUAL TRACKING: Order completion
* 1. Track client-side (immediate, captures user session)
* 2. Call server-side API (reliable, can't be blocked)
*/
const trackOrderCompleted = useCallback(async (order: {
order_id: string;
order_number: string;
@@ -119,37 +219,34 @@ export function useAnalytics() {
customer_email?: string;
payment_method?: string;
}) => {
console.log("[Dual Analytics] Tracking order:", order.order_number, "Total:", order.total);
console.log("[Analytics] Tracking order:", order.order_number);
// CLIENT-SIDE: Track immediately for user session data
// Track with both OpenPanel and Rybbit
trackDual("order_completed", {
order_id: order.order_id,
order_number: order.order_number,
total: order.total,
currency: order.currency,
item_count: order.item_count,
shipping_cost: order.shipping_cost,
customer_email: order.customer_email,
payment_method: order.payment_method,
source: "client",
});
// OpenPanel revenue tracking
try {
op.track("order_completed", {
order_id: order.order_id,
order_number: order.order_number,
total: order.total,
currency: order.currency,
item_count: order.item_count,
shipping_cost: order.shipping_cost,
customer_email: order.customer_email,
payment_method: order.payment_method,
source: "client",
});
op.revenue(order.total, {
currency: order.currency,
transaction_id: order.order_number,
source: "client",
});
console.log("[Client Analytics] Order tracked");
} catch (e) {
console.error("[Client Analytics] Order tracking error:", e);
console.error("[OpenPanel] Revenue tracking error:", e);
}
// SERVER-SIDE: Call API for reliable tracking
// Server-side tracking for reliability
try {
console.log("[Server Analytics] Calling server-side tracking API...");
const response = await fetch("/api/analytics/track-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -165,39 +262,61 @@ export function useAnalytics() {
}),
});
if (response.ok) {
console.log("[Server Analytics] Order tracked successfully");
} else {
if (!response.ok) {
console.error("[Server Analytics] Failed:", await response.text());
}
} catch (e) {
console.error("[Server Analytics] API call failed:", e);
}
}, [op]);
}, [op, trackDual]);
const trackSearch = useCallback((query: string, results_count: number) => {
try {
op.track("search", {
query,
results_count,
source: "client",
});
} catch (e) {
console.error("[Client Analytics] Search error:", e);
}
}, [op]);
trackDual("search", {
query,
results_count,
source: "client",
});
}, [trackDual]);
const trackExternalLink = useCallback((url: string, label?: string) => {
try {
op.track("external_link_click", {
url,
label,
source: "client",
});
} catch (e) {
console.error("[Client Analytics] External link error:", e);
}
}, [op]);
trackDual("external_link_click", {
url,
label,
source: "client",
});
}, [trackDual]);
const trackWishlistAdd = useCallback((product: {
id: string;
name: string;
}) => {
trackDual("wishlist_add", {
product_id: product.id,
product_name: product.name,
source: "client",
});
}, [trackDual]);
const trackUserLogin = useCallback((method: string) => {
trackDual("user_login", {
method,
source: "client",
});
}, [trackDual]);
const trackUserRegister = useCallback((method: string) => {
trackDual("user_register", {
method,
source: "client",
});
}, [trackDual]);
const trackNewsletterSignup = useCallback((email: string, source: string) => {
trackDual("newsletter_signup", {
email,
source,
});
}, [trackDual]);
const identifyUser = useCallback((user: {
profileId: string;
@@ -213,7 +332,7 @@ export function useAnalytics() {
email: user.email,
});
} catch (e) {
console.error("[Client Analytics] Identify error:", e);
console.error("[OpenPanel] Identify error:", e);
}
}, [op]);
@@ -221,11 +340,16 @@ export function useAnalytics() {
trackProductView,
trackAddToCart,
trackRemoveFromCart,
trackCartView,
trackCheckoutStarted,
trackCheckoutStep,
trackOrderCompleted,
trackSearch,
trackExternalLink,
trackWishlistAdd,
trackUserLogin,
trackUserRegister,
trackNewsletterSignup,
identifyUser,
};
}

View File

@@ -0,0 +1,209 @@
"use client";
// Rybbit Analytics Service
// Self-hosted instance at rybbit.nodecrew.me
declare global {
interface Window {
rybbit?: {
event: (eventName: string, eventData?: Record<string, any>) => void;
pageview: () => void;
};
}
}
export const RYBBIT_HOST = process.env.NEXT_PUBLIC_RYBBIT_HOST || "https://rybbit.nodecrew.me";
export const RYBBIT_SITE_ID = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID || "1";
/**
* Check if Rybbit is loaded and available
*/
export function isRybbitAvailable(): boolean {
return typeof window !== "undefined" &&
!!window.rybbit &&
typeof window.rybbit.event === "function";
}
/**
* Track a custom event with Rybbit
*/
export function trackRybbitEvent(
eventName: string,
eventData?: Record<string, any>
): void {
if (isRybbitAvailable()) {
try {
window.rybbit!.event(eventName, eventData);
} catch (e) {
console.warn("[Rybbit] Event tracking error:", e);
}
} else {
console.warn("[Rybbit] Not available for event:", eventName);
}
}
/**
* Track page view manually (usually auto-tracked by Rybbit script)
*/
export function trackRybbitPageview(): void {
if (isRybbitAvailable()) {
try {
window.rybbit!.pageview();
} catch (e) {
console.warn("[Rybbit] Pageview error:", e);
}
}
}
// E-commerce Event Tracking Functions
export function trackRybbitProductView(product: {
id: string;
name: string;
price: number;
currency: string;
category?: string;
variant?: string;
}): void {
trackRybbitEvent("product_view", {
product_id: product.id,
product_name: product.name,
price: product.price,
currency: product.currency,
category: product.category,
variant: product.variant,
});
}
export function trackRybbitAddToCart(product: {
id: string;
name: string;
price: number;
currency: string;
quantity: number;
variant?: string;
}): void {
trackRybbitEvent("add_to_cart", {
product_id: product.id,
product_name: product.name,
price: product.price,
currency: product.currency,
quantity: product.quantity,
variant: product.variant,
});
}
export function trackRybbitRemoveFromCart(product: {
id: string;
name: string;
quantity: number;
}): void {
trackRybbitEvent("remove_from_cart", {
product_id: product.id,
product_name: product.name,
quantity: product.quantity,
});
}
export function trackRybbitCartView(cart: {
total: number;
currency: string;
item_count: number;
}): void {
trackRybbitEvent("cart_view", {
cart_total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
});
}
export function trackRybbitCheckoutStarted(cart: {
total: number;
currency: string;
item_count: number;
items: Array<{
id: string;
name: string;
quantity: number;
price: number;
}>;
}): void {
trackRybbitEvent("checkout_started", {
cart_total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
items: cart.items,
});
}
export function trackRybbitCheckoutStep(step: string, data?: Record<string, unknown>): void {
trackRybbitEvent("checkout_step", {
step,
...data,
});
}
export function trackRybbitOrderCompleted(order: {
order_id: string;
order_number: string;
total: number;
currency: string;
item_count: number;
shipping_cost?: number;
customer_email?: string;
payment_method?: string;
}): void {
trackRybbitEvent("order_completed", {
order_id: order.order_id,
order_number: order.order_number,
total: order.total,
currency: order.currency,
item_count: order.item_count,
shipping_cost: order.shipping_cost,
customer_email: order.customer_email,
payment_method: order.payment_method,
});
}
export function trackRybbitSearch(query: string, results_count: number): void {
trackRybbitEvent("search", {
query,
results_count,
});
}
export function trackRybbitExternalLink(url: string, label?: string): void {
trackRybbitEvent("external_link_click", {
url,
label,
});
}
export function trackRybbitNewsletterSignup(email: string, source: string): void {
trackRybbitEvent("newsletter_signup", {
email,
source,
});
}
export function trackRybbitWishlistAdd(product: {
id: string;
name: string;
}): void {
trackRybbitEvent("wishlist_add", {
product_id: product.id,
product_name: product.name,
});
}
export function trackRybbitUserLogin(method: string): void {
trackRybbitEvent("user_login", {
method,
});
}
export function trackRybbitUserRegister(method: string): void {
trackRybbitEvent("user_register", {
method,
});
}