Compare commits
15 Commits
feature/do
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b9d8fa7d5 | ||
|
|
1c5ec1a271 | ||
|
|
8eb9f24b33 | ||
|
|
d4039c6e3b | ||
|
|
bbe618f22d | ||
|
|
cfb98a457f | ||
|
|
97479d542b | ||
|
|
56c05cc8fc | ||
|
|
511c3078c5 | ||
|
|
44091fc72a | ||
|
|
b3efebd3e4 | ||
|
|
044aefae94 | ||
|
|
36915a3f75 | ||
|
|
771e9dc20b | ||
|
|
df915ca128 |
3
features.md
Normal file
3
features.md
Normal 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
|
||||||
@@ -126,6 +126,10 @@ spec:
|
|||||||
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||||
- name: OPENPANEL_API_URL
|
- name: OPENPANEL_API_URL
|
||||||
value: "https://op.nodecrew.me/api"
|
value: "https://op.nodecrew.me/api"
|
||||||
|
- name: NEXT_PUBLIC_RYBBIT_HOST
|
||||||
|
value: "https://rybbit.nodecrew.me"
|
||||||
|
- name: NEXT_PUBLIC_RYBBIT_SITE_ID
|
||||||
|
value: "1"
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ spec:
|
|||||||
- web
|
- web
|
||||||
- websecure
|
- websecure
|
||||||
routes:
|
routes:
|
||||||
- match: Host(`dev.manoonoils.com`) || Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
|
- match: Host(`manoonoils.com`) || Host(`www.manoonoils.com`)
|
||||||
kind: Rule
|
kind: Rule
|
||||||
services:
|
services:
|
||||||
- name: storefront
|
- name: storefront
|
||||||
|
|||||||
@@ -5,7 +5,35 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
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: {
|
images: {
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
|
||||||
|
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
@@ -27,8 +55,16 @@ const nextConfig: NextConfig = {
|
|||||||
hostname: "**.saleor.cloud",
|
hostname: "**.saleor.cloud",
|
||||||
pathname: "/**",
|
pathname: "/**",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "images.unsplash.com",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ["lucide-react", "framer-motion"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
40
public/debug-op.js
Normal file
40
public/debug-op.js
Normal 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)');
|
||||||
|
})();
|
||||||
16
scripts/gsc-monitoring/Dockerfile
Normal file
16
scripts/gsc-monitoring/Dockerfile
Normal 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"]
|
||||||
121
scripts/gsc-monitoring/QUICKSTART.md
Normal file
121
scripts/gsc-monitoring/QUICKSTART.md
Normal 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!
|
||||||
261
scripts/gsc-monitoring/README.md
Normal file
261
scripts/gsc-monitoring/README.md
Normal 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
|
||||||
32
scripts/gsc-monitoring/cronjob-oauth.yaml
Normal file
32
scripts/gsc-monitoring/cronjob-oauth.yaml
Normal 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
|
||||||
45
scripts/gsc-monitoring/cronjob.yaml
Normal file
45
scripts/gsc-monitoring/cronjob.yaml
Normal 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
|
||||||
234
scripts/gsc-monitoring/monitor.py
Normal file
234
scripts/gsc-monitoring/monitor.py
Normal 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()
|
||||||
4
scripts/gsc-monitoring/requirements.txt
Normal file
4
scripts/gsc-monitoring/requirements.txt
Normal 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
|
||||||
164
scripts/gsc-monitoring/setup-oauth-local.py
Normal file
164
scripts/gsc-monitoring/setup-oauth-local.py
Normal 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()
|
||||||
133
scripts/gsc-monitoring/setup-oauth.py
Normal file
133
scripts/gsc-monitoring/setup-oauth.py
Normal 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()
|
||||||
310
scripts/test-checkout-shipping.js
Normal file
310
scripts/test-checkout-shipping.js
Normal 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();
|
||||||
0
scripts/test-frontend-checkout.js
Normal file
0
scripts/test-frontend-checkout.js
Normal file
137
scripts/test-frontend.mjs
Normal file
137
scripts/test-frontend.mjs
Normal 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);
|
||||||
254
scripts/test-full-checkout-flow.js
Normal file
254
scripts/test-full-checkout-flow.js
Normal 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();
|
||||||
232
scripts/test-order-creation.js
Normal file
232
scripts/test-order-creation.js
Normal 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();
|
||||||
@@ -3,7 +3,7 @@ import { vi } from "vitest";
|
|||||||
|
|
||||||
// Mock environment variables
|
// Mock environment variables
|
||||||
process.env.NEXT_PUBLIC_SALEOR_API_URL = "https://api.manoonoils.com/graphql/";
|
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.DASHBOARD_URL = "https://dashboard.manoonoils.com";
|
||||||
process.env.RESEND_API_KEY = "test-api-key";
|
process.env.RESEND_API_KEY = "test-api-key";
|
||||||
process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID = "test-client-id";
|
process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID = "test-client-id";
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
|||||||
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
||||||
import { getPageKeywords } from "@/lib/seo/keywords";
|
import { getPageKeywords } from "@/lib/seo/keywords";
|
||||||
import { Metadata } from "next";
|
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 {
|
interface AboutPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
@@ -67,10 +68,13 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
<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"
|
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
||||||
alt={metadata.about.productionAlt}
|
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 className="absolute inset-0 bg-black/20" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
|||||||
import { getPageKeywords } from "@/lib/seo/keywords";
|
import { getPageKeywords } from "@/lib/seo/keywords";
|
||||||
import ContactPageClient from "./ContactPageClient";
|
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 {
|
interface ContactPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
import { 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() {
|
export function generateStaticParams() {
|
||||||
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
|
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
|
||||||
@@ -46,13 +50,18 @@ export default async function LocaleLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OpenPanelComponent
|
<OpenPanelComponent
|
||||||
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||||
trackScreenViews={true}
|
trackScreenViews={true}
|
||||||
trackOutgoingLinks={true}
|
trackOutgoingLinks={true}
|
||||||
apiUrl="https://op.nodecrew.me/api"
|
apiUrl="/api/op"
|
||||||
scriptUrl="https://op.nodecrew.me/op1.js"
|
scriptUrl="/api/op1"
|
||||||
/>
|
/>
|
||||||
|
<Script
|
||||||
|
src="/api/script.js"
|
||||||
|
data-site-id={RYBBIT_SITE_ID}
|
||||||
|
strategy="lazyOnload"
|
||||||
|
/>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
|||||||
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||||
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
|
import { getPageKeywords, getBrandKeywords } from "@/lib/seo/keywords";
|
||||||
import { Metadata } from "next";
|
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> {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
@@ -157,10 +158,12 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
|
<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"
|
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
|
||||||
alt={metadata.home.productionAlt}
|
alt={metadata.home.productionAlt}
|
||||||
className="w-full h-full object-cover"
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function generateStaticParams() {
|
|||||||
return params;
|
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> {
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/l
|
|||||||
import { getPageKeywords } from "@/lib/seo/keywords";
|
import { getPageKeywords } from "@/lib/seo/keywords";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
|
||||||
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 {
|
interface ProductsPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createRouteHandler } from "@openpanel/nextjs/server";
|
|
||||||
|
|
||||||
export const { GET, POST } = createRouteHandler({
|
|
||||||
apiUrl: "https://op.nodecrew.me/api",
|
|
||||||
});
|
|
||||||
22
src/app/api/op/track/route.ts
Normal file
22
src/app/api/op/track/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const OPENPANEL_API_URL = "https://op.nodecrew.me/api";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const response = await fetch(`${OPENPANEL_API_URL}/track`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenPanel] Track error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to track event" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/api/op1/route.ts
Normal file
24
src/app/api/op1/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const OPENPANEL_SCRIPT_URL = "https://op.nodecrew.me/op1.js";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const searchParams = url.search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${OPENPANEL_SCRIPT_URL}${searchParams}`);
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
return new NextResponse(content, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/javascript",
|
||||||
|
"Cache-Control": "public, max-age=86400, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OpenPanel] Failed to fetch script:", error);
|
||||||
|
return new NextResponse("/* OpenPanel script unavailable */", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,8 +53,7 @@
|
|||||||
--color-cta-hover: #333333;
|
--color-cta-hover: #333333;
|
||||||
--color-overlay: rgba(0, 0, 0, 0.4);
|
--color-overlay: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
--font-display: 'DM Sans', sans-serif;
|
/* Font variables will be set by next/font in layout.tsx */
|
||||||
--font-body: 'Inter', sans-serif;
|
|
||||||
|
|
||||||
--transition-fast: 150ms ease;
|
--transition-fast: 150ms ease;
|
||||||
--transition-base: 250ms ease;
|
--transition-base: 250ms ease;
|
||||||
@@ -66,26 +65,9 @@
|
|||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
--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)
|
BASE STYLES (in Tailwind base layer)
|
||||||
|
Fonts loaded via next/font in layout.tsx
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@layer base {
|
@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
|
UTILITIES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { DM_Sans, Inter } from "next/font/google";
|
||||||
import ErrorBoundary from "@/components/providers/ErrorBoundary";
|
import ErrorBoundary from "@/components/providers/ErrorBoundary";
|
||||||
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
|
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
|
||||||
import { OrganizationSchema } from "@/components/seo";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -39,7 +52,7 @@ export default async function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning>
|
<html suppressHydrationWarning className={`${dmSans.variable} ${inter.variable}`}>
|
||||||
<body className="antialiased" suppressHydrationWarning>
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MetadataRoute } from "next";
|
import { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
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 {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MetadataRoute } from "next";
|
|||||||
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
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 {
|
interface SitemapEntry {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { X, Minus, Plus, Trash2, ShoppingBag } from "lucide-react";
|
|||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { formatPrice } from "@/lib/saleor";
|
import { formatPrice } from "@/lib/saleor";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
export default function CartDrawer() {
|
export default function CartDrawer() {
|
||||||
const t = useTranslations("Cart");
|
const t = useTranslations("Cart");
|
||||||
@@ -26,11 +27,13 @@ export default function CartDrawer() {
|
|||||||
initCheckout,
|
initCheckout,
|
||||||
clearError,
|
clearError,
|
||||||
} = useSaleorCheckoutStore();
|
} = useSaleorCheckoutStore();
|
||||||
|
const { trackCartView, trackRemoveFromCart } = useAnalytics();
|
||||||
|
|
||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
const lineCount = getLineCount();
|
const lineCount = getLineCount();
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
|
const lastCartStateRef = useRef<{ count: number; total: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initializedRef.current && locale) {
|
if (!initializedRef.current && locale) {
|
||||||
@@ -52,6 +55,22 @@ export default function CartDrawer() {
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -181,7 +200,14 @@ export default function CartDrawer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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}
|
disabled={isLoading}
|
||||||
className="p-2 text-[#999999] hover:text-red-500 transition-colors"
|
className="p-2 text-[#999999] hover:text-red-500 transition-colors"
|
||||||
aria-label={t("removeItem")}
|
aria-label={t("removeItem")}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
@@ -23,30 +23,23 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen w-full overflow-hidden">
|
<section className="relative min-h-screen w-full overflow-hidden">
|
||||||
{/* Background Image with Overlay */}
|
{/* Background Image with Overlay */}
|
||||||
<div
|
<div className="absolute inset-0">
|
||||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
<Image
|
||||||
style={{
|
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2574&auto=format&fit=crop"
|
||||||
backgroundImage: `url('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 className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-black/70" />
|
||||||
</div>
|
</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">
|
<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
|
<div className="max-w-4xl mx-auto animate-fadeSlideUp">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
|
||||||
className="max-w-4xl mx-auto"
|
|
||||||
>
|
|
||||||
{/* Social Proof Micro */}
|
{/* Social Proof Micro */}
|
||||||
<motion.div
|
<div className="flex items-center justify-center gap-2 mb-6 animate-fadeSlideUp" style={{ animationDelay: "0.1s" }}>
|
||||||
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">
|
<div className="flex">
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[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">
|
<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">
|
<span className="text-sm text-white/80">
|
||||||
{t("lovedBy")}
|
{t("lovedBy")}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Main Heading - Outcome Focused */}
|
{/* Main Heading */}
|
||||||
<motion.h1
|
<h1
|
||||||
initial={{ opacity: 0, y: 30 }}
|
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight animate-fadeSlideUp"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
style={{ animationDelay: "0.2s" }}
|
||||||
transition={{ duration: 0.8, delay: 0.5 }}
|
|
||||||
className="text-4xl md:text-6xl lg:text-7xl font-medium mb-6 tracking-tight leading-tight"
|
|
||||||
>
|
>
|
||||||
{t("transformHeadline")}
|
{t("transformHeadline")}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-white/90">{t("withNaturalOils")}</span>
|
<span className="text-white/90">{t("withNaturalOils")}</span>
|
||||||
</motion.h1>
|
</h1>
|
||||||
|
|
||||||
{/* Subtitle - Expands on how */}
|
{/* Subtitle */}
|
||||||
<motion.p
|
<p
|
||||||
initial={{ opacity: 0, y: 20 }}
|
className="text-lg md:text-xl text-white/80 mb-8 font-light max-w-2xl mx-auto leading-relaxed animate-fadeSlideUp"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
style={{ animationDelay: "0.3s" }}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{t("subtitleText")}
|
{t("subtitleText")}
|
||||||
</motion.p>
|
</p>
|
||||||
|
|
||||||
{/* CTA Button - Action verb + value */}
|
{/* CTA Buttons */}
|
||||||
<motion.div
|
<div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fadeSlideUp"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
style={{ animationDelay: "0.4s" }}
|
||||||
transition={{ duration: 0.6, delay: 0.9 }}
|
|
||||||
className="flex flex-col sm:flex-row items-center justify-center gap-4"
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`${localePath}/products`}
|
href={`${localePath}/products`}
|
||||||
@@ -100,14 +87,12 @@ export default function HeroVideo({ locale = "sr" }: HeroVideoProps) {
|
|||||||
>
|
>
|
||||||
{t("learnStory")}
|
{t("learnStory")}
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Trust Indicators */}
|
{/* Trust Indicators */}
|
||||||
<motion.div
|
<div
|
||||||
initial={{ opacity: 0 }}
|
className="flex flex-wrap items-center justify-center gap-6 mt-12 text-sm text-white/60 animate-fadeSlideUp"
|
||||||
animate={{ opacity: 1 }}
|
style={{ animationDelay: "0.5s" }}
|
||||||
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 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</svg>
|
||||||
<span>{t("crueltyFree")}</span>
|
<span>{t("crueltyFree")}</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll Indicator */}
|
{/* Scroll Indicator */}
|
||||||
<motion.button
|
<button
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 1.5, duration: 0.8 }}
|
|
||||||
onClick={scrollToContent}
|
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"
|
aria-label="Scroll to content"
|
||||||
>
|
>
|
||||||
<motion.div
|
<div className="scroll-indicator">
|
||||||
animate={{ y: [0, 8, 0] }}
|
|
||||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<ChevronDown className="w-6 h-6" strokeWidth={1.5} />
|
<ChevronDown className="w-6 h-6" strokeWidth={1.5} />
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,14 +4,17 @@ import { motion } from "framer-motion";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
export default function NewsletterSection() {
|
export default function NewsletterSection() {
|
||||||
const t = useTranslations("Newsletter");
|
const t = useTranslations("Newsletter");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
|
const { trackNewsletterSignup } = useAnalytics();
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
trackNewsletterSignup(email, "footer");
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,11 +32,12 @@ export default function ProductCard({ product, index = 0, locale = "sr" }: Produ
|
|||||||
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
|
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
|
||||||
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||||
{image ? (
|
{image ? (
|
||||||
<img
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={localized.name}
|
alt={localized.name}
|
||||||
className="w-full h-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
fill
|
||||||
loading="lazy"
|
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]">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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
|
<button
|
||||||
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -245,10 +245,12 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
|||||||
: "border-transparent hover:border-[#999999]"
|
: "border-transparent hover:border-[#999999]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={image.alt || localized.name}
|
alt={image.alt || localized.name}
|
||||||
className="w-full h-full object-cover"
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100px"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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">
|
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden flex-1">
|
||||||
<img
|
<Image
|
||||||
src={images[selectedImage].url}
|
src={images[selectedImage].url}
|
||||||
alt={images[selectedImage].alt || localized.name}
|
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 && (
|
{images.length > 1 && (
|
||||||
@@ -307,17 +312,15 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
|||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
className="lg:pl-8"
|
className="lg:pl-8"
|
||||||
>
|
>
|
||||||
<motion.div
|
<div className="min-h-[52px] flex items-center">
|
||||||
key={urgencyIndex}
|
<div
|
||||||
initial={{ opacity: 0, y: -10 }}
|
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"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
key={urgencyIndex}
|
||||||
exit={{ opacity: 0, y: 10 }}
|
>
|
||||||
transition={{ duration: 0.3 }}
|
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
|
||||||
className="bg-white/80 backdrop-blur-sm text-[#1a1a1a] py-3 rounded-lg mb-4 text-sm font-medium text-left"
|
{urgencyMessages[urgencyIndex].text}
|
||||||
>
|
</div>
|
||||||
<span className="mr-2">{urgencyMessages[urgencyIndex].icon}</span>
|
</div>
|
||||||
{urgencyMessages[urgencyIndex].text}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
|
<h1 className="text-3xl md:text-4xl font-medium mb-4 tracking-tight">
|
||||||
{localized.name}
|
{localized.name}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const op = new OpenPanel({
|
|||||||
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
|
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 {
|
export interface ServerOrderData {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
@@ -26,6 +31,34 @@ export interface ServerEventData {
|
|||||||
properties?: Record<string, any>;
|
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
|
* Server-side analytics tracking
|
||||||
* Called from API routes or Server Components
|
* Called from API routes or Server Components
|
||||||
@@ -34,7 +67,7 @@ export async function trackOrderCompletedServer(data: ServerOrderData) {
|
|||||||
try {
|
try {
|
||||||
console.log("[Server Analytics] Tracking order:", data.orderNumber, "Total:", data.total);
|
console.log("[Server Analytics] Tracking order:", data.orderNumber, "Total:", data.total);
|
||||||
|
|
||||||
// Track order event
|
// Track order event with OpenPanel
|
||||||
await op.track("order_completed", {
|
await op.track("order_completed", {
|
||||||
order_id: data.orderId,
|
order_id: data.orderId,
|
||||||
order_number: data.orderNumber,
|
order_number: data.orderNumber,
|
||||||
@@ -48,7 +81,7 @@ export async function trackOrderCompletedServer(data: ServerOrderData) {
|
|||||||
source: "server",
|
source: "server",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track revenue (this is the important part!)
|
// Track revenue with OpenPanel
|
||||||
await op.revenue(data.total, {
|
await op.revenue(data.total, {
|
||||||
currency: data.currency,
|
currency: data.currency,
|
||||||
transaction_id: data.orderNumber,
|
transaction_id: data.orderNumber,
|
||||||
@@ -56,6 +89,21 @@ export async function trackOrderCompletedServer(data: ServerOrderData) {
|
|||||||
source: "server",
|
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");
|
console.log("[Server Analytics] Order tracked successfully");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -74,6 +122,10 @@ export async function trackServerEvent(data: ServerEventData) {
|
|||||||
...data.properties,
|
...data.properties,
|
||||||
source: "server",
|
source: "server",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also track to Rybbit
|
||||||
|
await trackRybbitServer(data.event, data.properties);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Server Analytics] Event tracking failed:", error);
|
console.error("[Server Analytics] Event tracking failed:", error);
|
||||||
|
|||||||
@@ -2,11 +2,123 @@
|
|||||||
|
|
||||||
import { useOpenPanel } from "@openpanel/nextjs";
|
import { useOpenPanel } from "@openpanel/nextjs";
|
||||||
import { useCallback } from "react";
|
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() {
|
export function useAnalytics() {
|
||||||
const op = useOpenPanel();
|
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: {
|
const trackProductView = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -14,19 +126,15 @@ export function useAnalytics() {
|
|||||||
currency: string;
|
currency: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
trackDual("product_viewed", {
|
||||||
op.track("product_viewed", {
|
product_id: product.id,
|
||||||
product_id: product.id,
|
product_name: product.name,
|
||||||
product_name: product.name,
|
price: product.price,
|
||||||
price: product.price,
|
currency: product.currency,
|
||||||
currency: product.currency,
|
category: product.category,
|
||||||
category: product.category,
|
source: "client",
|
||||||
source: "client",
|
});
|
||||||
});
|
}, [trackDual]);
|
||||||
} catch (e) {
|
|
||||||
console.error("[Client Analytics] Product view error:", e);
|
|
||||||
}
|
|
||||||
}, [op]);
|
|
||||||
|
|
||||||
const trackAddToCart = useCallback((product: {
|
const trackAddToCart = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,37 +144,42 @@ export function useAnalytics() {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
trackDual("add_to_cart", {
|
||||||
op.track("add_to_cart", {
|
product_id: product.id,
|
||||||
product_id: product.id,
|
product_name: product.name,
|
||||||
product_name: product.name,
|
price: product.price,
|
||||||
price: product.price,
|
currency: product.currency,
|
||||||
currency: product.currency,
|
quantity: product.quantity,
|
||||||
quantity: product.quantity,
|
variant: product.variant,
|
||||||
variant: product.variant,
|
source: "client",
|
||||||
source: "client",
|
});
|
||||||
});
|
}, [trackDual]);
|
||||||
} catch (e) {
|
|
||||||
console.error("[Client Analytics] Add to cart error:", e);
|
|
||||||
}
|
|
||||||
}, [op]);
|
|
||||||
|
|
||||||
const trackRemoveFromCart = useCallback((product: {
|
const trackRemoveFromCart = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
trackDual("remove_from_cart", {
|
||||||
op.track("remove_from_cart", {
|
product_id: product.id,
|
||||||
product_id: product.id,
|
product_name: product.name,
|
||||||
product_name: product.name,
|
quantity: product.quantity,
|
||||||
quantity: product.quantity,
|
source: "client",
|
||||||
source: "client",
|
});
|
||||||
});
|
}, [trackDual]);
|
||||||
} catch (e) {
|
|
||||||
console.error("[Client Analytics] Remove from cart error:", e);
|
const trackCartView = useCallback((cart: {
|
||||||
}
|
total: number;
|
||||||
}, [op]);
|
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: {
|
const trackCheckoutStarted = useCallback((cart: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -79,36 +192,23 @@ export function useAnalytics() {
|
|||||||
price: number;
|
price: number;
|
||||||
}>;
|
}>;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
trackDual("checkout_started", {
|
||||||
op.track("checkout_started", {
|
cart_total: cart.total,
|
||||||
cart_total: cart.total,
|
currency: cart.currency,
|
||||||
currency: cart.currency,
|
item_count: cart.item_count,
|
||||||
item_count: cart.item_count,
|
items: cart.items,
|
||||||
items: cart.items,
|
source: "client",
|
||||||
source: "client",
|
});
|
||||||
});
|
}, [trackDual]);
|
||||||
} catch (e) {
|
|
||||||
console.error("[Client Analytics] Checkout started error:", e);
|
|
||||||
}
|
|
||||||
}, [op]);
|
|
||||||
|
|
||||||
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||||
try {
|
trackDual("checkout_step", {
|
||||||
op.track("checkout_step", {
|
step,
|
||||||
step,
|
...data,
|
||||||
...data,
|
source: "client",
|
||||||
source: "client",
|
});
|
||||||
});
|
}, [trackDual]);
|
||||||
} catch (e) {
|
|
||||||
console.error("[Client Analytics] Checkout step error:", e);
|
|
||||||
}
|
|
||||||
}, [op]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: {
|
const trackOrderCompleted = useCallback(async (order: {
|
||||||
order_id: string;
|
order_id: string;
|
||||||
order_number: string;
|
order_number: string;
|
||||||
@@ -119,37 +219,34 @@ export function useAnalytics() {
|
|||||||
customer_email?: string;
|
customer_email?: string;
|
||||||
payment_method?: 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 {
|
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, {
|
op.revenue(order.total, {
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
transaction_id: order.order_number,
|
transaction_id: order.order_number,
|
||||||
source: "client",
|
source: "client",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[Client Analytics] Order tracked");
|
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
console.log("[Server Analytics] Calling server-side tracking API...");
|
|
||||||
|
|
||||||
const response = await fetch("/api/analytics/track-order", {
|
const response = await fetch("/api/analytics/track-order", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -165,39 +262,61 @@ export function useAnalytics() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
console.log("[Server Analytics] Order tracked successfully");
|
|
||||||
} else {
|
|
||||||
console.error("[Server Analytics] Failed:", await response.text());
|
console.error("[Server Analytics] Failed:", await response.text());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Server Analytics] API call failed:", e);
|
console.error("[Server Analytics] API call failed:", e);
|
||||||
}
|
}
|
||||||
}, [op]);
|
}, [op, trackDual]);
|
||||||
|
|
||||||
const trackSearch = useCallback((query: string, results_count: number) => {
|
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||||
try {
|
trackDual("search", {
|
||||||
op.track("search", {
|
query,
|
||||||
query,
|
results_count,
|
||||||
results_count,
|
source: "client",
|
||||||
source: "client",
|
});
|
||||||
});
|
}, [trackDual]);
|
||||||
} catch (e) {
|
|
||||||
console.error("[Client Analytics] Search error:", e);
|
|
||||||
}
|
|
||||||
}, [op]);
|
|
||||||
|
|
||||||
const trackExternalLink = useCallback((url: string, label?: string) => {
|
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||||
try {
|
trackDual("external_link_click", {
|
||||||
op.track("external_link_click", {
|
url,
|
||||||
url,
|
label,
|
||||||
label,
|
source: "client",
|
||||||
source: "client",
|
});
|
||||||
});
|
}, [trackDual]);
|
||||||
} catch (e) {
|
|
||||||
console.error("[Client Analytics] External link error:", e);
|
const trackWishlistAdd = useCallback((product: {
|
||||||
}
|
id: string;
|
||||||
}, [op]);
|
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: {
|
const identifyUser = useCallback((user: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
@@ -213,7 +332,7 @@ export function useAnalytics() {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Client Analytics] Identify error:", e);
|
console.error("[OpenPanel] Identify error:", e);
|
||||||
}
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
@@ -221,11 +340,16 @@ export function useAnalytics() {
|
|||||||
trackProductView,
|
trackProductView,
|
||||||
trackAddToCart,
|
trackAddToCart,
|
||||||
trackRemoveFromCart,
|
trackRemoveFromCart,
|
||||||
|
trackCartView,
|
||||||
trackCheckoutStarted,
|
trackCheckoutStarted,
|
||||||
trackCheckoutStep,
|
trackCheckoutStep,
|
||||||
trackOrderCompleted,
|
trackOrderCompleted,
|
||||||
trackSearch,
|
trackSearch,
|
||||||
trackExternalLink,
|
trackExternalLink,
|
||||||
|
trackWishlistAdd,
|
||||||
|
trackUserLogin,
|
||||||
|
trackUserRegister,
|
||||||
|
trackNewsletterSignup,
|
||||||
identifyUser,
|
identifyUser,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
209
src/lib/services/RybbitService.ts
Normal file
209
src/lib/services/RybbitService.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user