53 Commits

Author SHA1 Message Date
Unchained
d43481716d feat(saleor): Phase 4 - Checkout Flow
- Create checkout page with form validation
- Implement shipping/billing address forms
- Add Cash on Delivery (COD) payment method
- Integrate Saleor checkout completion mutation
- Add order success page with confirmation
- Handle checkout errors gracefully
- Display order summary with line items
2026-03-21 12:45:09 +02:00
Unchained
8b3389725e feat(saleor): Phase 3 - Cart Migration
- Create Saleor checkout store (Zustand + persist)
- Update CartDrawer to use Saleor checkout
- Update Header to use Saleor checkout store
- Update ProductDetail with Add to Cart functionality
- Add checkout initialization on app load
- Handle checkout line add/update/delete operations
- Add error handling and loading states
2026-03-21 12:42:41 +02:00
Unchained
5706792980 feat(saleor): Phase 2 - Product Migration
- Update ProductCard to use Saleor Product type
- Update products listing page to fetch from Saleor
- Update product detail page with Saleor integration
- Add language switching support (SR/EN)
- Add SEO metadata generation
- Implement static params generation for all product slugs
- Add availability checking based on variant quantity
2026-03-21 12:38:24 +02:00
Unchained
7b94537670 feat(saleor): Phase 1 - GraphQL Client Setup
- Add Apollo Client for Saleor GraphQL API
- Create GraphQL fragments (Product, Variant, Checkout)
- Create GraphQL queries (Products, Checkout)
- Create GraphQL mutations (Checkout operations)
- Add TypeScript types for Saleor entities
- Add product helper functions
- Install @apollo/client and graphql dependencies

Part of WordPress/WooCommerce → Saleor migration
2026-03-21 12:36:21 +02:00
Unchained
db1914d69b test: verify full auto-deploy pipeline
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-07 12:37:05 +02:00
Unchained
2c6889ad20 fix: revert to init container deployment with webhook auto-restart
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Init containers clone and build fresh code on each pod start
- Webhook triggers kubectl rollout restart on git push
- This provides true auto-deploy without requiring Docker registry
2026-03-07 12:35:30 +02:00
Unchained
97a9fcf7d5 test: verify auto-deploy webhook triggers pod restart
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-07 12:33:23 +02:00
Unchained
9b0d82da30 Add Gitea Actions workflow for CI/CD
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-07 12:28:00 +02:00
Unchained
44e033c7ae temp: remove workflow for initial push 2026-03-07 12:27:11 +02:00
Unchained
8f3bcebbf6 feat: implement full CI/CD pipeline with GitHub Actions and Flux
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add GitHub Actions workflow to build and push Docker images to GHCR
- Add Dockerfile for multi-stage builds
- Update deployment to use GHCR images
- Add GHCR authentication secrets
- Configure Flux ImageRepository, ImagePolicy, and ImageUpdateAutomation
- Remove init container approach in favor of proper Docker builds

Auto-deploy flow:
1. Push to master triggers GitHub Actions
2. GitHub Actions builds image with commit hash tag
3. Image pushed to ghcr.io/unchainedio/manoon-headless
4. Flux ImageRepository detects new image
5. Flux ImageUpdateAutomation updates kustomization.yaml
6. Flux Kustomization applies new deployment
7. Kubernetes restarts pods with new image
2026-03-07 12:26:37 +02:00
Unchained
2c27fc65d0 Revert to init container approach
The Docker-based CI/CD approach is the industry standard but requires:
- Proper container registry (GHCR, Docker Hub) with HTTPS
- Or complex local registry setup with containerd HTTP configuration

The init container approach works reliably for now. To enable auto-deploy:
1. Flux detects git changes in 30s (via webhook)
2. Kustomization applies new config
3. Manually run: kubectl rollout restart deployment/storefront -n manoonoils

For true auto-deploy, consider:
- Setting up GHCR with proper auth
- Using GitHub Actions to build/push images
- Using Flux ImageUpdateAutomation

Or simpler: A post-deploy webhook that triggers rollout restart.
2026-03-07 11:33:07 +02:00
Unchained
8f2b214c9f deploy: use registry ClusterIP instead of DNS name 2026-03-07 11:23:23 +02:00
Unchained
c4ff39394e deploy: use local registry image 2026-03-07 11:21:24 +02:00
Unchained
c3b3e133a8 fix: yaml indentation in deployment.yaml 2026-03-07 11:10:45 +02:00
Unchained
81d74ced0c feat: migrate to standard Docker-based CI/CD deployment
- Add Dockerfile for building Next.js images
- Replace init-container deployment with standard deployment
- Add kustomization with image tag tracking
- Add deploy.sh script for CI/CD pipeline

This enables proper GitOps auto-deployment:
1. Build image with commit hash tag
2. Push to GHCR
3. Update kustomization.yaml image tag
4. Flux detects change and restarts pods
2026-03-07 11:09:59 +02:00
Unchained
467b513b67 feat: add gitRevision annotation for auto-restart on git changes 2026-03-07 11:06:15 +02:00
Unchained
c1038245e8 test: verify auto-deploy with ConfigMap trigger 2026-03-07 11:02:49 +02:00
Unchained
ee391a7b8d feat: add deployment-metadata configmap to trigger pod restarts on git changes 2026-03-07 11:01:04 +02:00
Unchained
5ce35817a1 feat: add git revision annotation to force pod restart on updates 2026-03-07 10:57:58 +02:00
Unchained
81580de2a5 test: verify flux auto-deploy webhook 2026-03-07 10:55:48 +02:00
Unchained
2129e1c115 Replace ManoonOils text logo with image logo in header and footer 2026-03-07 10:38:00 +02:00
Unchained
103309e0ff Fix broken image in StatsSection - use manoon-luksuzni-set product image 2026-03-07 07:27:15 +02:00
Unchained
2dc837b0e9 Add queryStringAuth to WooCommerce API for better authentication 2026-03-07 07:12:34 +02:00
Unchained
214547362c Remove secret from deployment - must be created manually with real credentials 2026-03-07 07:04:31 +02:00
Unchained
a1090e0e2c Fix kustomization.yaml - use deployment-nodejs.yaml 2026-03-07 06:31:35 +02:00
Unchained
72fe1d4079 Remove Docker-related files - using pure Node.js deployment 2026-03-07 06:20:00 +02:00
Unchained
e1120f617e Remove middleware temporarily to fix routing 2026-03-06 21:04:27 +02:00
Unchained
236eb628d2 Fix i18n: use localePrefix: never to serve default locale at root 2026-03-06 20:59:48 +02:00
Unchained
d88d77b082 Fix i18n routing: use shared routing config 2026-03-06 20:52:53 +02:00
Unchained
c065b5ee17 Fix probes: use /favicon.ico instead of static dir 2026-03-06 20:50:09 +02:00
Unchained
bd423dbcc6 Fix health probes - use /_next/static/ path 2026-03-06 20:40:45 +02:00
Unchained
ee8902b843 Working Node.js deployment - use npm install instead of ci 2026-03-06 20:40:08 +02:00
Unchained
ba0e789b80 Fix Node.js deployment: add debug output, increase resources for npm install 2026-03-06 20:32:14 +02:00
Unchained
d8fe9337bb Update Node.js deployment with resource limits for k3s 2026-03-06 19:34:29 +02:00
Unchained
ced136fb4d Add pure Node.js deployment without Docker 2026-03-06 19:10:56 +02:00
Unchained
cac26e73ce Add Docker-less Node.js deployment config 2026-03-06 18:56:22 +02:00
Unchained
0fab8b6d42 Restore multilingual support: / for sr, /en for en with browser detection 2026-03-06 18:51:38 +02:00
Unchained
2c8cf68e89 Refactor WooCommerce API to lazy initialization for build compatibility 2026-03-06 16:34:49 +02:00
Unchained
c45aefde6e Add error handling to sitemap.ts for build time 2026-03-06 16:34:00 +02:00
Unchained
42793da45f Remove 'en' locale from i18n routing config 2026-03-06 16:33:31 +02:00
Unchained
8aa849f4ba Add error handling for API calls during build time 2026-03-06 16:30:54 +02:00
Unchained
40b80b1ad0 Fix product page: disable static generation, use dynamic rendering 2026-03-06 16:26:44 +02:00
Unchained
7d23176b6a Remove /en routes to fix build - using sr locale only 2026-03-06 16:24:06 +02:00
Unchained
5df87cbb9d Revert to Docker image deployment 2026-03-06 16:19:41 +02:00
Unchained
0f5f009512 Update deployment to use Node.js image directly, no Docker required 2026-03-06 16:18:32 +02:00
Unchained
9cd8b19787 Redesign homepage with moumoujus-inspired layout
- Add AnnouncementBar with marquee animation
- Add NewHero with floating product card
- Add StatsSection with large stat numbers
- Add FeaturesSection with icons
- Add TestimonialsSection with cards
- Add NewsletterSection with signup form
- Update Header styling for new design
- Update globals.css with marquee animations
- Update page.tsx to use new components

All existing WooCommerce functionality preserved
2026-03-06 16:05:50 +02:00
Unchained
1bef68c360 Fix IngressRoute syntax 2026-03-06 14:29:46 +02:00
Unchained
8a720f5335 Add Kubernetes manifests for Flux CD deployment 2026-03-06 14:25:28 +02:00
Neo
927dfc45e7 Remove CI/CD workflow - use manual deploy 2026-03-04 09:41:20 +00:00
Neo
dc05b673a2 Revert test, use Coolify for CI/CD
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-03-04 09:40:10 +00:00
Neo
7883fc61c8 Test CI/CD deployment
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-03-04 09:37:23 +00:00
Neo
080b59a107 Clean up submodule, proper single repo
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-03-04 09:25:20 +00:00
Neo
93647a9e05 Add Gitea CI/CD workflow
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
2026-03-04 09:23:30 +00:00
62 changed files with 10475 additions and 351 deletions

63
.github/workflows/build-and-deploy.yaml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Build and Deploy
on:
push:
branches: [master, main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=,suffix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Update kustomization.yaml
run: |
COMMIT_SHA=${{ github.sha }}
SHORT_SHA=${COMMIT_SHA:0:7}
sed -i "s|newTag: .*|newTag: ${SHORT_SHA}|" k8s/kustomization.yaml
- name: Commit and push changes
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add k8s/kustomization.yaml
git diff --quiet && git diff --staged --quiet || git commit -m "deploy: update image to ${SHORT_SHA} [skip ci]"
git push

View File

@@ -1,24 +0,0 @@
name: Deploy to Production
on:
push:
branches: [master, main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.0
with:
host: ${{ secrets.HOST }}
username: root
key: ${{ secrets.SSH_KEY }}
script: |
cd /root/manoonoils-store
git pull origin master
npm run build
docker build -t manoonoils-store:latest .
docker stop manoonoils-store && docker rm manoonoils-store
docker run -d --name manoonoils-store -p 3000:3000 --env-file .env.local manoonoils-store:latest
docker network connect coolify manoonoils-store

135
ASSET_INVENTORY.md Normal file
View File

@@ -0,0 +1,135 @@
# Manoon Assets Migration Inventory
## Date: March 20, 2026
## Source: WordPress (manoon-media bucket)
## Destination: Saleor (saleor bucket)
---
## 📁 FOLDER STRUCTURE
```
saleor/
├── brand/ # Logos and brand assets
├── content/ # Blog, articles, general content
├── marketing/ # Before/after, testimonials, banners
├── products/ # Product images (migrated first)
└── thumbnails/ # Auto-generated by Saleor
```
---
## 🎨 BRAND ASSETS (35 files)
### Main Logo Files
| File | Size | Purpose | Recommended Use |
|------|------|---------|-----------------|
| `cropped-manoon-logo_256x.png` | 38KB | Main logo | Header, footer |
| `cropped-manoon-logo_256x-300x300.png` | 20KB | Square format | Social media, favicon |
| `cropped-manoon-logo_256x-416x416.png` | 30KB | Large square | High-res displays |
### Partner/Press Logos
| File | Brand | Use Case |
|------|-------|----------|
| `bazaar-logo.png` | Bazaar Magazine | As seen in/press section |
| `cosmopolitan-logo.png` | Cosmopolitan | As seen in/press section |
| `lepotazdravilja-logo.png` | Lepota Zdravlja | As seen in/press section |
**Full URL:** `https://minio-api.nodecrew.me/saleor/brand/{filename}`
---
## 📸 BEFORE/AFTER IMAGES (65 files)
### Hair Results
| File | Description |
|------|-------------|
| `hair-before-after-1_1.webp` | Hair elixir result #1 |
| `hair-before-after-2_1.webp` | Hair elixir result #2 |
| `hair-before-after-3_1.webp` | Hair elixir result #3 |
| `hair-before-after-4_1.webp` | Hair elixir result #4 |
| `hair-before-after-5_1.webp` | Hair elixir result #5 |
### Skin Results
| File | Description |
|------|-------------|
| `manoon-before-after-1_cleanup-compressed_1280x.jpg` | Serum result |
| `manoon-before-after-2_cleanup_1-compressed_1280x.jpg` | Serum result 2 |
| `manoon-before-after-3_cleanup-compressed_1280x.jpg` | Serum result 3 |
| `marlene-before-after_cleanup-compressed_1280x.jpg` | Customer Marlene |
| `susanne-before-after_cleanup-compressed_1280x.jpg` | Customer Susanne |
**Full URL:** `https://minio-api.nodecrew.me/saleor/marketing/{filename}`
---
## 💬 TESTIMONIAL IMAGES (67 files)
| File | Description |
|------|-------------|
| `Image-Testemonials-2.jpeg` | Testimonial featured image |
| `testimonial10_1280x.jpg` | Customer testimonial #10 |
| `testimonial11_1280x.jpg` | Customer testimonial #11 |
| `testimonial15_1280x.jpg` | Customer testimonial #15 |
**Full URL:** `https://minio-api.nodecrew.me/saleor/marketing/{filename}`
---
## 🛍️ PRODUCT IMAGES (9 main + thumbnails)
| Product | Main Image | Gallery Images |
|---------|-----------|----------------|
| Morning Glow | `morning-glow-main.jpg` | `morning-glow-gallery-1.jpg` |
| Hair Elixir | `hair-elixir-main.webp` | - |
| Anti-age Serum | `anti-age-serum-main.jpg` | `anti-age-serum-gallery-1.jpg`, `anti-age-serum-gallery-2.jpg` |
| Luksuzni Set | `luksuzni-set-main.jpg` | `luksuzni-set-gallery-1.jpg`, `luksuzni-set-gallery-2.jpg` |
**Full URL:** `https://minio-api.nodecrew.me/saleor/products/{filename}`
---
## 📝 CONTENT IMAGES (25 files)
Various blog/article images, WhatsApp uploads, and other content assets.
**Full URL:** `https://minio-api.nodecrew.me/saleor/content/{filename}`
---
## 🔗 QUICK REFERENCE URLS
### CDN Base URL
```
https://minio-api.nodecrew.me/saleor/
```
### Direct Access Examples
```
Logo: https://minio-api.nodecrew.me/saleor/brand/cropped-manoon-logo_256x.png
Product: https://minio-api.nodecrew.me/saleor/products/morning-glow-main.jpg
Marketing: https://minio-api.nodecrew.me/saleor/marketing/hair-before-after-1_1.webp
Content: https://minio-api.nodecrew.me/saleor/content/{filename}
```
---
## 🎯 NEXT STEPS FOR STOREFRONT
1. **Hero Section**: Use logo from `/brand/` folder
2. **Product Pages**: Use images from `/products/` folder
3. **Results/Social Proof**: Use before/after from `/marketing/` folder
4. **Testimonials**: Use testimonial images from `/marketing/` folder
5. **Press/As Seen In**: Use partner logos from `/brand/` folder
---
## 📊 TOTAL ASSETS MIGRATED
| Category | Count | Folder |
|----------|-------|--------|
| Brand/Logos | 35 | `/brand/` |
| Products | 9 | `/products/` |
| Before/After | 65 | `/marketing/` |
| Testimonials | 67 | `/marketing/` |
| Content | 25 | `/content/` |
| **TOTAL** | **201** | - |

View File

@@ -1,27 +1,30 @@
FROM node:22-alpine AS base
# Multi-stage build for Next.js
FROM node:20-slim AS builder
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci
# Copy package files
COPY package*.json ./
RUN npm install --prefer-offline --no-audit
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
# Copy source and build
COPY . .
RUN npm run build
FROM base AS runner
# Production stage
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs || true
RUN adduser --system --uid 1001 nextjs || true
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV HOSTNAME=0.0.0.0
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

271
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,271 @@
# WooCommerce to Saleor Migration Guide
## Migration Summary
| Component | Status | Notes |
|-----------|--------|-------|
| **Products** | ✅ Complete | 4 products with variants, SKUs, pricing (RSD) |
| **Assets** | ✅ Complete | 204 files migrated to organized folders |
| **Inventory** | ✅ Complete | track_inventory=false, stock records added |
| **Translations** | ✅ Complete | English translations added |
| **Users** | ⏳ Ready | **4,886 total** (1,172 with orders + 2,714 prospects) |
| **Orders** | ⏳ Ready | 1,786 COD orders |
---
## 1. Product Migration (DONE)
Products migrated with:
- SKUs mapped directly
- Prices in RSD (Serbian Dinar)
- Published status
- Channel listings configured
- Inventory settings: `track_inventory=false`
### Product SKU Mapping
| WooCommerce SKU | Saleor SKU | Product |
|----------------|------------|---------|
| morning-glow | MORNING-GLOW-50ML | Morning Glow |
| hair-elixir | HAIR-ELIXIR-30ML | Hair Elixir |
| anti-age-serum | ANTI-AGE-SERUM-30ML | Anti-age Serum |
| luksuzni-set | LUK-SU-ZNI-SET | Luksuzni Set |
---
## 2. Asset Migration (DONE)
All 204 assets organized in MinIO `saleor` bucket:
```
saleor/
├── brand/ (36 files) - Logos, partner badges
├── marketing/ (133 files) - Before/after, testimonials
├── content/ (26 files) - Blog images
└── products/ (9 files) - Product photos
```
**CDN Base URL:** `https://minio-api.nodecrew.me/saleor/`
---
## 3. Customer & Order Migration Strategy
### Customer Analysis
| Category | Count | Description |
|----------|-------|-------------|
| **Total WordPress Users** | 4,886 | All registered accounts |
| **With Orders** | 1,172 | Actually purchased something |
| **Without Orders** | 2,714 | Abandoned carts, newsletter signups |
| **Guest Orders** | 144 | No account, email only |
| **TOTAL REAL CUSTOMERS** | **1,274** | Unique emails from orders |
### Why Migrate All 4,886 Users?
The 2,714 users without orders are valuable for:
- **Abandoned cart recovery** - They started but didn't finish
- **Newsletter subscribers** - Already interested in brand
- **Reactivation campaigns** - Win back potential customers
- **Lookalike audiences** - For Meta/Google ads
### Customer Segmentation
During migration, users are automatically segmented:
| Segment | Criteria | Count (Est.) | Strategy |
|---------|----------|--------------|----------|
| **VIP_CUSTOMER** | 3+ completed orders | ~200 | Loyalty program, early access |
| **ACTIVE_CUSTOMER** | 1-2 completed orders | ~972 | Cross-sell, subscription |
| **CART_ABANDONER** | Pending/processing orders | ~1,086 | Recovery sequence |
| **PROSPECT** | No orders | ~2,628 | Welcome series, education |
---
## 4. Migration Scripts
### Available Scripts
| Script | Purpose | Use When |
|--------|---------|----------|
| `migrate_all_users_and_orders.py` | **Complete migration** (recommended) | You want all users + segmentation |
| `migrate_cod_orders.py` | Orders only (no user creation) | Quick order migration only |
| `migrate_guest_orders.py` | Alternative guest checkout | Legacy option |
### Recommended: Complete Migration
```bash
# Set environment variables
export WP_DB_HOST=doorwayftw
export WP_DB_USER=DUjqYuqsYvaGUFV4
export WP_DB_PASSWORD=voP0UzecALE0WRNJQcTCf0STMcxIiX99
export SALEOR_DB_HOST=doorwayftw
export SALEOR_DB_USER=saleor
export SALEOR_DB_PASSWORD=<get-from-k8s-secret>
# Preview (dry run)
python scripts/migrate_all_users_and_orders.py --users --orders --dry-run
# Migrate specific segment
python scripts/migrate_all_users_and_orders.py --users --segment VIP_CUSTOMER
# Full migration
python scripts/migrate_all_users_and_orders.py --users --orders
```
### Migration by Segments (Phased Approach)
**Phase 1: VIP & Active Customers** (Lowest risk)
```bash
python scripts/migrate_all_users_and_orders.py \
--users --segment VIP_CUSTOMER --orders --limit-orders 100
```
**Phase 2: Cart Abandoners** (Medium value)
```bash
python scripts/migrate_all_users_and_orders.py \
--users --segment CART_ABANDONER --orders
```
**Phase 3: Prospects** (Reactivation focus)
```bash
python scripts/migrate_all_users_and_orders.py \
--users --segment PROSPECT
```
---
## 5. Post-Migration: Email Reactivation Campaigns
See `EMAIL_REACTIVATION_CAMPAIGNS.md` for complete strategy.
### Quick Summary
| Campaign | Target | Goal |
|----------|--------|------|
| **Cart Recovery** | 1,086 abandoners | 10-15% conversion |
| **Welcome Series** | 2,628 prospects | 5-8% first order |
| **Win-Back** | Inactive customers | 3-5% reactivation |
| **VIP Program** | 200 top customers | Loyalty + referrals |
### Campaign Templates Included
- Cart recovery (3 emails)
- Welcome series (4 emails)
- Win-back sequence (2 emails)
- VIP perks announcement
### Technical Setup
Segmentation data stored in user metadata:
```json
{
"segment": "CART_ABANDONER",
"wp_user_id": 12345,
"order_count": 1,
"completed_orders": 0,
"total_spent": 0,
"registration_date": "2022-11-20T13:42:19"
}
```
Export for email platform:
```sql
-- Get all PROSPECTS for welcome campaign
SELECT email, first_name, metadata->>'registration_date'
FROM account_user
WHERE metadata->>'segment' = 'PROSPECT';
```
---
## 6. COD Payment Handling
Since Manoon uses Cash on Delivery:
### Status Mapping
| WC Status | Saleor Status | Payment |
|-----------|---------------|---------|
| `wc-pending` | `UNCONFIRMED` | Unpaid |
| `wc-processing` | `UNFULFILLED` | Unpaid |
| `wc-completed` | `FULFILLED` | ✅ Paid (COD collected) |
| `wc-cancelled` | `CANCELED` | Unpaid |
### Payment Records
For completed orders, a dummy payment record is created:
- Gateway: `mirumee.payments.dummy`
- Status: `FULLY_CHARGED`
- Amount: Order total
This allows reporting and analytics to work correctly.
---
## 7. Data Transformations
| Field | WooCommerce | Saleor |
|-------|-------------|--------|
| **Prices** | Decimal (115.00) | Integer cents (11500) |
| **Tax Rate** | Calculated | Fixed 15% (Serbia VAT) |
| **Status** | wc-* strings | Saleor workflow states |
| **Origin** | Various | `BULK_CREATE` |
| **Passwords** | WP hashed | `!` (unusable, reset required) |
---
## 8. Verification Checklist
After migration:
- [ ] User count matches: 4,886
- [ ] Order count matches: 1,786
- [ ] Segments correctly assigned
- [ ] LTV calculated for each customer
- [ ] Order totals are correct (cents)
- [ ] Completed orders have payment records
- [ ] Addresses formatted correctly
- [ ] SKUs link to correct products
---
## 9. Rollback Plan
If needed:
```sql
-- Delete imported data
DELETE FROM order_order WHERE metadata->>'origin' = 'BULK_CREATE';
DELETE FROM account_user WHERE id IN (
SELECT saleor_user_id FROM wc_complete_user_mapping
);
-- Drop mapping tables
DROP TABLE wc_complete_user_mapping;
DROP TABLE wc_order_mapping;
```
---
## 10. Next Steps
1. ✅ Run migration preview: `--dry-run`
2. ✅ Verify counts match expectations
3. ✅ Run Phase 1 (VIP customers)
4. ✅ Set up email platform (Mautic/MailerLite/Mailchimp)
5. ✅ Import segments into email platform
6. ✅ Launch cart recovery campaign
7. ✅ Launch welcome series for prospects
8. ✅ Monitor conversion rates
9. ✅ Optimize campaigns based on data
---
## Support
For issues:
1. Check Saleor logs: `kubectl logs -n saleor deployment/saleor-api`
2. Run with `--dry-run` first
3. Check mapping tables for progress
4. Review `EMAIL_REACTIVATION_CAMPAIGNS.md` for marketing setup

View File

@@ -34,3 +34,8 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
# CI/CD Test
// Flux auto-deploy test - Sat Mar 7 10:55:48 AM EET 2026
// Auto-deploy test: 2026-03-07T09:02:49Z
// Auto-deploy test: 2026-03-07T10:33:23Z
// Auto-deploy test 2: 2026-03-07T10:37:05Z

528
SALEOR_MIGRATION_PLAN.md Normal file
View File

@@ -0,0 +1,528 @@
# Manoon Headless: WordPress/WooCommerce → Saleor Migration Plan
## Current State Analysis
### Tech Stack
- **Framework**: Next.js 16.1.6 + React 19.2.3
- **Styling**: Tailwind CSS v4
- **State**: Zustand (cart)
- **i18n**: next-intl (Serbian/English)
- **Animation**: Framer Motion
- **Backend**: WooCommerce REST API
### Current Data Flow
```
Next.js Storefront → WooCommerce REST API → WordPress Database
```
### Target Data Flow
```
Next.js Storefront → Saleor GraphQL API → PostgreSQL Database
```
---
## Migration Strategy: Stacked PRs
Using stacked PRs for dependent changes:
```
main (WooCommerce - stable)
├── feature/001-saleor-graphql-client (base)
│ └── Saleor GraphQL client, types, config
├── feature/002-saleor-products (depends on 001)
│ └── Product fetching, listing, detail pages
├── feature/003-saleor-cart (depends on 002)
│ └── Cart functionality with Saleor checkout
├── feature/004-saleor-checkout (depends on 003)
│ └── Checkout flow, payments (COD), order creation
└── feature/005-remove-woocommerce (depends on 004)
└── Remove WooCommerce code, env vars, deps
```
---
## Phase 1: GraphQL Client Setup (feature/001-saleor-graphql-client)
### Tasks
- [ ] Install GraphQL dependencies (`@apollo/client`, `graphql`)
- [ ] Create Saleor GraphQL client configuration
- [ ] Set up type generation from Saleor schema
- [ ] Create environment variables for Saleor API
- [ ] Test connection to Saleor API
### Files to Create
```
src/lib/saleor/
├── client.ts # Apollo Client configuration
├── fragments/
│ ├── Product.ts # Product fragment
│ ├── Variant.ts # Variant fragment
│ └── Checkout.ts # Checkout fragment
├── mutations/
│ ├── Checkout.ts # Checkout mutations
│ └── Cart.ts # Cart mutations
└── queries/
├── Products.ts # Product queries
└── Checkout.ts # Checkout queries
src/types/saleor.ts # Generated TypeScript types
```
### Dependencies to Add
```bash
npm install @apollo/client graphql
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations
```
---
## Phase 2: Product Migration (feature/002-saleor-products)
### Tasks
- [ ] Create Saleor product types/interfaces
- [ ] Replace `getProducts()` with Saleor query
- [ ] Replace `getProductBySlug()` with Saleor query
- [ ] Update `ProductCard` component to use Saleor data
- [ ] Update `ProductDetail` component to use Saleor data
- [ ] Handle product variants
- [ ] Handle product translations (SR/EN)
### GraphQL Queries Needed
```graphql
# Get all products
query GetProducts($channel: String!, $locale: LanguageCodeEnum!) {
products(channel: $channel, first: 100) {
edges {
node {
id
name
slug
description
translation(languageCode: $locale) {
name
slug
description
}
variants {
id
name
sku
pricing {
price {
gross {
amount
currency
}
}
}
}
media {
url
alt
}
}
}
}
}
# Get product by slug
query GetProduct($slug: String!, $channel: String!, $locale: LanguageCodeEnum!) {
product(slug: $slug, channel: $channel) {
id
name
slug
description
translation(languageCode: $locale) {
name
slug
description
}
variants {
id
name
sku
pricing {
price {
gross {
amount
currency
}
}
}
}
media {
url
alt
}
}
}
```
### Files to Modify
```
src/lib/woocommerce.ts → src/lib/saleor/products.ts
src/components/product/ProductCard.tsx
src/components/product/ProductDetail.tsx
src/app/products/page.tsx
src/app/products/[slug]/page.tsx
```
---
## Phase 3: Cart Migration (feature/003-saleor-cart)
### Tasks
- [ ] Replace Zustand cart store with Saleor checkout
- [ ] Create checkout on first cart addition
- [ ] Update cart lines (add, remove, update quantity)
- [ ] Fetch checkout by ID (from localStorage/cookie)
- [ ] Update CartDrawer component
### Saleor Checkout Flow
```
1. User adds item → Create checkout (if not exists)
2. Add checkout line → checkoutLinesAdd mutation
3. Update quantity → checkoutLinesUpdate mutation
4. Remove item → checkoutLinesDelete mutation
5. Store checkoutId in localStorage
```
### GraphQL Mutations Needed
```graphql
# Create checkout
mutation CheckoutCreate($input: CheckoutCreateInput!) {
checkoutCreate(input: $input) {
checkout {
id
token
lines {
id
quantity
variant {
id
name
product {
name
media {
url
}
}
}
}
}
errors {
field
message
}
}
}
# Add lines
mutation CheckoutLinesAdd($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lines) {
checkout {
id
lines {
id
quantity
}
}
}
}
# Update lines
mutation CheckoutLinesUpdate($checkoutId: ID!, $lines: [CheckoutLineUpdateInput!]!) {
checkoutLinesUpdate(checkoutId: $checkoutId, lines: $lines) {
checkout {
id
lines {
id
quantity
}
}
}
}
```
### Files to Modify
```
src/stores/cartStore.ts → src/stores/saleorCheckoutStore.ts
src/components/cart/CartDrawer.tsx
```
---
## Phase 4: Checkout Flow (feature/004-saleor-checkout)
### Tasks
- [ ] Create checkout page
- [ ] Implement shipping address form
- [ ] Implement billing address form
- [ ] Set shipping method (COD)
- [ ] Create order on completion
- [ ] Show order confirmation
### Cash on Delivery (COD) Flow
```
1. User completes checkout form
2. Set shipping/billing addresses
3. Select shipping method (fixed price)
4. Complete checkout → creates order
5. Order status: UNFULFILLED
6. Payment status: NOT_CHARGED (COD)
```
### GraphQL Mutations
```graphql
# Set shipping address
mutation CheckoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
checkout {
id
shippingAddress {
firstName
lastName
streetAddress1
city
postalCode
phone
}
}
}
}
# Set billing address
mutation CheckoutBillingAddressUpdate($checkoutId: ID!, $billingAddress: AddressInput!) {
checkoutBillingAddressUpdate(checkoutId: $checkoutId, billingAddress: $billingAddress) {
checkout {
id
billingAddress {
firstName
lastName
streetAddress1
city
postalCode
phone
}
}
}
}
# Complete checkout (creates order)
mutation CheckoutComplete($checkoutId: ID!) {
checkoutComplete(checkoutId: $checkoutId) {
order {
id
number
status
total {
gross {
amount
currency
}
}
}
errors {
field
message
}
}
}
```
### Files to Create
```
src/app/checkout/
├── page.tsx # Checkout page
├── CheckoutForm.tsx # Address forms
├── OrderSummary.tsx # Cart summary
└── CheckoutSuccess.tsx # Order confirmation
```
---
## Phase 5: Cleanup (feature/005-remove-woocommerce)
### Tasks
- [ ] Remove WooCommerce dependencies
- [ ] Remove WooCommerce API file
- [ ] Clean up environment variables
- [ ] Update documentation
- [ ] Test complete flow
### Files to Remove
```
src/lib/woocommerce.ts
```
### Dependencies to Remove
```bash
npm uninstall @woocommerce/woocommerce-rest-api
```
### Environment Variables to Update
```bash
# Remove
NEXT_PUBLIC_WOOCOMMERCE_URL
NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY
NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET
# Add
NEXT_PUBLIC_SALEOR_API_URL
NEXT_PUBLIC_SALEOR_CHANNEL
```
---
## URL Structure
### Current (WooCommerce)
```
/products/ # Product listing
/products/:slug/ # Product detail (Serbian)
/en/products/:slug/ # Product detail (English)
```
### Target (Saleor)
```
/products/ # Product listing
/products/:slug/ # Product detail (Serbian or English slug)
```
Saleor stores both Serbian and English slugs. The storefront will fetch by slug and detect language.
---
## Component Mapping
| Current Component | Saleor Equivalent | Changes |
|-------------------|-------------------|---------|
| `WooProduct` interface | `Product` fragment | Different field names |
| `getProducts()` | `GetProducts` query | GraphQL instead of REST |
| `getProductBySlug()` | `GetProduct` query | GraphQL instead of REST |
| `useCartStore` (Zustand) | `useCheckoutStore` | Saleor checkout-based |
| `formatPrice()` | `formatPrice()` | Handle Money type |
---
## Data Mapping
### Product
| WooCommerce | Saleor | Notes |
|-------------|--------|-------|
| `id` | `id` | Woo uses int, Saleor uses UUID |
| `name` | `name` | Same |
| `slug` | `slug` | Same |
| `price` | `variants[0].pricing.price.gross.amount` | Nested in variant |
| `regular_price` | `variants[0].pricing.price.gross.amount` | Saleor has discounts |
| `images[0].src` | `media[0].url` | Different structure |
| `stock_status` | `variants[0].quantityAvailable` | Check > 0 |
| `description` | `description` | JSON editor format |
| `sku` | `variants[0].sku` | In variant |
### Cart/Checkout
| WooCommerce | Saleor | Notes |
|-------------|--------|-------|
| Cart items in localStorage | Checkout ID in localStorage | Saleor stores server-side |
| `add_to_cart` | `checkoutLinesAdd` | Mutation |
| `update_quantity` | `checkoutLinesUpdate` | Mutation |
| `remove_from_cart` | `checkoutLinesDelete` | Mutation |
| Cart total (calculated) | `checkout.totalPrice` | Server-calculated |
---
## Testing Checklist
### Phase 1: GraphQL Client
- [ ] Apollo Client connects to Saleor API
- [ ] Type generation works
- [ ] Environment variables configured
### Phase 2: Products
- [ ] Product listing page shows products
- [ ] Product detail page works with Serbian slug
- [ ] Product detail page works with English slug
- [ ] Language switcher works
- [ ] Product images load
- [ ] Prices display correctly (RSD)
### Phase 3: Cart
- [ ] Add to cart works
- [ ] Update quantity works
- [ ] Remove from cart works
- [ ] Cart persists across page reloads
- [ ] CartDrawer shows correct items
### Phase 4: Checkout
- [ ] Checkout page loads
- [ ] Shipping address form works
- [ ] Billing address form works
- [ ] Order creation works
- [ ] Order confirmation shows
- [ ] COD payment method available
### Phase 5: Cleanup
- [ ] No WooCommerce dependencies
- [ ] All tests pass
- [ ] Build succeeds
- [ ] No console errors
---
## Rollback Plan
If issues arise, revert to WooCommerce:
```bash
git checkout master
npm install # Restore WooCommerce deps
```
---
## Post-Migration Tasks
- [ ] Update deployment docs
- [ ] Train team on Saleor dashboard
- [ ] Set up monitoring
- [ ] Configure CDN for images
- [ ] Test on staging
- [ ] Deploy to production
- [ ] Monitor for errors
- [ ] Collect user feedback
---
## Resources
- **Saleor API URL**: `https://api.manoonoils.com/graphql/`
- **Saleor Dashboard**: `https://dashboard.manoonoils.com/`
- **Current Storefront**: `https://dev.manoonoils.com/`
- **MinIO Assets**: `https://minio-api.nodecrew.me/saleor/`
---
## Migration Commands
```bash
# Start migration
git checkout -b feature/001-saleor-graphql-client
# After each phase
git add .
git commit -m "feat(saleor): Phase X - Description"
git push -u origin feature/001-saleor-graphql-client
# Create PR on GitHub
gh pr create --title "[1/5] Saleor GraphQL Client Setup" --base main
# Merge and continue
git checkout main
git pull origin main
git checkout -b feature/002-saleor-products
```

View File

@@ -0,0 +1,460 @@
# Advanced E-Commerce Features Checklist
## Saleor Built-in vs Missing Features
### ✅ Built-in (Ready to Use)
| Feature | Saleor Support | Notes |
|---------|---------------|-------|
| **Products & Variants** | ✅ Native | Simple & variable products |
| **Categories** | ✅ Native | Hierarchical with nesting |
| **Collections** | ✅ Native | Manual & automated collections |
| **Inventory** | ✅ Native | Multi-warehouse support |
| **Multi-language** | ✅ Native | Full translation support |
| **Multi-currency** | ✅ Native | Per-channel pricing |
| **Promotions** | ✅ Native | % off, fixed amount, vouchers |
| **Gift Cards** | ✅ Native | Digital gift cards |
| **Taxes** | ✅ Native | Per-country tax rates |
| **Shipping** | ✅ Native | Zones & methods |
| **Customer Accounts** | ✅ Native | Full account management |
| **Order Management** | ✅ Native | Status tracking, fulfillments |
| **Staff Permissions** | ✅ Native | Role-based access |
| **Pages & Menus** | ✅ Native | CMS features |
| **Checkout** | ✅ Native | Customizable flow |
| **Payments** | ✅ Native | Stripe, Adyen, etc. |
---
## ❌ Missing Features (Need to Build/Add)
### 1. Product Reviews ⭐ HIGH PRIORITY
**Status:** NOT in Saleor (on roadmap but not planned)
**Solutions:**
| Solution | Cost | Effort | Best For |
|----------|------|--------|----------|
| **Judge.me** | Free-$15/mo | 2 hours | Budget option, works well |
| **Trustpilot** | $200+/mo | 2 hours | SEO, brand trust |
| **Yotpo** | $300+/mo | 4 hours | Enterprise, UGC |
| **Build Custom** | Free | 2-4 weeks | Full control |
**Custom Build SQL:**
```sql
CREATE TABLE product_review (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES product_product(id),
user_id INTEGER REFERENCES account_user(id),
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
title VARCHAR(255),
comment TEXT,
is_verified_purchase BOOLEAN DEFAULT false,
is_approved BOOLEAN DEFAULT false,
helpful_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
```
---
### 2. Upsells & Cross-sells ⭐ HIGH PRIORITY
**Status:** NOT in Saleor (confirmed missing)
**What You Need:**
```sql
-- Related products / upsells table
CREATE TABLE product_related (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES product_product(id),
related_product_id INTEGER REFERENCES product_product(id),
type VARCHAR(50), -- 'upsell', 'cross_sell', 'related'
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(product_id, related_product_id, type)
);
```
**Types of Upsells:**
| Type | Example | Location |
|------|---------|----------|
| **Upsell** | 500ml → 1L (upgrade) | Product page |
| **Cross-sell** | Olive oil + vinegar (complementary) | Cart page |
| **Related** | Same category products | Product page |
| **Bundle** | Oil + vinegar + herbs package | Product page |
| **Frequently Bought Together** | AI-based recommendations | Cart page |
**Implementation Options:**
#### Option A: Manual (Product-Level)
```sql
-- Admin manually assigns related products
INSERT INTO product_related (product_id, related_product_id, type, sort_order)
VALUES
(1, 5, 'upsell', 1), -- Product 1 shows Product 5 as upsell
(1, 6, 'cross_sell', 1), -- Product 1 shows Product 6 as cross-sell
(1, 7, 'related', 1); -- Product 1 shows Product 7 as related
```
**Admin UI Needed:**
- Product edit page with "Related Products" section
- Search & select products
- Drag to reorder
- Choose type (upsell/cross-sell/related)
#### Option B: Automated (Category-Based)
```typescript
// Automatically show products from same category
const getRelatedProducts = async (productId: string, categoryId: string) => {
return await saleorClient.query({
query: gql`
query GetRelatedProducts($categoryId: ID!, $excludeId: ID!) {
products(
first: 4,
filter: {categories: [$categoryId]},
channel: "default-channel"
) {
edges {
node {
id
name
slug
thumbnail { url }
variants {
channelListings {
price { amount currency }
}
}
}
}
}
}
`,
variables: { categoryId, excludeId: productId }
});
};
```
#### Option C: AI/ML Recommendations (Advanced)
Services:
- **Recombee** - $99/mo+
- **Amazon Personalize** - Pay per use
- **Algolia Recommend** - $29/mo+
- **Build custom** - Requires order history analysis
**Effort:** High (4-8 weeks)
---
### 3. Product Bundles ⭐ MEDIUM PRIORITY
**Status:** NOT in Saleor (requested, on roadmap)
**Example:**
- Olive Oil 500ml + Vinegar 250ml = Bundle price $15 (save $3)
**Custom Implementation:**
```sql
-- Bundle definition
CREATE TABLE product_bundle (
id SERIAL PRIMARY KEY,
name VARCHAR(250),
slug VARCHAR(255) UNIQUE,
description JSONB,
product_type_id INTEGER REFERENCES product_producttype(id),
bundle_price_amount NUMERIC(20,3),
currency VARCHAR(3),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- Bundle items
CREATE TABLE product_bundle_item (
id SERIAL PRIMARY KEY,
bundle_id INTEGER REFERENCES product_bundle(id),
product_variant_id INTEGER REFERENCES product_productvariant(id),
quantity INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0
);
```
**Storefront Display:**
```typescript
// Show bundle on product page
<ProductBundle
bundle={{
name: "Mediterranean Starter Pack",
items: [
{ name: "Olive Oil 500ml", price: 12 },
{ name: "Balsamic Vinegar 250ml", price: 6 },
],
regularPrice: 18,
bundlePrice: 15,
savings: 3
}}
/>
```
---
### 4. Abandoned Cart Recovery ⭐ HIGH PRIORITY
**Status:** NOT in Saleor
**Solutions:**
1. **Mautic** (FREE - you have it!) - See `mautic-abandoned-cart.md`
2. **Klaviyo** - $20-50/mo
3. **N8N automation** - FREE
---
### 5. Email Marketing ⭐ MEDIUM PRIORITY
**Status:** NOT in Saleor
**Solutions:**
1. **Mautic** (FREE - you have it!)
2. **Klaviyo** - Best for e-commerce
3. **Mailchimp** - Good free tier
**Email Types Needed:**
- Welcome email
- Order confirmation
- Shipping notification
- Post-purchase follow-up
- Win-back campaign
- Birthday discount
---
### 6. Loyalty/Rewards Program ⭐ MEDIUM PRIORITY
**Status:** NOT in Saleor
**Custom Build:**
```sql
-- Loyalty points
CREATE TABLE loyalty_account (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES account_user(id),
points_balance INTEGER DEFAULT 0,
lifetime_points INTEGER DEFAULT 0,
tier VARCHAR(50) DEFAULT 'bronze', -- bronze, silver, gold
created_at TIMESTAMP DEFAULT NOW()
);
-- Points transactions
CREATE TABLE loyalty_transaction (
id SERIAL PRIMARY KEY,
account_id INTEGER REFERENCES loyalty_account(id),
points INTEGER, -- positive for earn, negative for redeem
type VARCHAR(50), -- 'purchase', 'referral', 'redemption', 'bonus'
description TEXT,
order_id INTEGER REFERENCES order_order(id),
created_at TIMESTAMP DEFAULT NOW()
);
```
---
### 7. Subscription/Recurring Products ⭐ LOW PRIORITY
**Status:** NOT in Saleor
**Solutions:**
- **Stripe Billing** - Best integration
- **Recharge** - $300/mo+ (Shopify-focused)
- **Chargebee** - $249/mo+
- **Build custom** with Stripe
---
### 8. Wishlist/Favorites ⭐ MEDIUM PRIORITY
**Status:** NOT in Saleor
**Simple Implementation:**
```sql
CREATE TABLE wishlist_item (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES account_user(id),
product_variant_id INTEGER REFERENCES product_productvariant(id),
added_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, product_variant_id)
);
```
---
### 9. Recently Viewed Products ⭐ LOW PRIORITY
**Implementation:**
```typescript
// Store in localStorage or Redis
const trackProductView = (productId: string) => {
const recentlyViewed = JSON.parse(localStorage.getItem('recentlyViewed') || '[]');
recentlyViewed.unshift(productId);
localStorage.setItem('recentlyViewed', JSON.stringify(recentlyViewed.slice(0, 10)));
};
```
---
### 10. Product Comparison ⭐ LOW PRIORITY
**Implementation:**
```typescript
// Allow users to compare 2-3 products side-by-side
const ProductComparison = ({ products }) => {
return (
<table>
<thead>
<tr>
<th>Feature</th>
{products.map(p => <th key={p.id}>{p.name}</th>)}
</tr>
</thead>
<tbody>
<tr>
<td>Price</td>
{products.map(p => <td key={p.id}>${p.price}</td>)}
</tr>
<tr>
<td>Volume</td>
{products.map(p => <td key={p.id}>{p.volume}</td>)}
</tr>
</tbody>
</table>
);
};
```
---
### 11. Quick View (Modal) ⭐ LOW PRIORITY
**Implementation:**
```typescript
// Product card with quick view button
<ProductCard>
<Image src={product.thumbnail} />
<h3>{product.name}</h3>
<button onClick={() => openQuickView(product.id)}>
Quick View
</button>
</ProductCard>
// Modal fetches product details
<QuickViewModal productId={selectedProductId} />
```
---
### 12. AJAX Add to Cart (No Page Reload) ⭐ MEDIUM PRIORITY
**Implementation:**
```typescript
const AddToCartButton = ({ variantId }) => {
const [adding, setAdding] = useState(false);
const handleAdd = async () => {
setAdding(true);
await saleorClient.mutate({
mutation: ADD_TO_CART,
variables: { variantId, quantity: 1 }
});
setAdding(false);
showToast('Added to cart!');
updateCartCount(); // Update header cart icon
};
return <button onClick={handleAdd} disabled={adding}>Add to Cart</button>;
};
```
---
### 13. Dynamic Pricing / Volume Discounts ⭐ LOW PRIORITY
**Example:**
- Buy 1: $12
- Buy 2: $11 each (save $2)
- Buy 3+: $10 each (save $6)
**Saleor Native:** Not supported
**Custom:**
```typescript
const getVolumePrice = (basePrice: number, quantity: number) => {
if (quantity >= 3) return basePrice * 0.83; // 17% off
if (quantity >= 2) return basePrice * 0.92; // 8% off
return basePrice;
};
```
---
### 14. Back in Stock Notifications ⭐ MEDIUM PRIORITY
**Implementation:**
```sql
CREATE TABLE stock_notification_request (
id SERIAL PRIMARY KEY,
email VARCHAR(255),
product_variant_id INTEGER REFERENCES product_productvariant(id),
is_notified BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
-- When stock is updated, check and send emails
```
---
## Recommended Priority Order
### Phase 1: Essential (Launch)
- [x] Saleor core products
- [ ] **Reviews** (Judge.me or custom)
- [ ] **Upsells/Cross-sells** (manual assignment)
- [ ] **AJAX cart**
- [ ] **Mautic abandoned cart**
### Phase 2: Growth (1-3 months post-launch)
- [ ] **Email marketing** (Mautic or Klaviyo)
- [ ] **Wishlist**
- [ ] **Bundles**
- [ ] **Recently viewed**
### Phase 3: Advanced (6+ months)
- [ ] **Loyalty program**
- [ ] **AI recommendations**
- [ ] **Subscriptions**
- [ ] **Product comparison**
---
## Cost Summary
| Feature | DIY Build | Third-Party | Recommended |
|---------|-----------|-------------|-------------|
| Reviews | 2-4 weeks | Judge.me FREE | **Judge.me** |
| Upsells | 1-2 weeks | N/A | **Custom** |
| Bundles | 2-3 weeks | N/A | **Custom** |
| Abandoned Cart | 2-3 days | Klaviyo $20/mo | **Mautic FREE** |
| Email Marketing | 1 week | Klaviyo $20/mo | **Mautic FREE** |
| Loyalty | 2-3 weeks | Smile.io $199/mo | **Custom later** |
| Subscriptions | 4-6 weeks | Recharge $300/mo | **Stripe later** |
---
## Total Estimated Dev Time
**Phase 1:** 4-6 weeks
**Phase 2:** 3-4 weeks
**Phase 3:** 6-8 weeks
**Total:** 3-4 months for full-featured store

302
infrastructure-overview.md Normal file
View File

@@ -0,0 +1,302 @@
# Infrastructure Overview: WordPress → Saleor Migration
## System Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ K3s Cluster │
│ │
│ ┌──────────────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ manoonoils namespace │ │ saleor namespace │ │
│ │ (WordPress/WooCommerce)│ │ (Headless Commerce) │ │
│ │ │ │ │ │
│ │ ┌──────────────────┐ │ │ ┌──────────────────────────────────┐ │ │
│ │ │ WordPress │ │ │ │ Saleor API (Django) │ │ │
│ │ │ - WooCommerce │ │ │ │ - GraphQL endpoint │ │ │
│ │ │ - ADVMO Plugin │ │ │ │ - Product management │ │ │
│ │ └────────┬─────────┘ │ │ └────────┬─────────────────────────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌────────▼─────────┐ │ │ ┌────────▼──────────────────────────┐ │ │
│ │ │ Redis │ │ │ │ Redis │ │ │
│ │ │ (Object Cache) │ │ │ │ (Celery + Cache) │ │ │
│ │ └──────────────────┘ │ │ └───────────────────────────────────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌────────▼─────────┐ │ │ ┌────────▼──────────────────────────┐ │ │
│ │ │ MariaDB │ │ │ │ PostgreSQL │ │ │
│ │ │ (WP database) │ │ │ │ (Products, Orders, Users) │ │ │
│ │ └──────────────────┘ │ │ └───────────────────────────────────┘ │ │
│ │ │ │ │ │
│ └──────────────────────────┘ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Shared Services │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ MinIO Object Storage │ │ │
│ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │
│ │ │ │ manoon-media │ │ saleor │ │ other │ │ │ │
│ │ │ │ (WP images) │ │ (Saleor │ │ buckets... │ │ │ │
│ │ │ │ │ │ images) │ │ │ │ │ │
│ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ Traefik Ingress │ │ │
│ │ │ │ │ │
│ │ │ manoonoils.com → WordPress │ │ │
│ │ │ dev.manoonoils.com → Next.js Storefront │ │ │
│ │ │ api.manoonoils.com → Saleor API │ │ │
│ │ │ dashboard.manoonoils.com → Saleor Dashboard │ │ │
│ │ │ minio-api.nodecrew.me → MinIO API │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Redis Usage
### WordPress Redis (manoonoils namespace)
```yaml
# WordPress uses Redis for:
# - Object caching (reduces DB queries)
# - Session storage
# - Transients cache
Service: redis.manoonoils.svc.cluster.local:6379
Purpose: WP Object Cache only
Data: Temporary cache (can be cleared)
Persistence: Not critical
```
**wp-config.php:**
```php
define( 'WP_REDIS_HOST', 'redis' );
define( 'WP_REDIS_PORT', 6379 );
```
### Saleor Redis (saleor namespace)
```yaml
# Saleor uses Redis for:
# - Celery task queue (background jobs)
# - Django cache framework
# - WebSocket channel layer (if using subscriptions)
Service: saleor-redis.saleor.svc.cluster.local:6379
Purpose: Task queue + Cache
Data: Task queue messages, cached data
Persistence: Not critical (tasks re-created if lost)
```
**Saleor environment:**
```bash
CELERY_BROKER_URL=redis://saleor-redis.saleor:6379/0
REDIS_URL=redis://saleor-redis.saleor:6379/0
```
### Key Point: Separate Redis Instances
| Component | Redis Instance | Purpose |
|-----------|----------------|---------|
| **WordPress** | `redis.manoonoils` | Object cache only |
| **Saleor** | `saleor-redis.saleor` | Celery + cache |
| **No sharing** | - | Each has its own |
## Media Storage Architecture
### MinIO Configuration
**Single MinIO instance serves both systems:**
```
MinIO Server (minio.manoonoils:9000)
├── manoon-media/ ← WordPress uploads (existing)
│ ├── wp-content/
│ │ └── uploads/
│ │ ├── 2024/
│ │ │ └── 01/
│ │ │ └── product-image.jpg
│ │ └── 2023/
│ └── assets/
│ └── logo.png
└── saleor/ ← Saleor media (new)
├── products/ ← Product images
├── assets/ ← Logos, favicons
└── exports/ ← Data exports
```
### WordPress Media Flow
```
1. User uploads image in WordPress
2. ADVMO plugin intercepts upload
3. Image saved to MinIO: manoon-media/wp-content/uploads/2024/01/image.jpg
4. WordPress stores URL: https://minio-api.nodecrew.me/manoon-media/wp-content/uploads/2024/01/image.jpg
5. Image served from MinIO
```
### Saleor Media Flow
```
1. User uploads image in Saleor Dashboard
2. Saleor API saves to MinIO: saleor/products/image.jpg
3. Saleor generates thumbnails
4. Image served via API: https://api.manoonoils.com/media/products/image.jpg
5. Or direct from MinIO: https://minio-api.nodecrew.me/saleor/products/image.jpg
```
## Image Migration Process
### Option 1: Copy Images (Recommended)
```bash
# 1. Access MinIO
kubectl exec -it deployment/minio -n manoonoils -- /bin/sh
# 2. Set up alias
mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
# 3. Create saleor bucket if not exists
mc mb local/saleor
# 4. Copy all WordPress images to Saleor bucket
mc cp --recursive local/manoon-media/wp-content/uploads/ local/saleor/products/
# 5. Copy assets
mc cp local/manoon-media/assets/logo.png local/saleor/assets/
```
**Result:**
- Original images stay in `manoon-media` (WordPress keeps working)
- Copies in `saleor` (for Saleor use)
- No downtime during migration
### Option 2: Move Images (After Full Migration)
```bash
# Only after WordPress is fully retired:
# 1. Move instead of copy
mc mv local/manoon-media/wp-content/uploads/ local/saleor/products/
# 2. Update any remaining references
# 3. Delete manoon-media bucket when confirmed safe
```
## Logo & Asset Strategy
### During Migration Period
**Keep logos in both places:**
```
MinIO:
├── manoon-media/assets/logo.png ← Used by WordPress
└── saleor/assets/logo.png ← Used by Saleor
```
**Next.js storefront:**
```typescript
// Use absolute URL to MinIO
const LOGO_URL = 'https://minio-api.nodecrew.me/saleor/assets/logo.png';
// Or use Next.js public folder
import logo from '@/public/logo.png';
```
### Post-Migration
**Option A: Keep in MinIO**
- Serve from `saleor/assets/`
- Update via MinIO console or API
- CDN-friendly
**Option B: Move to Next.js**
```
storefront/public/
├── logo.png
├── favicon.ico
└── assets/
└── hero-banner.jpg
```
**Access:** `https://dev.manoonoils.com/logo.png`
## Data Flow During Migration
### Phase 1: Parallel Running
```
Customer visits dev.manoonoils.com (Saleor storefront)
Products fetched from Saleor API
Product images loaded from:
- NEW products: saleor bucket
- OLD products: manoon-media bucket (mapped URL)
Checkout via Saleor checkout API
```
### Phase 2: Full Cutover
```
Customer visits dev.manoonoils.com
All products in Saleor
All images in saleor bucket
WordPress fully retired
```
## Backup Strategy
### What to Back Up
| Component | Backup Method | Frequency | Location |
|-----------|--------------|-----------|----------|
| **WordPress DB** | Kopia | Daily | StorageBox |
| **WordPress files** | Kopia | Daily | StorageBox |
| **MinIO buckets** | Kopia | Daily | StorageBox |
| **Saleor DB** | Kopia | Daily | StorageBox |
| **Saleor PVC** | Kopia | Daily | StorageBox |
### MinIO Backup Commands
```bash
# Backup specific bucket
kopia snapshot create /mnt/storagebox/kopia-backups
# Or use MinIO client for bucket backup
mc mirror local/saleor /backup/saleor-$(date +%Y%m%d)
```
## Summary
| Component | WordPress | Saleor | Relationship |
|-----------|-----------|--------|--------------|
| **Redis** | Separate instance | Separate instance | No sharing |
| **Database** | MariaDB | PostgreSQL | Separate |
| **Media** | manoon-media bucket | saleor bucket | Same MinIO |
| **Cache** | WP Object Cache | Django Cache | Separate |
| **Task Queue** | None (WP-Cron) | Celery + Redis | Saleor only |
**Key Takeaways:**
1. ✅ Saleor has its own Redis (no conflict with WordPress)
2. ✅ Both use same MinIO (easy image copying)
3. ✅ Copy images from `manoon-media` to `saleor` bucket
4. ✅ Keep logos in both places during transition
5. ✅ WordPress can stay running while Saleor is tested

168
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,168 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: storefront
namespace: manoonoils
spec:
replicas: 1
selector:
matchLabels:
app: storefront
template:
metadata:
labels:
app: storefront
spec:
initContainers:
- name: clone
image: alpine/git:latest
command:
- sh
- -c
- |
set -e
apk add --no-cache git
git clone --depth 1 --branch master \
http://gitea.gitea.svc.cluster.local:3000/unchained/manoon-headless.git \
/workspace
echo "Clone complete."
volumeMounts:
- name: workspace
mountPath: /workspace
securityContext:
runAsUser: 0
resources:
limits:
cpu: 500m
memory: 256Mi
- name: install
image: node:20-slim
workingDir: /workspace
command:
- sh
- -c
- |
set -e
echo "Installing dependencies..."
npm install --prefer-offline --no-audit 2>&1
echo "Dependencies installed."
volumeMounts:
- name: workspace
mountPath: /workspace
securityContext:
runAsUser: 0
resources:
limits:
cpu: 2000m
memory: 3Gi
requests:
cpu: 100m
memory: 1Gi
- name: build
image: node:20-slim
workingDir: /workspace
command:
- sh
- -c
- |
set -e
echo "Building Next.js app..."
npm run build
echo "Build complete!"
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_WOOCOMMERCE_URL
valueFrom:
secretKeyRef:
name: woocommerce-credentials
key: WOOCOMMERCE_URL
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY
valueFrom:
secretKeyRef:
name: woocommerce-credentials
key: WOOCOMMERCE_CONSUMER_KEY
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET
valueFrom:
secretKeyRef:
name: woocommerce-credentials
key: WOOCOMMERCE_CONSUMER_SECRET
- name: NEXT_PUBLIC_SITE_URL
value: "https://dev.manoonoils.com"
volumeMounts:
- name: workspace
mountPath: /workspace
securityContext:
runAsUser: 0
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 100m
memory: 512Mi
containers:
- name: storefront
image: node:20-slim
workingDir: /workspace
command:
- npm
- start
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: "production"
- name: PORT
value: "3000"
- name: HOSTNAME
value: "0.0.0.0"
- name: NEXT_PUBLIC_WOOCOMMERCE_URL
valueFrom:
secretKeyRef:
name: woocommerce-credentials
key: WOOCOMMERCE_URL
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY
valueFrom:
secretKeyRef:
name: woocommerce-credentials
key: WOOCOMMERCE_CONSUMER_KEY
- name: NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET
valueFrom:
secretKeyRef:
name: woocommerce-credentials
key: WOOCOMMERCE_CONSUMER_SECRET
- name: NEXT_PUBLIC_SITE_URL
value: "https://dev.manoonoils.com"
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 50m
memory: 128Mi
startupProbe:
httpGet:
path: /favicon.ico
port: 3000
periodSeconds: 10
failureThreshold: 30
livenessProbe:
httpGet:
path: /favicon.ico
port: 3000
periodSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /favicon.ico
port: 3000
periodSeconds: 5
failureThreshold: 3
volumeMounts:
- name: workspace
mountPath: /workspace
volumes:
- name: workspace
emptyDir:
sizeLimit: 2Gi

17
k8s/ingress.yaml Normal file
View File

@@ -0,0 +1,17 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: storefront
namespace: manoonoils
spec:
entryPoints:
- web
- websecure
routes:
- match: Host(`dev.manoonoils.com`)
kind: Rule
services:
- name: storefront
port: 3000
tls:
certResolver: letsencrypt

9
k8s/kustomization.yaml Normal file
View File

@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
images:
- name: ghcr.io/unchainedio/manoon-headless
newTag: 2c27fc6 # Updated by GitHub Actions

12
k8s/service.yaml Normal file
View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: storefront
namespace: manoonoils
spec:
selector:
app: storefront
ports:
- port: 3000
targetPort: 3000
type: ClusterIP

466
mautic-abandoned-cart.md Normal file
View File

@@ -0,0 +1,466 @@
# Mautic Abandoned Cart Recovery Setup
## Overview
Use your existing Mautic instance for abandoned cart recovery instead of paying for Klaviyo.
**Mautic URL:** https://mautic.nodecrew.me
## How It Works
```
1. User adds item to cart
2. Storefront sends event to Mautic (via API or tracking pixel)
3. Mautic creates/updates contact with cart data
4. Campaign waits 1 hour
5. If no purchase → Send abandoned cart email
6. User clicks email → Cart restored → Convert!
```
## Step 1: Set Up Mautic Tracking
### Option A: Mautic Tracking Pixel (JavaScript)
Add to your Next.js storefront:
```typescript
// lib/mautic.ts
export function trackAddToCart(product: any, quantity: number) {
if (typeof window !== 'undefined' && (window as any).mt) {
(window as any).mt('send', 'pageview', {
page_title: `Added to Cart: ${product.name}`,
page_url: window.location.href,
product_name: product.name,
product_sku: product.variants[0]?.sku,
product_price: product.variants[0]?.channelListings[0]?.price?.amount,
quantity: quantity,
event: 'add_to_cart'
});
}
}
export function trackCheckoutStarted(checkout: any) {
if (typeof window !== 'undefined' && (window as any).mt) {
(window as any).mt('send', 'pageview', {
page_title: 'Checkout Started',
page_url: window.location.href,
checkout_value: checkout.totalPrice?.amount,
checkout_id: checkout.id,
event: 'checkout_started'
});
}
}
export function trackOrderCompleted(order: any) {
if (typeof window !== 'undefined' && (window as any).mt) {
(window as any).mt('send', 'pageview', {
page_title: 'Order Completed',
page_url: window.location.href,
order_total: order.total.gross.amount,
order_id: order.id,
event: 'purchase_completed'
});
}
}
```
```typescript
// pages/_app.tsx or layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<head>
{/* Mautic Tracking */}
<Script
id="mautic-tracking"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(w,d,t,u,n,a,m){
w['MauticTrackingObject']=n;
w[n]=w[n]||function(){(w[n].q=w[n].q||[]).push(arguments)},a=d.createElement(t),
m=d.getElementsByTagName(t)[0];a.async=1;a.src=u;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://mautic.nodecrew.me/mtc.js','mt');
mt('send', 'pageview');
`
}}
/>
</head>
<body>{children}</body>
</html>
);
}
```
### Option B: Direct Mautic API Integration
More reliable for e-commerce events:
```typescript
// lib/mautic-api.ts
const MAUTIC_URL = 'https://mautic.nodecrew.me';
const MAUTIC_USERNAME = process.env.MAUTIC_API_USER;
const MAUTIC_PASSWORD = process.env.MAUTIC_API_PASS;
export async function createOrUpdateContact(email: string, data: any) {
const response = await fetch(`${MAUTIC_URL}/api/contacts/new`, {
method: 'POST',
headers: {
'Authorization': `Basic ${Buffer.from(`${MAUTIC_USERNAME}:${MAUTIC_PASSWORD}`).toString('base64')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
firstname: data.firstName,
lastname: data.lastName,
phone: data.phone,
// Custom fields for cart
cart_items: JSON.stringify(data.cartItems),
cart_value: data.cartValue,
cart_abandoned: true,
cart_abandoned_at: new Date().toISOString(),
last_product_added: data.lastProductName,
}),
});
return response.json();
}
export async function trackCartAbandoned(email: string, checkout: any) {
return createOrUpdateContact(email, {
cartItems: checkout.lines.map((line: any) => ({
name: line.variant.name,
quantity: line.quantity,
price: line.totalPrice.gross.amount,
})),
cartValue: checkout.totalPrice.gross.amount,
lastProductName: checkout.lines[0]?.variant.name,
});
}
export async function markCartRecovered(email: string) {
const response = await fetch(`${MAUTIC_URL}/api/contacts/edit`, {
method: 'PATCH',
headers: {
'Authorization': `Basic ${Buffer.from(`${MAUTIC_USERNAME}:${MAUTIC_PASSWORD}`).toString('base64')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
cart_abandoned: false,
cart_recovered: true,
cart_recovered_at: new Date().toISOString(),
}),
});
return response.json();
}
```
## Step 2: Create Custom Fields in Mautic
1. Go to https://mautic.nodecrew.me
2. Settings → Custom Fields
3. Create these fields:
| Field Label | Alias | Data Type | Default Value |
|-------------|-------|-----------|---------------|
| Cart Items | cart_items | Text | |
| Cart Value | cart_value | Number | 0 |
| Cart Abandoned | cart_abandoned | Boolean | false |
| Cart Abandoned At | cart_abandoned_at | Date/Time | |
| Last Product Added | last_product_added | Text | |
| Cart Recovered | cart_recovered | Boolean | false |
## Step 3: Create Segments
### Segment 1: Abandoned Cart (1 hour)
1. Segments → New
2. Name: "Abandoned Cart - 1 Hour"
3. Filters:
- Cart Abandoned = true
- Cart Abandoned At > 1 hour ago
- Cart Recovered = false
- Email = not empty
### Segment 2: Abandoned Cart (24 hours)
1. Segments → New
2. Name: "Abandoned Cart - 24 Hours"
3. Filters:
- Cart Abandoned = true
- Cart Abandoned At > 1 day ago
- Cart Recovered = false
- Email = not empty
## Step 4: Create Email Templates
### Email 1: First Reminder (1 hour)
**Subject:** Zaboravili ste nešto u korpi / You left something in your cart
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<!-- Serbian Version -->
<h2>Zdravo {contactfield=firstname},</h2>
<p>Primijetili smo da ste ostavili artikle u korpi:</p>
<!-- Cart Items (you'd dynamically insert these) -->
<div style="border: 1px solid #ddd; padding: 15px; margin: 15px 0;">
<p><strong>Poslednji proizvod:</strong> {contactfield=last_product_added}</p>
<p><strong>Vrednost korpe:</strong> {contactfield=cart_value} USD</p>
</div>
<a href="https://dev.manoonoils.com/cart-recovery?email={contactfield=email}"
style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; display: inline-block;">
Završite kupovinu
</a>
<hr>
<!-- English Version -->
<h2>Hello {contactfield=firstname},</h2>
<p>We noticed you left items in your cart:</p>
<div style="border: 1px solid #ddd; padding: 15px; margin: 15px 0;">
<p><strong>Last product:</strong> {contactfield=last_product_added}</p>
<p><strong>Cart value:</strong> {contactfield=cart_value} USD</p>
</div>
<a href="https://dev.manoonoils.com/cart-recovery?email={contactfield=email}"
style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; display: inline-block;">
Complete Purchase
</a>
</body>
</html>
```
### Email 2: Second Reminder (24 hours) - With Discount
**Subject:** Još uvijek čekamo! / Still waiting for you! (10% off)
```html
<!DOCTYPE html>
<html>
<body>
<h2>Hej {contactfield=firstname},</h2>
<p>Vaša korpa još uvijek čeka! Dajemo vam <strong>10% popusta</strong> da završite kupovinu:</p>
<p>Koristite kod: <strong>COMEBACK10</strong></p>
<a href="https://dev.manoonoils.com/cart-recovery?email={contactfield=email}&coupon=COMEBACK10"
style="background: #28a745; color: white; padding: 12px 24px; text-decoration: none; display: inline-block;">
Završite kupovinu sa 10% popusta
</a>
<hr>
<h2>Hey {contactfield=firstname},</h2>
<p>Your cart is still waiting! Here's <strong>10% off</strong> to complete your purchase:</p>
<p>Use code: <strong>COMEBACK10</strong></p>
<a href="https://dev.manoonoils.com/cart-recovery?email={contactfield=email}&coupon=COMEBACK10"
style="background: #28a745; color: white; padding: 12px 24px; text-decoration: none; display: inline-block;">
Complete Purchase with 10% Off
</a>
</body>
</html>
```
## Step 5: Create Campaign
### Campaign Workflow
1. **Campaigns → New**
2. Name: "Abandoned Cart Recovery"
3. Description: "Recover abandoned carts with 2-email sequence"
**Campaign Canvas:**
```
[Contact enters campaign]
[Decision: Cart Abandoned?]
↓ Yes
[Wait: 1 hour]
[Send Email: First Reminder]
[Wait: 23 hours]
[Decision: Cart Recovered?]
↓ No
[Send Email: Second Reminder + 10% off]
[Wait: 3 days]
[Decision: Cart Recovered?]
↓ No
[Remove from campaign]
```
### Campaign Settings
**Entry Conditions:**
- Contact added to segment "Abandoned Cart - 1 Hour"
**Exit Conditions:**
- Cart Recovered = true
- Order Completed event triggered
## Step 6: Cart Recovery Page
Create a recovery page in Next.js:
```typescript
// pages/cart-recovery.tsx
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { saleorClient } from '@/lib/saleor/client';
import { gql } from '@apollo/client';
import { markCartRecovered } from '@/lib/mautic-api';
const GET_CHECKOUT_BY_EMAIL = gql`
query GetCheckoutByEmail($email: String!) {
checkouts(first: 1, filter: {customer: $email}) {
edges {
node {
id
token
lines {
id
quantity
variant {
id
name
product {
name
}
}
}
}
}
}
}
`;
export default function CartRecoveryPage() {
const router = useRouter();
const { email, coupon } = router.query;
useEffect(() => {
if (email) {
// Mark cart as recovered in Mautic
markCartRecovered(email as string);
// Redirect to checkout with recovered cart
// You'll need to implement checkout restoration logic
router.push(`/checkout?email=${email}${coupon ? `&coupon=${coupon}` : ''}`);
}
}, [email, coupon, router]);
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<p>Restoring your cart...</p>
</div>
);
}
```
## Step 7: Storefront Integration
Add tracking to your add-to-cart and checkout flows:
```typescript
// components/AddToCartButton.tsx
import { trackAddToCart } from '@/lib/mautic';
import { trackCartAbandoned } from '@/lib/mautic-api';
export function AddToCartButton({ product, variant, quantity }) {
const handleAddToCart = async () => {
// Add to Saleor cart
await addToCart(variant.id, quantity);
// Track in Mautic
trackAddToCart(product, quantity);
};
return <button onClick={handleAddToCart}>Add to Cart</button>;
}
```
```typescript
// components/CheckoutForm.tsx
import { trackCheckoutStarted } from '@/lib/mautic';
export function CheckoutForm({ checkout, email }) {
useEffect(() => {
if (checkout && email) {
trackCheckoutStarted(checkout);
}
}, [checkout, email]);
// Track abandonment when user leaves
useEffect(() => {
const handleBeforeUnload = () => {
if (!orderCompleted && checkout) {
trackCartAbandoned(email, checkout);
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [checkout, email, orderCompleted]);
return <form>...</form>;
}
```
## Testing
1. **Add item to cart** on storefront
2. **Enter email** at checkout
3. **Close browser** (don't complete purchase)
4. **Wait 1 hour**
5. **Check Mautic** → Contact should have cart_abandoned = true
6. **Check email** → Should receive first reminder
## Monitoring
Track campaign performance in Mautic:
- **Emails Sent**
- **Open Rate**
- **Click Rate**
- **Conversion Rate** (cart recovered)
- **Revenue Generated**
## Cost Comparison
| Solution | Monthly Cost | Setup Time |
|----------|-------------|------------|
| **Mautic** (existing) | FREE | 2-3 days |
| Klaviyo | $20-50+ | 1 day |
| Custom Build | FREE | 2-4 weeks |
## Summary
**Mautic CAN do abandoned cart recovery**
**Use your existing instance = FREE**
⚠️ **Requires custom integration work**
⚠️ **Email templates need manual setup**
**Recommendation:** Since you already pay for Mautic hosting, use it for abandoned cart instead of paying for Klaviyo. The setup is moderate complexity but saves $20-50/month.

449
media-migration-guide.md Normal file
View File

@@ -0,0 +1,449 @@
# Media & Image Migration Guide
## Current Setup
### WordPress/WooCommerce (Current)
- **Storage:** MinIO
- **Bucket:** `manoon-media`
- **Plugin:** Advanced Media Offloader (ADVMO)
- **Endpoint:** `http://minio:9000`
- **Public URL:** `https://minio-api.nodecrew.me/manoon-media/`
### Saleor (New)
- **Storage:** MinIO (same instance)
- **Bucket:** `saleor`
- **Endpoint:** `http://minio.manoonoils:9000`
- **Media URL:** `/media/` (served via Saleor API)
- **PVC:** `saleor-media-pvc` (5GB local cache)
## Architecture
```
┌─────────────────┐ ┌─────────────────┐
│ WordPress │ │ Saleor │
│ │ │ │
│ WooCommerce │ │ API/Dashboard│
│ │ │ │
└────────┬────────┘ └────────┬────────┘
│ │
│ ADVMO Plugin │ django-storages
│ (S3-compatible) │ (S3-compatible)
│ │
└───────────┬───────────────┘
┌───────────┴───────────┐
│ MinIO │
│ (S3-compatible │
│ object storage) │
└───────────┬───────────┘
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐
│ manoon- │ │ saleor │ │ other │
│ media │ │ bucket │ │ buckets │
│ (WP) │ │(Saleor) │ │ │
└─────────┘ └─────────┘ └───────────┘
```
## Step 1: Verify Buckets
```bash
# Access MinIO container
kubectl exec -ti deployment/minio -n manoonoils -- /bin/sh
# List all buckets
mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
mc ls local
# Expected output:
# [bucket] manoon-media (WordPress)
# [bucket] saleor (Saleor)
# [bucket] other... (if any)
```
If `saleor` bucket doesn't exist, create it:
```bash
mc mb local/saleor
```
## Step 2: Image Migration Strategies
### Option A: Copy Images from WordPress to Saleor Bucket
**Best for:** Clean separation, full control
```bash
# Copy all images from WordPress bucket to Saleor bucket
kubectl exec -ti deployment/minio -n manoonoils -- \
mc cp --recursive local/manoon-media/wp-content/uploads/ local/saleor/
# Or sync (faster for subsequent runs)
kubectl exec -ti deployment/minio -n manoonoils -- \
mc mirror local/manoon-media/wp-content/uploads/ local/saleor/products/
```
**After copy, images will be at:**
- `http://minio-api.nodecrew.me/saleor/products/2024/01/image.jpg`
### Option B: Share Bucket (Keep WordPress Images in Place)
**Best for:** Quick migration, no duplication
Configure Saleor to read from `manoon-media` bucket:
```yaml
# Update deployment to use WordPress bucket temporarily
env:
- name: AWS_MEDIA_BUCKET_NAME
value: "manoon-media" # Instead of "saleor"
- name: MEDIA_URL
value: "https://minio-api.nodecrew.me/manoon-media/"
```
**Pros:** No copying needed
**Cons:** WordPress and Saleor share bucket (risk of conflicts)
### Option C: Keep Separate + URL Mapping
**Best for:** Gradual migration
1. Keep WordPress images in `manoon-media`
2. New Saleor uploads go to `saleor` bucket
3. Use URL mapping for old images
```typescript
// Storefront image component
const ProductImage = ({ imageUrl }) => {
// If image is from old WordPress, rewrite URL
const mappedUrl = imageUrl.includes('manoon-media')
? imageUrl.replace('manoon-media', 'saleor')
: imageUrl;
return <img src={mappedUrl} />;
};
```
## Step 3: Add Images to Saleor Products
### Saleor Product Media Structure
Saleor stores media in `product_productmedia` table:
```sql
-- Check table structure
\d product_productmedia
-- Columns:
-- id, product_id, image (file path), alt, sort_order, type
```
### Migration Script
```sql
-- Create temporary mapping table
CREATE TEMP TABLE wp_image_mapping (
wp_product_id INTEGER,
saleor_product_id INTEGER,
wp_image_url VARCHAR(500),
saleor_image_path VARCHAR(500)
);
-- After copying images to saleor bucket, insert media records
INSERT INTO product_productmedia (product_id, image, alt, sort_order, type)
SELECT
p.id as product_id,
'products/' || SPLIT_PART(m.saleor_image_path, '/', -1) as image,
p.name as alt,
0 as sort_order,
'IMAGE' as type
FROM temp_woocommerce_import t
JOIN product_product p ON p.slug = t.slug
JOIN wp_image_mapping m ON m.wp_product_id = t.wc_id;
```
### Using Saleor Dashboard (Manual)
For small catalogs, use the Saleor Dashboard:
1. Go to https://dashboard.manoonoils.com
2. Catalog → Products → Select product
3. Media tab → Upload images
4. Set alt text, sort order
### Using GraphQL API (Programmatic)
```graphql
mutation ProductMediaCreate($product: ID!, $image: Upload!, $alt: String) {
productMediaCreate(input: {product: $product, image: $image, alt: $alt}) {
media {
id
url
}
errors {
field
message
}
}
}
```
Python script example:
```python
import requests
from saleor.graphql import Client
# Upload image to Saleor
def upload_product_image(product_id, image_path, alt_text):
url = "https://api.manoonoils.com/graphql/"
query = """
mutation ProductMediaCreate($product: ID!, $image: Upload!, $alt: String) {
productMediaCreate(input: {product: $product, image: $image, alt: $alt}) {
media { id url }
errors { field message }
}
}
"""
operations = {
"query": query,
"variables": {
"product": product_id,
"alt": alt_text
}
}
map_data = {"0": ["variables.image"]}
with open(image_path, 'rb') as f:
files = {
'operations': (None, json.dumps(operations)),
'map': (None, json.dumps(map_data)),
'0': (image_path, f, 'image/jpeg')
}
response = requests.post(url, files=files)
return response.json()
```
## Step 4: Handle Logos & Assets
### Option 1: Store in Saleor (Recommended)
Upload logos to Saleor as product media for a "Store" product, or serve via CDN:
```bash
# Upload logo to MinIO saleor bucket
mc cp logo.png local/saleor/assets/
mc cp favicon.ico local/saleor/assets/
```
**Access URLs:**
- Logo: `https://minio-api.nodecrew.me/saleor/assets/logo.png`
- Favicon: `https://minio-api.nodecrew.me/saleor/assets/favicon.ico`
### Option 2: Store in Next.js Public Folder
For storefront-specific assets:
```
storefront/
├── public/
│ ├── logo.png
│ ├── favicon.ico
│ └── images/
│ └── hero-banner.jpg
```
Access: `https://dev.manoonoils.com/logo.png`
### Option 3: Keep in WordPress (Transition Period)
Continue serving assets from WordPress during migration:
```typescript
// Storefront config
const ASSETS_URL = process.env.NEXT_PUBLIC_ASSETS_URL ||
'https://minio-api.nodecrew.me/manoon-media/assets/';
// Usage
<img src={`${ASSETS_URL}logo.png`} alt="Logo" />
```
## Step 5: Storefront Image Component
Handle both old and new image URLs:
```typescript
// components/ProductImage.tsx
import { useState } from 'react';
interface ProductImageProps {
url: string;
alt: string;
className?: string;
}
export function ProductImage({ url, alt, className }: ProductImageProps) {
const [error, setError] = useState(false);
// Map old WordPress URLs to new Saleor URLs
const mappedUrl = url?.includes('manoon-media')
? url.replace('manoon-media', 'saleor')
: url;
if (error) {
return <div className="image-placeholder">No Image</div>;
}
return (
<img
src={mappedUrl}
alt={alt}
className={className}
onError={() => setError(true)}
loading="lazy"
/>
);
}
```
## Step 6: Image Optimization
### Saleor Thumbnails
Saleor automatically generates thumbnails:
```graphql
query ProductImages {
product(slug: "organsko-maslinovo-ulje", channel: "default-channel") {
media {
id
url
alt
type
# Thumbnails
thumbnail(size: 255) {
url
}
thumbnail(size: 510) {
url
}
thumbnail(size: 1020) {
url
}
}
}
}
```
### Next.js Image Optimization
```typescript
import Image from 'next/image';
// Optimized image component
export function OptimizedProductImage({ media }) {
return (
<Image
src={media.thumbnail?.url || media.url}
alt={media.alt}
width={400}
height={400}
quality={80}
placeholder="blur"
blurDataURL={media.thumbnail?.url}
/>
);
}
```
## Step 7: Bulk Image Migration Script
```bash
#!/bin/bash
# migrate-images.sh
# 1. Export WooCommerce product images list
kubectl exec deployment/wordpress -n manoonoils -- \
wp db query "SELECT p.ID, p.post_title, pm.meta_value as image_url
FROM wp_posts p
JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'product' AND pm.meta_key = '_wp_attached_file'" \
> /tmp/wp-images.csv
# 2. Copy images to Saleor bucket
while IFS=',' read -r product_id title image_path; do
echo "Copying: $image_path"
kubectl exec deployment/minio -n manoonoils -- \
mc cp "local/manoon-media/$image_path" "local/saleor/products/"
done < /tmp/wp-images.csv
# 3. Update Saleor database with image paths
# (Run SQL script to insert into product_productmedia)
```
## Step 8: Verification Checklist
- [ ] All products have at least one image
- [ ] Images load correctly in Saleor Dashboard
- [ ] Images display in storefront
- [ ] Thumbnails generate properly
- [ ] Alt text is set for SEO
- [ ] Logo loads correctly
- [ ] Favicon works
- [ ] No broken image links
## Troubleshooting
### Images not showing in Saleor Dashboard
```bash
# Check if Saleor can access MinIO
kubectl exec deployment/saleor-api -n saleor -- \
curl -I http://minio.manoonoils:9000/saleor/
# Check bucket permissions
kubectl exec deployment/minio -n manoonoils -- \
mc policy get local/saleor
# Set bucket to public (if needed)
kubectl exec deployment/minio -n manoonoils -- \
mc policy set public local/saleor
```
### Image URLs returning 404
1. Check image exists in bucket:
```bash
mc ls local/saleor/products/2024/01/
```
2. Check image path in database:
```sql
SELECT * FROM product_productmedia WHERE product_id = 1;
```
3. Verify MEDIA_URL configuration:
```bash
kubectl get deployment saleor-api -n saleor -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="MEDIA_URL")].value}'
```
## Summary
| Component | Current (WP) | Target (Saleor) | Action |
|-----------|--------------|-----------------|--------|
| **Product Images** | MinIO: `manoon-media` | MinIO: `saleor` | Copy or share bucket |
| **Logo** | WP media | MinIO: `saleor/assets/` or Next.js public | Upload to new location |
| **Favicon** | WP root | Next.js public or MinIO | Move to storefront |
| **Thumbnails** | WP generates | Saleor generates | Automatic |
| **CDN** | MinIO direct | MinIO direct or Cloudflare | Optional upgrade |
## Recommended Approach
1. **Create `saleor` bucket** in existing MinIO
2. **Copy** all product images from `manoon-media` to `saleor`
3. **Upload logos** to `saleor/assets/` or Next.js public folder
4. **Run SQL** to insert image records into `product_productmedia`
5. **Update storefront** to handle both old and new URLs during transition
6. **Test** all images load correctly

240
moumoujus-specification.md Normal file
View File

@@ -0,0 +1,240 @@
# MOUMOUJUS.COM - Complete Technical Specification
## 1. TECH STACK
| Component | Technology |
|-----------|------------|
| **Framework** | Next.js 15 (App Router) |
| **Language** | TypeScript |
| **Styling** | Tailwind CSS |
| **Build Tool** | Turbopack |
| **Hosting** | Vercel |
| **Fonts** | DM Sans (body), Cedrat Display (headings) |
| **Animations** | CSS Animations + Framer Motion (scroll animations) |
| **Push Notifications** | OneSignal |
| **Images** | WebP format, Next.js Image optimization |
## 2. COLOR PALETTE
| Token | Value | Usage |
|-------|-------|-------|
| `bg-ice-blue` | `#E8F4F8` | Primary background, hero sections |
| `bg-ice-blue-light` | `#F0F7FA` | Secondary background, testimonials |
| `text-heading` | `#1A1A1A` | Headlines, primary text |
| `text-foreground` | `#4A4A4A` | Body text |
| `text-muted` | `#6B7280` | Labels, captions |
| `border-border-light` | `rgba(26,26,26,0.06)` | Subtle borders |
| `text-success` | `#10B981` | Success states, checkmarks |
| `white` | `#FFFFFF` | Cards, overlays |
## 3. TYPOGRAPHY
### Primary Font (Body): DM Sans
- Weights: 300, 400, 500, 600, 700
- Fallback: Arial, sans-serif
### Secondary Font (Headings): Cedrat Display
- Weights: 100, 300, 400, 700 + Italic variants
- Used for: `.font-serif` class (all headlines)
### Typography Scale
| Element | Size | Weight | Line Height | Tracking |
|---------|------|--------|-------------|----------|
| H1 (Hero) | `text-3xl``text-5xl` | 400 | `leading-[1.15]` | `tracking-tight` |
| H2 (Section) | `text-4xl``text-6xl` | 400 italic | `leading-[1.1]` | `tracking-tight` |
| H3 (Cards) | `text-lg``text-xl` | 500 | Normal | `tracking-wide` |
| Body | `text-base` | 400 | `leading-relaxed` | Normal |
| Label | `text-xs` | 500 | Normal | `tracking-[0.3em]` uppercase |
| Caption | `text-[11px]` | 500 | Normal | `tracking-wider` uppercase |
## 4. HEADER / ANNOUNCEMENT BAR
**Position:** Fixed top, full width, z-index 50
**Background:** `bg-ice-blue` (light blue)
**Height:** ~40px
### Marquee Animation
- **Content:** Repeating text: "CERAMIDE MOISTURISER FOR BARRIER REPAIR" with arrows (→)
- **Animation:** `animate-marquee` - infinite horizontal scroll left
- **Speed:** ~30 seconds per cycle
- **Icon:** Lucide ArrowRight
- **Gap between items:** `mx-10 lg:mx-16`
- **Typography:** `text-xs tracking-[0.12em] uppercase text-heading/70`
### Navigation Bar (Sticky)
**Height:** 64px
**Max Width:** 1400px
**Padding:** `px-6`
#### Left Side:
- Logo: SVG format
- Height: `h-5 lg:h-6`
#### Right Side Icons:
1. Account/User
2. Shopping Bag
3. Mobile Menu (Hamburger)
## 5. HERO SECTION
**Height:** `100vh` (full viewport)
**Position:** `relative`, `overflow-hidden`
### Background
**Image:** Full cover hero image
**Overlay:** None visible
### Mobile Hero Text (Bottom Center)
**Position:** Absolute, flex justify-end
**Headline:**
- Font: Cedrat Display, italic
- Size: `text-3xl`
- Color: White
- Text: "Barrier Repair, Real Results"
### Desktop Floating Product Card
**Position:** `absolute left-10 lg:left-20 top-24 lg:top-28`
**Width:** `300px``320px`
**Background:** `bg-white/95 backdrop-blur-md`
**Border Radius:** `rounded-[4px]`
**Shadow:** `shadow-lg`
#### Card Content:
1. Product Image
2. Title: "The Mantle" + 5-star rating
3. Subtitle: Product description
4. Tech Badge: "STRATA-3™ Technology"
5. Price + "Add to Cart" button
## 6. BRAND MARQUEE
**Background:** White
**Border:** `border-y border-border-light`
**Animation:** `animate-marquee-slow`
**Logos:** ~12+ SVG logos in grayscale
## 7. PHILOSOPHY / MANIFESTO SECTION
### Sticky Phone Mockup (Left Side)
**Phone Frame:**
- Width: `360px`, Height: `700px`
- Rounded: `rounded-[4px]`
- Background: Gradient
**Rotating Border:**
- Dashed circle
- Animation: `spin 60s linear infinite`
### Scrolling Content (Right Side)
**Section 1: Featured Product**
- Label: "Featured"
- Headline: "The Mantle" (italic serif)
- Body text + CTA button
**Section 2: The Science**
- Headline: "You have needs, we have answers"
- 4 accordion items with icons:
1. Skin Barrier Repair & Hydration
2. Environmental Protection with Ectoin
3. Anti-Ageing with Peptides & Exosomes
4. Best Moisturiser for Sensitive Skin
## 8. TRANSFORMATION / STATS SECTION
**Background:** `bg-ice-blue`
**Stats Grid (4 columns):**
| Stat | Value |
|------|-------|
| 92% | improved hydration |
| 87% | reduction in fine lines |
| 95% | smoother skin texture |
| 89% | calmer skin |
## 9. STRATA-3™ TECHNOLOGY SECTION
**Three Levels (1-2-3):**
1. **Cellular** - Ectoin and exosomes
2. **Tissue** - Peptides and stem cells
3. **Surface** - Triple ceramide complex
## 10. HAPPY FACE GUARANTEE
**Headline:** "Happy Face Guarantee"
**Content:** Full refund policy description
**Icon:** Shield with checkmark
## 11. BEFORE/AFTER TESTIMONIALS
**Background:** `bg-ice-blue-light`
**Card Style:**
- White background, rounded corners
- 5-star rating
- Quote (italic serif)
- Author name + skin type
- "Verified purchase" badge
## 12. FAQ SECTION
Accordion style with dashed borders
Sample questions about product usage, shipping, etc.
## 13. EMAIL SIGNUP / DISCOUNT
**Headline:** "Get 11% off your first order"
**Form:** Email input + Subscribe button
## 14. FOOTER
**5 Columns:**
1. Who is Moumoujus?
2. About
3. Shop
4. Help
5. Connect
**Bottom Bar:** Copyright, legal links, payment icons
## 15. ANIMATIONS
### Scroll Animations
- Fade In Up: opacity 0→1, translateY 20px→0
- Duration: 600-800ms
- Stagger: 100-150ms
### Hover Effects
- Button: Text slide effect
- Links: Color transition + underline
- Cards: Subtle lift
### Marquees
```css
animation: marquee 30s linear infinite;
```
## 16. IMAGES NEEDED
- Logo (SVG)
- Hero background (WebP)
- Product images
- Before/After photos
- Testimonial videos
## 17. RESPONSIVE BREAKPOINTS
- Mobile: < 640px
- Tablet: 640px+
- Desktop: 1024px+
- Wide: 1280px+
## 18. COMPONENTS TO BUILD
1. Marquee
2. StickyPhone
3. ProductCard
4. TestimonialCard
5. Accordion
6. AnimatedButton
7. StarRating
8. BeforeAfter
9. NewsletterForm

189
package-lock.json generated
View File

@@ -8,12 +8,17 @@
"name": "manoonoils-store",
"version": "0.1.0",
"dependencies": {
"@apollo/client": "^4.1.6",
"@woocommerce/woocommerce-rest-api": "^1.0.2",
"clsx": "^2.1.1",
"framer-motion": "^12.34.4",
"graphql": "^16.13.1",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next-intl": "^4.8.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
},
"devDependencies": {
@@ -40,6 +45,48 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@apollo/client": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.1.6.tgz",
"integrity": "sha512-ak8uzqmKeX3u9BziGf83RRyODAJKFkPG72hTNvEj4WjMWFmuKW2gGN1i3OfajKT6yuGjvo+n23ES2zqWDKFCZg==",
"license": "MIT",
"workspaces": [
"dist",
"codegen",
"scripts/codemods/ac3-to-ac4"
],
"dependencies": {
"@graphql-typed-document-node/core": "^3.1.1",
"@wry/caches": "^1.0.0",
"@wry/equality": "^0.5.6",
"@wry/trie": "^0.5.0",
"graphql-tag": "^2.12.6",
"optimism": "^0.18.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"graphql": "^16.0.0",
"graphql-ws": "^5.5.5 || ^6.0.3",
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
"react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
"rxjs": "^7.3.0",
"subscriptions-transport-ws": "^0.9.0 || ^0.11.0"
},
"peerDependenciesMeta": {
"graphql-ws": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"subscriptions-transport-ws": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -509,6 +556,15 @@
"tslib": "^2.8.1"
}
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2698,6 +2754,54 @@
"node": ">=8.0.0"
}
},
"node_modules/@wry/caches": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz",
"integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@wry/context": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz",
"integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@wry/equality": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz",
"integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@wry/trie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz",
"integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3191,6 +3295,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4511,6 +4624,30 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphql": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz",
"integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/graphql-tag": {
"version": "2.12.6",
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz",
"integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -5604,6 +5741,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5903,17 +6049,6 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -6103,6 +6238,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/optimism": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
"integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==",
"license": "MIT",
"dependencies": {
"@wry/caches": "^1.0.0",
"@wry/context": "^0.7.0",
"@wry/trie": "^0.5.0",
"tslib": "^2.3.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6539,6 +6686,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -7085,6 +7242,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",

View File

@@ -9,12 +9,17 @@
"lint": "eslint"
},
"dependencies": {
"@apollo/client": "^4.1.6",
"@woocommerce/woocommerce-rest-api": "^1.0.2",
"clsx": "^2.1.1",
"framer-motion": "^12.34.4",
"graphql": "^16.13.1",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next-intl": "^4.8.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
},
"devDependencies": {

296
saleor-features.md Normal file
View File

@@ -0,0 +1,296 @@
# Saleor Features Overview
## Built-in Features
### Core Commerce
- **Products & Variants** - Support for simple and variable products
- **Categories** - Hierarchical nested categories (MPTT tree structure)
- **Collections** - Manual and automated collections
- **Inventory** - Multi-warehouse stock tracking
- **Channels** - Multi-channel support (different prices/currencies per channel)
- **Multi-language** - Full translation support for products, categories, pages
- **Multi-currency** - Channel-based currency configuration
### Orders & Checkout
- **Shopping Cart** - Persistent cart with metadata support
- **Checkout Flow** - Customizable checkout process
- **Orders** - Full order management with status tracking
- **Draft Orders** - Create orders manually (e.g., for phone orders)
- **Order History** - Complete audit trail of order changes
### Payments
- **Payment Gateway Integration** - Stripe, Adyen, PayPal, and more
- **Transactions** - Transaction-based payment handling (Saleor 3.x+)
- **Multiple Payment Methods** - Per-channel configuration
- **Partial Payments** - Support for split payments
### Shipping
- **Shipping Zones** - Geographic shipping regions
- **Shipping Methods** - Multiple carriers and rates
- **Free Shipping** - Threshold-based free shipping
- **Weight-based Rates** - Calculate by product weight
### Discounts & Promotions
- **Vouchers** - Coupon codes with various rules
- **Promotions** - Automatic discounts (percentage, fixed amount)
- **Buy X Get Y** - Gift with purchase promotions
- **Catalog Promotions** - Category/product-specific discounts
### Customers
- **User Accounts** - Customer registration and profiles
- **Address Book** - Multiple shipping/billing addresses
- **Customer Groups** - User segmentation
- **Order History** - Customer order visibility
### Content Management
- **Pages** - Static pages (About, Contact, etc.)
- **Menus** - Navigation menu builder
- **Page Types** - Structured content with attributes
### Gift Cards
- **Digital Gift Cards** - Sell and redeem gift cards
- **Balance Tracking** - Usage history
### Taxes
- **Tax Classes** - Different tax rates per product type
- **Tax Configuration** - Country/region-specific taxes
- **VAT Support** - European VAT handling
### Staff & Permissions
- **Staff Accounts** - Admin user management
- **Permission Groups** - Role-based access control
- **Impersonation** - Login as customer for support
---
## Missing Features (Need to Build/Add)
### 1. Product Reviews ⭐
**Status:** NOT built-in (on roadmap but not planned)
**Official Statement:**
> "We are not planning to add product reviews, however, you could use product metadata to provide very basic reviews or use a full fledge service for reviews such as trustpilot and integrate it with Saleor."
**Options:**
#### Option A: Third-Party Service (Recommended)
- **Trustpilot** - Industry standard, SEO benefits
- **Yotpo** - Reviews + UGC + loyalty
- **Judge.me** - Affordable, works well with headless
- **Reviews.io** - Good API for headless
Integration: Add JS widget to storefront
#### Option B: Build Custom Review System
Create new tables:
```sql
-- Custom reviews table
CREATE TABLE product_review (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES product_product(id),
user_id INTEGER REFERENCES account_user(id),
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
title VARCHAR(255),
comment TEXT,
is_approved BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
Then add GraphQL mutations:
```graphql
type Mutation {
productReviewCreate(productId: ID!, input: ReviewInput!): ProductReview
productReviewUpdate(reviewId: ID!, input: ReviewInput!): ProductReview
}
```
**Effort:** Medium-High (2-4 weeks)
#### Option C: Use Product Metadata (Quick Hack)
Store reviews in product metadata:
```json
{
"reviews": [
{"rating": 5, "comment": "Great product!", "author": "John"}
]
}
```
**Limitations:** No filtering, no moderation, poor performance
---
### 2. Abandoned Cart Recovery ⭐
**Status:** NOT built-in
**Options:**
#### Option A: Email Marketing Platform (Recommended)
Most popular solution:
**Klaviyo** (Best for Saleor)
- Native e-commerce focus
- Abandoned cart flows
- Product recommendations
- Customer segmentation
- Works via API integration
**Integration approach:**
```javascript
// Track checkout started
klaviyo.track('Started Checkout', {
$value: checkout.totalPrice.amount,
ItemNames: checkout.lines.map(l => l.variant.name),
CheckoutURL: `https://dev.manoonoils.com/checkout/${checkout.id}`
});
```
Other options:
- **Mailchimp** - Good free tier
- **Sendinblue** - Affordable
- **ActiveCampaign** - Advanced automation
- **Omnisend** - E-commerce focused
**Effort:** Low-Medium (1-2 weeks)
#### Option B: Build Custom Abandoned Cart
Database approach:
```sql
-- Track checkout abandonment
CREATE TABLE checkout_abandoned (
checkout_id INTEGER PRIMARY KEY REFERENCES checkout_checkout(id),
user_email VARCHAR(255),
cart_value NUMERIC(20,3),
abandoned_at TIMESTAMP DEFAULT NOW(),
email_sent BOOLEAN DEFAULT false,
recovered BOOLEAN DEFAULT false
);
```
Components needed:
1. **Background job** - Check for abandoned carts (e.g., 1 hour after last update)
2. **Email service** - Sendgrid/AWS SES/etc
3. **Email templates** - Serbian + English
4. **Recovery URL** - Deep link to restore cart
5. **Analytics** - Track recovery rate
**Effort:** High (4-6 weeks)
#### Option C: N8N Automation
Use your existing n8n instance:
```
Trigger: Schedule (every hour)
PostgreSQL: Find abandoned checkouts
Filter: Not completed, older than 1 hour
Send Email: Via Sendgrid
Update: Mark email_sent = true
```
**Effort:** Medium (1-2 weeks)
---
### 3. Email Marketing Automation
**Status:** NOT built-in
**Options:**
- Klaviyo (recommended)
- Mailchimp
- Sendinblue
**What you get:**
- Welcome emails
- Order confirmations
- Shipping notifications
- Post-purchase follow-up
- Win-back campaigns
---
### 4. Live Chat
**Status:** NOT built-in
**Options:**
- Tidio
- Intercom
- Crisp
- Tawk.to (free)
---
### 5. Analytics
**Status:** NOT built-in
**Options:**
- Google Analytics 4
- Plausible
- Mixpanel
- Amplitude
- Your existing Rybbit
---
## Recommended Setup for Manoon Oils
### Phase 1: Essential (Launch)
- [ ] Saleor core (✅ Done)
- [ ] Payment gateway (Stripe)
- [ ] Shipping configuration
- [ ] Tax setup
- [ ] Basic email (order confirmations)
### Phase 2: Growth (Post-launch)
- [ ] **Klaviyo** - Abandoned cart + email marketing
- [ ] **Trustpilot** or **Judge.me** - Product reviews
- [ ] Advanced analytics
- [ ] Live chat
### Phase 3: Optimization
- [ ] Loyalty program
- [ ] Subscription products
- [ ] Advanced promotions
- [ ] B2B features
---
## Cost Estimate
| Feature | Solution | Monthly Cost |
|---------|----------|--------------|
| Reviews | Judge.me | Free - $15 |
| Reviews | Trustpilot | $200+ |
| Abandoned Cart | Klaviyo | Free (up to 250 contacts) - $20+ |
| Live Chat | Tawk.to | Free |
| Live Chat | Intercom | $74+ |
| Email | Sendgrid | Free (100/day) - $19+ |
---
## Summary
| Feature | Built-in? | Solution |
|---------|-----------|----------|
| Product Reviews | ❌ No | Judge.me / Trustpilot / Custom build |
| Abandoned Cart | ❌ No | Klaviyo / N8N automation / Custom build |
| Email Marketing | ❌ No | Klaviyo / Mailchimp |
| Live Chat | ❌ No | Tawk.to / Intercom |
| Gift Cards | ✅ Yes | Native Saleor |
| Multi-language | ✅ Yes | Native Saleor |
| Multi-currency | ✅ Yes | Native Saleor |
| Promotions | ✅ Yes | Native Saleor |
| Inventory | ✅ Yes | Native Saleor |
**Bottom line:** Saleor is a solid commerce engine but requires third-party services or custom development for reviews and abandoned cart recovery.

521
saleor-migration.md Normal file
View File

@@ -0,0 +1,521 @@
# Saleor Product Migration Guide
## Overview
Guide for migrating products from WooCommerce to Saleor while maintaining identical URLs and supporting Serbian/English translations.
## URL Structure Comparison
| Platform | Product URL Pattern |
|----------|---------------------|
| **WooCommerce** | `/product/product-name/` |
| **Target Structure** | `/products/organsko-maslinovo-ulje/` (Serbian) <br> `/products/organic-olive-oil/` (English) |
| **Saleor API** | No URLs - GraphQL only |
| **Saleor Storefront** | Configurable via Next.js routing |
## Important: Saleor is Headless
Saleor itself has **no URLs** - it's just a GraphQL API. The URLs are determined by your **storefront** (Next.js/React app).
Current setup:
- `dev.manoonoils.com` → Next.js Storefront (currently using WooCommerce)
- `api.manoonoils.com` → Saleor API (headless)
- `dashboard.manoonoils.com` → Saleor Admin
## URL Structure: /products/ with Different Slugs per Language
**Target URL structure (no language prefix):**
```
/products/organsko-maslinovo-ulje-500ml/ ← Serbian
/products/organic-olive-oil-500ml/ ← English (different slug)
```
Both URLs work independently - user switches language by clicking a language selector that navigates to the translated slug.
### Database Setup
**1. Serbian product (default):**
```sql
INSERT INTO product_product (
name, slug, description, description_plaintext,
product_type_id, seo_title, seo_description
) VALUES (
'Organsko Maslinovo Ulje 500ml',
'organsko-maslinovo-ulje-500ml', -- Serbian slug
'{"blocks": [{"type": "paragraph", "data": {"text": "Opis na srpskom"}}]}',
'Opis na srpskom',
1, 'Organsko Maslinovo Ulje', 'Najbolje organsko ulje'
);
```
**2. English translation with different slug:**
```sql
-- Note: Different slug for English version
INSERT INTO product_producttranslation (
product_id, language_code, name, slug,
description, seo_title, seo_description
) VALUES (
1, 'en',
'Organic Olive Oil 500ml',
'organic-olive-oil-500ml', -- English slug (different!)
'{"blocks": [{"type": "paragraph", "data": {"text": "English description"}}]}',
'Organic Olive Oil', 'Best organic olive oil'
);
```
### Next.js Storefront Configuration
**next.config.js:**
```javascript
module.exports = {
// Disable Next.js i18n routing - we handle it manually
i18n: {
locales: ['default'],
defaultLocale: 'default',
localeDetection: false,
},
async rewrites() {
return [
// Handle /products/ prefix
{
source: '/products/:slug*',
destination: '/products/:slug*',
},
];
},
};
```
**pages/products/[slug].tsx:**
```typescript
import { GetStaticProps, GetStaticPaths } from 'next';
import { useRouter } from 'next/router';
import { gql } from '@apollo/client';
import { saleorClient } from '@/lib/saleor/client';
const GET_PRODUCT_BY_SLUG = gql`
query GetProductBySlug($slug: String!) {
product(slug: $slug, channel: "default-channel") {
id
name
slug
description
translation(languageCode: EN) {
name
slug
description
}
}
}
`;
export const getStaticPaths: GetStaticPaths = async () => {
// Fetch ALL product slugs (both Serbian and English)
const { data } = await saleorClient.query({
query: gql`
query GetAllProductSlugs {
products(first: 100, channel: "default-channel") {
edges {
node {
slug # Serbian slug
translation(languageCode: EN) {
slug # English slug
}
}
}
}
}
`,
});
const paths = [];
data.products.edges.forEach(({ node }: any) => {
// Serbian slug
paths.push({ params: { slug: node.slug } });
// English slug (if exists)
if (node.translation?.slug) {
paths.push({ params: { slug: node.translation.slug } });
}
});
return {
paths,
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const slug = params?.slug as string;
// Try to fetch product by this slug
const { data } = await saleorClient.query({
query: GET_PRODUCT_BY_SLUG,
variables: { slug },
});
if (!data.product) {
return { notFound: true };
}
// Determine language based on which slug matched
const isEnglishSlug = slug === data.product.translation?.slug;
const locale = isEnglishSlug ? 'en' : 'sr';
return {
props: {
product: data.product,
currentLocale: locale,
isEnglishSlug,
},
};
};
export default function ProductPage({ product, currentLocale, isEnglishSlug }: any) {
const router = useRouter();
// Use translation if viewing English slug
const displayData = isEnglishSlug && product.translation
? product.translation
: product;
// URLs for language switcher
const serbianUrl = `/products/${product.slug}`;
const englishUrl = product.translation?.slug
? `/products/${product.translation.slug}`
: serbianUrl;
return (
<>
<Head>
<title>{displayData.name}</title>
{/* Canonical URL - Serbian version */}
<link rel="canonical" href={`https://dev.manoonoils.com${serbianUrl}`} />
{/* Alternate languages */}
<link rel="alternate" hrefLang="sr" href={`https://dev.manoonoils.com${serbianUrl}`} />
<link rel="alternate" hrefLang="en" href={`https://dev.manoonoils.com${englishUrl}`} />
</Head>
<article>
<h1>{displayData.name}</h1>
<div dangerouslySetInnerHTML={{ __html: displayData.description }} />
{/* Language Switcher */}
<div className="language-switcher">
<a href={serbianUrl} className={currentLocale === 'sr' ? 'active' : ''}>
🇷🇸 Srpski
</a>
<a href={englishUrl} className={currentLocale === 'en' ? 'active' : ''}>
🇬🇧 English
</a>
</div>
</article>
</>
);
}
```
### Alternative: Cookie-Based Language Detection
If you want automatic language detection without URL prefix:
```typescript
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only handle /products/ routes
if (!pathname.startsWith('/products/')) {
return NextResponse.next();
}
// Get language preference from cookie or header
const locale = request.cookies.get('NEXT_LOCALE')?.value ||
request.headers.get('accept-language')?.split(',')[0]?.slice(0, 2) ||
'sr';
// Store locale in cookie for subsequent requests
const response = NextResponse.next();
response.cookies.set('NEXT_LOCALE', locale);
return response;
}
export const config = {
matcher: ['/products/:path*'],
};
```
## Option 1: Language Prefix URLs
```
/sr/product/organsko-maslinovo-ulje-500ml/ ← Serbian
/en/product/organic-olive-oil-500ml/ ← English
```
**Storefront fetches correct translation:**
```graphql
query GetProduct($slug: String!, $locale: LanguageCodeEnum!) {
product(slug: $slug, channel: "default-channel") {
name
description
translation(languageCode: $locale) {
name
description
}
}
}
```
## Database Schema
### Core Product Tables
```
product_product ← Main product (Serbian default)
- id, name, slug, description
- product_type_id, category_id
- seo_title, seo_description
product_producttranslation ← English translation
- product_id, language_code
- name, slug, description
product_productvariant ← SKUs
- id, product_id, sku, name
product_productvariantchannellisting ← Pricing
- variant_id, channel_id
- price_amount, currency
warehouse_stock ← Inventory
- product_variant_id, quantity
```
## Migration SQL Script
```sql
-- 1. Create temp table for WooCommerce export
CREATE TEMP TABLE temp_woocommerce_import (
wc_id INTEGER,
sku VARCHAR(255),
name_sr VARCHAR(250),
slug VARCHAR(255),
description_sr TEXT,
description_plain_sr TEXT,
price NUMERIC(20,3),
name_en VARCHAR(250),
description_en TEXT,
slug_en VARCHAR(255)
);
-- 2. Ensure default product type exists
INSERT INTO product_producttype (name, slug, has_variants, is_shipping_required, weight, is_digital, kind)
VALUES ('Default Type', 'default-type', false, true, 0, false, 'NORMAL')
ON CONFLICT (slug) DO NOTHING;
-- 3. Insert Serbian products (preserve WooCommerce slugs)
INSERT INTO product_product (
name, slug, description, description_plaintext,
product_type_id, seo_title, seo_description,
metadata, private_metadata, search_document, search_index_dirty,
weight, created_at, updated_at
)
SELECT
name_sr,
slug, -- PRESERVE WooCommerce slug!
jsonb_build_object('blocks', jsonb_build_array(
jsonb_build_object('type', 'paragraph', 'data',
jsonb_build_object('text', description_sr))
)),
COALESCE(description_plain_sr, LEFT(description_sr, 300)),
1, name_sr,
LEFT(COALESCE(description_plain_sr, description_sr), 300),
'{}', '{}', '', true,
0, NOW(), NOW()
FROM temp_woocommerce_import
ON CONFLICT (slug) DO NOTHING;
-- 4. Create variants (simple products = 1 variant each)
INSERT INTO product_productvariant (name, sku, product_id, track_inventory, weight, is_preorder, created_at, updated_at)
SELECT
'Default', t.sku, p.id, true, 0, false, NOW(), NOW()
FROM temp_woocommerce_import t
JOIN product_product p ON p.slug = t.slug
ON CONFLICT (sku) DO NOTHING;
-- 5. Update default_variant
UPDATE product_product p
SET default_variant_id = v.id
FROM product_productvariant v
WHERE v.product_id = p.id;
-- 6. Create channel listings (publish all products)
INSERT INTO product_productchannellisting (
published_at, is_published, channel_id, product_id,
currency, visible_in_listings, discounted_price_dirty
)
SELECT NOW(), true, 1, p.id, 'USD', true, false
FROM product_product p
WHERE p.id NOT IN (SELECT product_id FROM product_productchannellisting)
ON CONFLICT (product_id, channel_id) DO NOTHING;
-- 7. Add pricing from WooCommerce
INSERT INTO product_productvariantchannellisting (
currency, price_amount, channel_id, variant_id
)
SELECT 'USD', t.price, 1, v.id
FROM temp_woocommerce_import t
JOIN product_product p ON p.slug = t.slug
JOIN product_productvariant v ON v.product_id = p.id
ON CONFLICT (variant_id, channel_id) DO UPDATE SET price_amount = EXCLUDED.price_amount;
-- 8. Add English translations with DIFFERENT slugs
-- English slug is stored in translation table and will be used for /products/english-slug/ URLs
INSERT INTO product_producttranslation (
product_id, language_code, name, slug, description, seo_title, seo_description
)
SELECT
p.id,
'en',
t.name_en,
-- IMPORTANT: Use different English slug for /products/english-slug/ URL
COALESCE(NULLIF(t.slug_en, ''),
LOWER(REGEXP_REPLACE(t.name_en, '[^a-zA-Z0-9]+', '-', 'g'))),
jsonb_build_object('blocks', jsonb_build_array(
jsonb_build_object('type', 'paragraph', 'data',
jsonb_build_object('text', COALESCE(t.description_en, t.description_sr)))
)),
t.name_en,
LEFT(COALESCE(t.description_en, t.description_sr), 300)
FROM temp_woocommerce_import t
JOIN product_product p ON p.slug = t.slug
WHERE t.name_en IS NOT NULL AND t.name_en != ''
ON CONFLICT (language_code, product_id) DO UPDATE SET
name = EXCLUDED.name,
slug = EXCLUDED.slug,
description = EXCLUDED.description;
-- Verify both slugs exist
SELECT
p.slug as serbian_slug,
pt.slug as english_slug
FROM product_product p
LEFT JOIN product_producttranslation pt ON pt.product_id = p.id AND pt.language_code = 'en'
LIMIT 5;
-- 9. Trigger search reindex
UPDATE product_product SET search_index_dirty = true;
-- 10. Clean up
DROP TABLE temp_woocommerce_import;
```
## GraphQL Query Example
```graphql
query ProductDetail($slug: String!, $locale: LanguageCodeEnum!) {
product(slug: $slug, channel: "default-channel") {
id
name # Serbian name (default)
slug # Serbian slug
description # Serbian description
translation(languageCode: $locale) {
name # English name
slug # English slug
description # English description
}
variants {
id
name
sku
translation(languageCode: $locale) {
name
}
channelListings {
price {
amount
currency
}
}
}
}
}
```
## Next.js Storefront Example
```typescript
// pages/product/[slug].tsx
export const getStaticProps: GetStaticProps = async ({ params, locale }) => {
const { data } = await saleorClient.query({
query: GET_PRODUCT,
variables: {
slug: params?.slug,
locale: locale?.toUpperCase() || 'SR'
},
});
return {
props: {
product: data.product,
},
};
};
export default function ProductPage({ product }) {
const router = useRouter();
const displayName = product.translation?.name || product.name;
return (
<>
<Head>
<link rel="canonical" href={`https://dev.manoonoils.com/product/${product.slug}`} />
<link rel="alternate" hrefLang="sr" href={`/product/${product.slug}`} />
<link rel="alternate" hrefLang="en" href={`/en/product/${product.translation?.slug || product.slug}`} />
</Head>
<h1>{displayName}</h1>
</>
);
}
```
## Summary
| Requirement | Solution |
|-------------|----------|
| URL structure `/products/` | Next.js pages directory: `pages/products/[slug].tsx` |
| Different slugs per language | Store English slug in `product_producttranslation.slug` |
| No language code in URL | Both `/products/serbian-slug/` and `/products/english-slug/` work independently |
| Language switching | User clicks link to go from Serbian URL to English URL |
| SEO preservation | Canonical URL = Serbian, hreflang tags for both versions |
### URL Examples
| Language | Product Name | URL |
|----------|-------------|-----|
| Serbian | Organsko Maslinovo Ulje | `/products/organsko-maslinovo-ulje-500ml/` |
| English | Organic Olive Oil | `/products/organic-olive-oil-500ml/` |
### Database Values
```sql
-- product_product (Serbian - default)
slug: 'organsko-maslinovo-ulje-500ml'
name: 'Organsko Maslinovo Ulje 500ml'
-- product_producttranslation (English)
language_code: 'en'
slug: 'organic-olive-oil-500ml' Different slug!
name: 'Organic Olive Oil 500ml'
```
See full documentation with code examples in this file.

View File

@@ -0,0 +1,304 @@
# Email Reactivation Campaign Strategy
## Post-Migration Marketing Plan
### Customer Segments (4,886 Total)
| Segment | Count | Definition | Strategy |
|---------|-------|------------|----------|
| **VIP_CUSTOMER** | ~200 | 3+ completed orders | Loyalty rewards, early access, referral program |
| **ACTIVE_CUSTOMER** | ~972 | 1-2 completed orders | Cross-sell, subscription, reviews |
| **CART_ABANDONER** | ~1,086 | Pending/processing orders | Recovery sequence, discount incentive |
| **PROSPECT** | ~2,628 | Registered, never ordered | Welcome series, education, first-order discount |
---
## Campaign 1: Cart Abandoner Recovery
**Target:** 1,086 users with pending/processing orders
### Email Sequence
#### Email 1: Immediate (0 hours)
```
Subject: Zaboravili ste nešto u korpi 👀
Pozdrav [First Name],
Primijetili smo da ste ostavili artikle u korpi za kupovinu:
[Product Name] - [Price] RSD
Poštarina je BESPLATNA za narudžbine preko 3.000 RSD.
[DOVRŠI KUPOVINU]
Pitanja? Odgovorite na ovaj email.
---
Team Manoon
```
#### Email 2: 24 hours
```
Subject: Još uvijek čekamo vas 🛒
[First Name],
Vaša korpa još uvijek čeka:
[Product Image]
[Product Name]
Ostalo je još samo par komada na zalihi.
[DOVRŠI KUPOVINU]
```
#### Email 3: 72 hours (Final)
```
Subject: Posebna ponuda samo za vas 🎁
[First Name],
Vidimo da ste zainteresovani za naše proizvode.
Koristite kod ZAVRSI10 za 10% popusta na vašu narudžbinu.
Važi naredna 24 sata.
[DOVRŠI KUPOVINU]
```
---
## Campaign 2: Prospect Activation
**Target:** 2,628 registered users who never ordered
### Email Sequence
#### Email 1: Welcome (Day 0)
```
Subject: Dobrodošli u Manoon porodicu ✨
Zdravo [First Name],
Hvala što ste se prijavili! Očekuje vas:
✓ 100% prirodna kozmetika
✓ Vidljivi rezultati za 30 dana
✓ Besplatna dostava preko 3.000 RSD
Kao dobrodošlicu, imate 15% popusta na prvu kupovinu.
Kod: DOBRODOSLI15
[PREGLEDAJ PROIZVODE]
---
Team Manoon
```
#### Email 2: Education (Day 3)
```
Subject: Kako izgleda 30-dnevna transformacija?
[First Name],
Pogledajte neverovatne rezultate naših kupaca:
[Before/After Image Gallery]
💬 "Nakon 3 nedelje primetila sam ogromnu razliku"
- Marija, Beograd
[POGLEDAJ PRIČE]
```
#### Email 3: Social Proof (Day 7)
```
Subject: Više od 1.000 zadovoljnih kupaca
[First Name],
Naši kupci vole:
⭐⭐⭐⭐⭐ "Najbolji serum koji sam koristio"
⭐⭐⭐⭐⭐ "Kosa mi je znatno jača"
⭐⭐⭐⭐⭐ "Konačno prirodni proizvodi koji rade"
[ČITAJ UTISKE]
```
#### Email 4: Urgency (Day 14)
```
Subject: Poslednja prilika: 15% popusta
[First Name],
Vaš kod DOBRODOSLI15 ističe za 48 sati.
Ne propustite priliku da isprobate našu prirodnu kozmetiku sa popustom.
[ISKORISTI POPUST]
```
---
## Campaign 3: Win-Back (Inactive Customers)
**Target:** Active customers who haven't ordered in 6+ months
### Email Sequence
#### Email 1: "We Miss You" (Day 0)
```
Subject: Nedostajete nam, [First Name] 💚
Zdravo [First Name],
Primijetili smo da dugo niste naručivali.
Imamo novo za vas:
🆕 Novi proizvodi
🎁 Specijalne ponude
📦 Brža dostava
Želite da vidite šta je novo?
[VIDI NOVITETE]
```
#### Email 2: Incentive (Day 7)
```
Subject: Specijalna ponuda za povratak
[First Name],
Kao znak zahvalnosti za vašu raniju podršku:
20% popusta na sledeću kupovinu
Kod: POVRATAK20
Važi do: [Date]
[KUPI SADA]
```
---
## Campaign 4: VIP Customer Rewards
**Target:** 200 customers with 3+ orders
### Exclusive Perks
1. **Early Access** - New products 48 hours before public
2. **Birthday Gift** - Free product on birthday
3. **Referral Program** - Give 15%, Get 15%
4. **Exclusive Content** - Behind the scenes, beauty tips
#### Email Template
```
Subject: Vi ste naš VIP kupac 🌟
Draga [First Name],
Zahvaljujući vašoj podršci ([X] kupovina), postali ste deo našeg VIP kluba.
Vaše privilegije:
✨ Rani pristup novim proizvodima
🎁 Rođendanski poklon
💰 20% popust na SVAKU kupovinu
👥 Poklonite 15% prijateljima, zaradite 15%
[VIDI VIP PONUDE]
Hvala vam što ste deo Manoon priče.
---
Team Manoon
```
---
## Technical Implementation
### Saleor Setup for Segmentation
```python
# Add custom metadata to users during migration
metadata = {
"segment": "CART_ABANDONER", # or VIP_CUSTOMER, ACTIVE_CUSTOMER, PROSPECT
"wp_user_id": 12345,
"order_count": 2,
"completed_orders": 1,
"total_spent": 15000.00,
"first_order_date": "2023-01-15",
"registration_date": "2022-11-20"
}
```
### Integration Options
#### Option 1: Saleor Webhooks + n8n + MailerLite/Mailchimp
```
Saleor User Created → n8n → Add to Email List → Trigger Sequence
```
#### Option 2: Direct SQL Queries for Export
```sql
-- Export PROSPECTS for welcome campaign
SELECT email, first_name, metadata->>'registration_date' as date
FROM account_user
WHERE metadata->>'segment' = 'PROSPECT';
-- Export CART_ABANDONERS
SELECT email, first_name, metadata->>'order_count' as orders
FROM account_user
WHERE metadata->>'segment' = 'CART_ABANDONER';
```
#### Option 3: Mautic (already installed on your cluster)
- Import segmented lists
- Create campaigns per segment
- Track opens, clicks, conversions
---
## Campaign Calendar
| Week | Campaign | Target | Emails |
|------|----------|--------|--------|
| 1 | Cart Recovery | 1,086 abandoners | 3 emails |
| 2 | Prospect Welcome | 2,628 prospects | 4 emails |
| 3 | Win-Back | Inactive customers | 2 emails |
| 4 | VIP Launch | 200 VIPs | 1 email + setup |
| Ongoing | Nurture | All segments | Monthly newsletter |
---
## Success Metrics
| Metric | Target |
|--------|--------|
| Cart recovery rate | 10-15% |
| Prospect conversion | 5-8% |
| Win-back rate | 3-5% |
| VIP referral rate | 20% |
| Overall email open rate | >25% |
| Click-through rate | >3% |
---
## Next Steps
1. **Migrate data** using `migrate_all_users_and_orders.py`
2. **Set up email platform** (MailerLite, Mailchimp, or Mautic)
3. **Create email templates** in your chosen platform
4. **Import segmented lists** from Saleor
5. **Launch campaigns** in sequence
6. **Track results** and optimize

View File

@@ -0,0 +1,852 @@
#!/usr/bin/env python3
"""
WooCommerce COMPLETE User & Order Migration to Saleor
=======================================================
ASSUMPTION: For COD stores, ALL orders = fulfilled (paid) EXCEPT cancelled
In early WooCommerce stores, order status tracking was inconsistent, but
if an order was not cancelled, the COD payment was collected.
This script treats:
- wc-completed, wc-pending, wc-processing, wc-on-hold = FULFILLED (PAID)
- wc-cancelled, wc-refunded, wc-failed = CANCELLED (NOT PAID)
Migrates ALL WordPress users (not just customers with orders):
- Customers with orders (1,172) → Active customers
- Users without orders (3,714) → Leads/Prospects for reactivation
Segmentation Strategy:
- VIP: 4+ orders
- Repeat: 2-3 orders
- One-time: 1 order
- Prospect: 0 orders
Use cases after migration:
1. Email reactivation campaigns for prospects
2. Win-back campaigns for inactive customers
3. Welcome series for new registrations
4. Segmented marketing based on activity
"""
import os
import sys
import json
import uuid
import argparse
from datetime import datetime
from typing import Dict, List, Optional, Set
from dataclasses import dataclass, field
from collections import defaultdict
import psycopg2
WP_DB_CONFIG = {
'host': os.getenv('WP_DB_HOST', 'localhost'),
'port': int(os.getenv('WP_DB_PORT', 3306)),
'user': os.getenv('WP_DB_USER', 'wordpress'),
'password': os.getenv('WP_DB_PASSWORD', ''),
'database': os.getenv('WP_DB_NAME', 'wordpress'),
}
SALEOR_DB_CONFIG = {
'host': os.getenv('SALEOR_DB_HOST', 'localhost'),
'port': int(os.getenv('SALEOR_DB_PORT', 5432)),
'user': os.getenv('SALEOR_DB_USER', 'saleor'),
'password': os.getenv('SALEOR_DB_PASSWORD', ''),
'database': os.getenv('SALEOR_DB_NAME', 'saleor'),
}
# COD Status Mapping - SIMPLIFIED
# ALL orders are treated as FULFILLED (paid) EXCEPT cancelled
# For COD stores: if not cancelled, payment was collected
ORDER_STATUS_MAP = {
'wc-pending': 'FULFILLED', # All treated as completed
'wc-processing': 'FULFILLED',
'wc-on-hold': 'FULFILLED',
'wc-completed': 'FULFILLED',
'wc-cancelled': 'CANCELED', # Only cancelled = not paid
'wc-refunded': 'CANCELED', # Refunded = not paid
'wc-failed': 'CANCELED',
}
# Statuses that indicate payment was collected (for COD)
# Everything EXCEPT cancelled/refunded/failed
PAID_STATUSES = ['wc-completed', 'wc-pending', 'wc-processing', 'wc-on-hold']
@dataclass
class WPUser:
"""WordPress user with activity tracking"""
wp_user_id: int
email: str
first_name: str
last_name: str
date_registered: datetime
phone: Optional[str] = None
billing_address: Optional[Dict] = None
shipping_address: Optional[Dict] = None
# Activity tracking - UPDATED to count pending/processing as paid
order_count: int = 0
paid_orders: int = 0 # completed + pending + processing
cancelled_orders: int = 0
total_spent: float = 0.0
last_order_date: Optional[datetime] = None
first_order_date: Optional[datetime] = None
# Segmentation
@property
def segment(self) -> str:
"""Determine customer segment for marketing"""
# Simplified: all non-cancelled orders = paid
if self.paid_orders >= 4:
return "VIP_CUSTOMER"
elif self.paid_orders >= 2:
return "REPEAT_CUSTOMER"
elif self.paid_orders == 1:
return "ONE_TIME"
else:
return "PROSPECT"
@property
def ltv(self) -> float:
"""Lifetime value in RSD"""
return self.total_spent
@dataclass
class CODOrder:
"""COD Order - updated to mark pending/processing as paid"""
wc_order_id: int
order_number: str
status: str
date_created: datetime
date_modified: datetime
customer_email: str
customer_first_name: str
customer_last_name: str
customer_phone: Optional[str]
total: float # in cents
subtotal: float
tax: float
shipping: float
currency: str
billing_address: Dict
shipping_address: Dict
customer_note: str
shipping_method: str
items: List[Dict]
is_paid: bool # True for completed, pending, processing
wp_user_id: Optional[int] = None # Link to WordPress user if registered
class CompleteExporter:
"""Export ALL WordPress users and orders"""
def __init__(self, wp_db_config: Dict):
try:
import pymysql
self.conn = pymysql.connect(
host=wp_db_config['host'],
port=wp_db_config['port'],
user=wp_db_config['user'],
password=wp_db_config['password'],
database=wp_db_config['database'],
cursorclass=pymysql.cursors.DictCursor
)
except ImportError:
raise ImportError("pymysql required")
def get_all_users_with_activity(self) -> List[WPUser]:
"""Get ALL WordPress users with their order activity - UPDATED"""
query = """
SELECT
u.ID as wp_user_id,
u.user_email as email,
u.user_registered as date_registered,
um_first.meta_value as first_name,
um_last.meta_value as last_name,
um_phone.meta_value as phone,
-- Order activity - count pending/processing as paid
COUNT(DISTINCT p.ID) as order_count,
COUNT(DISTINCT CASE WHEN p.post_status IN ('wc-completed', 'wc-pending', 'wc-processing') THEN p.ID END) as paid_orders,
COUNT(DISTINCT CASE WHEN p.post_status = 'wc-cancelled' THEN p.ID END) as cancelled_orders,
SUM(CASE WHEN p.post_status IN ('wc-completed', 'wc-pending', 'wc-processing') THEN CAST(COALESCE(meta_total.meta_value, 0) AS DECIMAL(12,2)) ELSE 0 END) as total_spent,
MIN(p.post_date) as first_order_date,
MAX(p.post_date) as last_order_date
FROM wp_users u
LEFT JOIN wp_usermeta um_first ON u.ID = um_first.user_id AND um_first.meta_key = 'first_name'
LEFT JOIN wp_usermeta um_last ON u.ID = um_last.user_id AND um_last.meta_key = 'last_name'
LEFT JOIN wp_usermeta um_phone ON u.ID = um_phone.user_id AND um_phone.meta_key = 'billing_phone'
LEFT JOIN wp_postmeta pm ON pm.meta_key = '_customer_user' AND pm.meta_value = u.ID
LEFT JOIN wp_posts p ON p.ID = pm.post_id AND p.post_type = 'shop_order'
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id AND meta_total.meta_key = '_order_total'
GROUP BY u.ID, u.user_email, u.user_registered, um_first.meta_value, um_last.meta_value, um_phone.meta_value
ORDER BY u.ID
"""
with self.conn.cursor() as cursor:
cursor.execute(query)
rows = cursor.fetchall()
users = []
for row in rows:
# Get address from most recent order or usermeta
address = self._get_user_address(row['wp_user_id'])
user = WPUser(
wp_user_id=row['wp_user_id'],
email=row['email'],
first_name=row['first_name'] or '',
last_name=row['last_name'] or '',
date_registered=row['date_registered'],
phone=row['phone'],
billing_address=address,
shipping_address=address,
order_count=row['order_count'] or 0,
paid_orders=row['paid_orders'] or 0,
cancelled_orders=row['cancelled_orders'] or 0,
total_spent=float(row['total_spent'] or 0),
first_order_date=row['first_order_date'],
last_order_date=row['last_order_date']
)
users.append(user)
return users
def get_orders(self, limit: Optional[int] = None,
status: Optional[str] = None) -> List[CODOrder]:
"""Fetch orders with user linking"""
query = """
SELECT
p.ID as wc_order_id,
p.post_date as date_created,
p.post_modified as date_modified,
p.post_status as status,
meta_total.meta_value as total,
meta_subtotal.meta_value as subtotal,
meta_tax.meta_value as tax,
meta_shipping.meta_value as shipping,
meta_currency.meta_value as currency,
meta_email.meta_value as customer_email,
meta_first.meta_value as customer_first_name,
meta_last.meta_value as customer_last_name,
meta_phone.meta_value as customer_phone,
meta_shipping_method.meta_value as shipping_method,
meta_customer_note.meta_value as customer_note,
meta_customer_id.meta_value as wp_user_id
FROM wp_posts p
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id AND meta_total.meta_key = '_order_total'
LEFT JOIN wp_postmeta meta_subtotal ON p.ID = meta_subtotal.post_id AND meta_subtotal.meta_key = '_order_subtotal'
LEFT JOIN wp_postmeta meta_tax ON p.ID = meta_tax.post_id AND meta_tax.meta_key = '_order_tax'
LEFT JOIN wp_postmeta meta_shipping ON p.ID = meta_shipping.post_id AND meta_shipping.meta_key = '_order_shipping'
LEFT JOIN wp_postmeta meta_currency ON p.ID = meta_currency.post_id AND meta_currency.meta_key = '_order_currency'
LEFT JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id AND meta_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta meta_first ON p.ID = meta_first.post_id AND meta_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta meta_last ON p.ID = meta_last.post_id AND meta_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta meta_phone ON p.ID = meta_phone.post_id AND meta_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta meta_shipping_method ON p.ID = meta_shipping_method.post_id AND meta_shipping_method.meta_key = '_shipping_method'
LEFT JOIN wp_postmeta meta_customer_note ON p.ID = meta_customer_note.post_id AND meta_customer_note.meta_key = 'customer_note'
LEFT JOIN wp_postmeta meta_customer_id ON p.ID = meta_customer_id.post_id AND meta_customer_id.meta_key = '_customer_user'
WHERE p.post_type = 'shop_order'
"""
params = []
if status:
# Handle multiple statuses
statuses = status.split(',')
if len(statuses) == 1:
query += " AND p.post_status = %s"
params.append(status)
else:
placeholders = ','.join(['%s'] * len(statuses))
query += f" AND p.post_status IN ({placeholders})"
params.extend(statuses)
query += " ORDER BY p.post_date DESC"
if limit:
query += f" LIMIT {limit}"
with self.conn.cursor() as cursor:
cursor.execute(query, params)
rows = cursor.fetchall()
orders = []
for row in rows:
billing = self._get_address(row['wc_order_id'], 'billing')
shipping = self._get_address(row['wc_order_id'], 'shipping')
items = self._get_items(row['wc_order_id'])
# UPDATED: Treat pending/processing as paid
is_paid = row['status'] in PAID_STATUSES
wp_user_id = int(row['wp_user_id']) if row['wp_user_id'] else None
order = CODOrder(
wc_order_id=row['wc_order_id'],
order_number=f"WC-{row['wc_order_id']}",
status=row['status'],
date_created=row['date_created'],
date_modified=row['date_modified'],
customer_email=row['customer_email'] or '',
customer_first_name=row['customer_first_name'] or '',
customer_last_name=row['customer_last_name'] or '',
customer_phone=row['customer_phone'],
total=float(row['total'] or 0) * 100,
subtotal=float(row['subtotal'] or 0) * 100,
tax=float(row['tax'] or 0) * 100,
shipping=float(row['shipping'] or 0) * 100,
currency=row['currency'] or 'RSD',
billing_address=billing or self._empty_address(),
shipping_address=shipping or billing or self._empty_address(),
shipping_method=row['shipping_method'] or 'Cash on Delivery',
customer_note=row['customer_note'] or '',
items=items,
is_paid=is_paid,
wp_user_id=wp_user_id
)
orders.append(order)
return orders
def _get_user_address(self, user_id: int) -> Optional[Dict]:
"""Get address from user's most recent order or usermeta"""
# Try to get from most recent order first
query = """
SELECT
MAX(CASE WHEN pm.meta_key = '_billing_first_name' THEN pm.meta_value END) as first_name,
MAX(CASE WHEN pm.meta_key = '_billing_last_name' THEN pm.meta_value END) as last_name,
MAX(CASE WHEN pm.meta_key = '_billing_company' THEN pm.meta_value END) as company,
MAX(CASE WHEN pm.meta_key = '_billing_address_1' THEN pm.meta_value END) as address_1,
MAX(CASE WHEN pm.meta_key = '_billing_address_2' THEN pm.meta_value END) as address_2,
MAX(CASE WHEN pm.meta_key = '_billing_city' THEN pm.meta_value END) as city,
MAX(CASE WHEN pm.meta_key = '_billing_postcode' THEN pm.meta_value END) as postcode,
MAX(CASE WHEN pm.meta_key = '_billing_country' THEN pm.meta_value END) as country,
MAX(CASE WHEN pm.meta_key = '_billing_phone' THEN pm.meta_value END) as phone
FROM wp_postmeta pm_customer
JOIN wp_posts p ON p.ID = pm_customer.post_id AND p.post_type = 'shop_order'
JOIN wp_postmeta pm ON pm.post_id = p.ID
WHERE pm_customer.meta_key = '_customer_user' AND pm_customer.meta_value = %s
ORDER BY p.post_date DESC
LIMIT 1
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (user_id,))
row = cursor.fetchone()
if row and row['first_name']:
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
# Fall back to usermeta
query = """
SELECT
MAX(CASE WHEN meta_key = 'billing_first_name' THEN meta_value END) as first_name,
MAX(CASE WHEN meta_key = 'billing_last_name' THEN meta_value END) as last_name,
MAX(CASE WHEN meta_key = 'billing_company' THEN meta_value END) as company,
MAX(CASE WHEN meta_key = 'billing_address_1' THEN meta_value END) as address_1,
MAX(CASE WHEN meta_key = 'billing_address_2' THEN meta_value END) as address_2,
MAX(CASE WHEN meta_key = 'billing_city' THEN meta_value END) as city,
MAX(CASE WHEN meta_key = 'billing_postcode' THEN meta_value END) as postcode,
MAX(CASE WHEN meta_key = 'billing_country' THEN meta_value END) as country,
MAX(CASE WHEN meta_key = 'billing_phone' THEN meta_value END) as phone
FROM wp_usermeta
WHERE user_id = %s
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (user_id,))
row = cursor.fetchone()
if row and row['first_name']:
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
return None
def _get_address(self, order_id: int, prefix: str) -> Optional[Dict]:
query = f"""
SELECT
MAX(CASE WHEN meta_key = '_{prefix}_first_name' THEN meta_value END) as first_name,
MAX(CASE WHEN meta_key = '_{prefix}_last_name' THEN meta_value END) as last_name,
MAX(CASE WHEN meta_key = '_{prefix}_company' THEN meta_value END) as company,
MAX(CASE WHEN meta_key = '_{prefix}_address_1' THEN meta_value END) as address_1,
MAX(CASE WHEN meta_key = '_{prefix}_address_2' THEN meta_value END) as address_2,
MAX(CASE WHEN meta_key = '_{prefix}_city' THEN meta_value END) as city,
MAX(CASE WHEN meta_key = '_{prefix}_postcode' THEN meta_value END) as postcode,
MAX(CASE WHEN meta_key = '_{prefix}_country' THEN meta_value END) as country,
MAX(CASE WHEN meta_key = '_{prefix}_phone' THEN meta_value END) as phone
FROM wp_postmeta
WHERE post_id = %s
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
row = cursor.fetchone()
if not row or not row['first_name']:
return None
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
def _empty_address(self) -> Dict:
return {
'first_name': '', 'last_name': '', 'company_name': '',
'street_address_1': '', 'street_address_2': '',
'city': '', 'postal_code': '', 'country': 'RS', 'phone': ''
}
def _get_items(self, order_id: int) -> List[Dict]:
query = """
SELECT
oi.order_item_name as name,
meta_sku.meta_value as sku,
meta_qty.meta_value as quantity,
meta_subtotal.meta_value as subtotal,
meta_total.meta_value as total,
meta_tax.meta_value as tax
FROM wp_woocommerce_order_items oi
LEFT JOIN wp_woocommerce_order_itemmeta meta_sku ON oi.order_item_id = meta_sku.order_item_id AND meta_sku.meta_key = '_sku'
LEFT JOIN wp_woocommerce_order_itemmeta meta_qty ON oi.order_item_id = meta_qty.order_item_id AND meta_qty.meta_key = '_qty'
LEFT JOIN wp_woocommerce_order_itemmeta meta_subtotal ON oi.order_item_id = meta_subtotal.order_item_id AND meta_subtotal.meta_key = '_line_subtotal'
LEFT JOIN wp_woocommerce_order_itemmeta meta_total ON oi.order_item_id = meta_total.order_item_id AND meta_total.meta_key = '_line_total'
LEFT JOIN wp_woocommerce_order_itemmeta meta_tax ON oi.order_item_id = meta_tax.order_item_id AND meta_tax.meta_key = '_line_tax'
WHERE oi.order_id = %s AND oi.order_item_type = 'line_item'
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
rows = cursor.fetchall()
items = []
for row in rows:
qty = int(row['quantity'] or 1)
items.append({
'name': row['name'] or '',
'sku': row['sku'] or '',
'quantity': qty,
'subtotal': float(row['subtotal'] or 0) * 100,
'total': float(row['total'] or 0) * 100,
'tax': float(row['tax'] or 0) * 100,
})
return items
class CompleteImporter:
"""Import all users and orders with segmentation"""
def __init__(self, saleor_db_config: Dict):
self.conn = psycopg2.connect(
host=saleor_db_config['host'],
port=saleor_db_config['port'],
user=saleor_db_config['user'],
password=saleor_db_config['password'],
database=saleor_db_config['database']
)
self.wp_id_to_saleor_id: Dict[int, uuid.UUID] = {}
self._ensure_tables()
self._load_mappings()
def _ensure_tables(self):
"""Create mapping and segmentation tables"""
with self.conn.cursor() as cursor:
# User mapping with segmentation data - UPDATED schema
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_complete_user_mapping (
wp_user_id BIGINT PRIMARY KEY,
saleor_user_id UUID NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
segment VARCHAR(50) NOT NULL,
order_count INTEGER DEFAULT 0,
paid_orders INTEGER DEFAULT 0,
total_spent DECIMAL(12,2) DEFAULT 0,
first_order_date TIMESTAMP,
last_order_date TIMESTAMP,
date_registered TIMESTAMP,
migrated_at TIMESTAMP DEFAULT NOW()
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_order_mapping (
wc_order_id BIGINT PRIMARY KEY,
saleor_order_id UUID NOT NULL,
wp_user_id BIGINT,
customer_email VARCHAR(255),
migrated_at TIMESTAMP DEFAULT NOW()
);
""")
self.conn.commit()
def _load_mappings(self):
with self.conn.cursor() as cursor:
cursor.execute("SELECT wp_user_id, saleor_user_id FROM wc_complete_user_mapping")
for row in cursor.fetchall():
self.wp_id_to_saleor_id[row[0]] = row[1]
def get_channel_id(self) -> uuid.UUID:
with self.conn.cursor() as cursor:
cursor.execute("SELECT id FROM channel_channel WHERE slug = 'default-channel' LIMIT 1")
return cursor.fetchone()[0]
def import_user(self, user: WPUser, dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import a WordPress user with segmentation metadata"""
if user.wp_user_id in self.wp_id_to_saleor_id:
return self.wp_id_to_saleor_id[user.wp_user_id]
user_id = uuid.uuid4()
if dry_run:
print(f" [{user.segment}] Would create: {user.email} (Paid orders: {user.paid_orders}, LTV: {user.ltv:.0f} RSD)")
return user_id
with self.conn.cursor() as cursor:
# Create user with segmentation metadata
metadata = {
'wp_user_id': user.wp_user_id,
'segment': user.segment,
'order_count': user.order_count,
'paid_orders': user.paid_orders,
'total_spent': user.total_spent,
'imported_from': 'woocommerce',
'registration_date': user.date_registered.isoformat() if user.date_registered else None
}
cursor.execute("""
INSERT INTO account_user (id, email, first_name, last_name,
is_staff, is_active, date_joined, password, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
user_id, user.email, user.first_name, user.last_name,
False, True, user.date_registered, '!', json.dumps(metadata)
))
# Create address if available
if user.billing_address:
addr_id = uuid.uuid4()
cursor.execute("""
INSERT INTO account_address (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
addr_id, user.billing_address['first_name'], user.billing_address['last_name'],
user.billing_address['company_name'], user.billing_address['street_address_1'],
user.billing_address['street_address_2'], user.billing_address['city'],
user.billing_address['postal_code'], user.billing_address['country'],
user.phone or ''
))
cursor.execute("""
INSERT INTO account_user_addresses (user_id, address_id)
VALUES (%s, %s)
""", (user_id, addr_id))
cursor.execute("""
UPDATE account_user
SET default_billing_address_id = %s, default_shipping_address_id = %s
WHERE id = %s
""", (addr_id, addr_id, user_id))
# Record mapping with segmentation
cursor.execute("""
INSERT INTO wc_complete_user_mapping
(wp_user_id, saleor_user_id, email, segment, order_count,
paid_orders, total_spent, first_order_date, last_order_date, date_registered)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
user.wp_user_id, user_id, user.email, user.segment,
user.order_count, user.paid_orders, user.total_spent,
user.first_order_date, user.last_order_date, user.date_registered
))
self.conn.commit()
self.wp_id_to_saleor_id[user.wp_user_id] = user_id
print(f" [{user.segment}] Created: {user.email} (Paid: {user.paid_orders}, LTV: {user.ltv:.0f} RSD)")
return user_id
def import_order(self, order: CODOrder, dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import an order linked to the user - UPDATED for COD assumption"""
with self.conn.cursor() as cursor:
cursor.execute("SELECT saleor_order_id FROM wc_order_mapping WHERE wc_order_id = %s",
(order.wc_order_id,))
if cursor.fetchone():
return None
order_id = uuid.uuid4()
channel_id = self.get_channel_id()
saleor_status = ORDER_STATUS_MAP.get(order.status, 'UNCONFIRMED')
# Get user ID if this was a registered user
user_id = None
if order.wp_user_id and order.wp_user_id in self.wp_id_to_saleor_id:
user_id = self.wp_id_to_saleor_id[order.wp_user_id]
if dry_run:
paid_marker = "" if order.is_paid else ""
print(f" Order {order.order_number} {paid_marker} (Status: {order.status})")
return order_id
with self.conn.cursor() as cursor:
# Create billing address
bill_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_orderbillingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (bill_id, order.billing_address['first_name'], order.billing_address['last_name'],
order.billing_address['company_name'], order.billing_address['street_address_1'],
order.billing_address['street_address_2'], order.billing_address['city'],
order.billing_address['postal_code'], order.billing_address['country'],
order.billing_address['phone']))
# Create shipping address
ship_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_ordershippingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (ship_id, order.shipping_address['first_name'], order.shipping_address['last_name'],
order.shipping_address['company_name'], order.shipping_address['street_address_1'],
order.shipping_address['street_address_2'], order.shipping_address['city'],
order.shipping_address['postal_code'], order.shipping_address['country'],
order.shipping_address['phone']))
# Insert order
cursor.execute("""
INSERT INTO order_order (
id, created_at, updated_at, status, user_email, user_id, currency,
total_gross_amount, total_net_amount,
shipping_price_gross_amount, shipping_price_net_amount,
shipping_method_name, channel_id,
billing_address_id, shipping_address_id,
billing_address, shipping_address,
metadata, origin, should_refresh_prices,
tax_exemption, discount_amount, display_gross_prices, customer_note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
order_id, order.date_created, order.date_modified, saleor_status,
order.customer_email, user_id, order.currency,
order.total, order.subtotal, order.shipping, order.shipping,
order.shipping_method, channel_id, bill_id, ship_id,
json.dumps(order.billing_address), json.dumps(order.shipping_address),
json.dumps({
'woo_order_id': order.wc_order_id,
'cod_payment': True,
'payment_collected': order.is_paid,
'original_status': order.status,
'wp_user_id': order.wp_user_id
}),
'BULK_CREATE', False, False, 0.0, True, order.customer_note
))
# Insert order lines
for item in order.items:
cursor.execute("SELECT id FROM product_productvariant WHERE sku = %s",
(item['sku'],))
variant = cursor.fetchone()
variant_id = variant[0] if variant else None
qty = item['quantity']
unit_net = item['subtotal'] / qty if qty else 0
unit_gross = (item['subtotal'] + item['tax']) / qty if qty else 0
cursor.execute("""
INSERT INTO order_orderline (id, order_id, product_name, product_sku,
quantity, currency, unit_price_net_amount, unit_price_gross_amount,
total_price_net_amount, total_price_gross_amount,
unit_discount_amount, unit_discount_type, tax_rate,
is_shipping_required, variant_id, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (uuid.uuid4(), order_id, item['name'], item['sku'], qty,
order.currency, unit_net, unit_gross, item['subtotal'],
item['subtotal'] + item['tax'], 0.0, 'FIXED', '0.15',
True, variant_id, order.date_created))
# UPDATED: Create payment record for ALL paid orders (completed, pending, processing)
if order.is_paid:
cursor.execute("""
INSERT INTO payment_payment (
id, gateway, is_active, to_confirm, order_id, total,
captured_amount, currency, charge_status, partial, modified_at, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (uuid.uuid4(),
'mirumee.payments.dummy', # Dummy gateway for COD
False, # Not active (completed)
False,
order_id,
order.total,
order.total, # Fully captured (COD collected)
order.currency,
'FULLY_CHARGED',
False,
order.date_modified,
order.date_created))
# Record mapping
cursor.execute("""
INSERT INTO wc_order_mapping (wc_order_id, saleor_order_id, wp_user_id, customer_email)
VALUES (%s, %s, %s, %s)
""", (order.wc_order_id, order_id, order.wp_user_id, order.customer_email))
self.conn.commit()
return order_id
def main():
parser = argparse.ArgumentParser(
description='Complete WooCommerce Migration (All Users + Orders) - ASSUMES pending=completed for COD'
)
parser.add_argument('--users', action='store_true', help='Migrate all WordPress users')
parser.add_argument('--orders', action='store_true', help='Migrate all orders')
parser.add_argument('--dry-run', action='store_true', help='Preview only')
parser.add_argument('--limit-users', type=int, help='Limit user count')
parser.add_argument('--limit-orders', type=int, help='Limit order count')
parser.add_argument('--segment', type=str,
choices=['VIP_CUSTOMER', 'REPEAT_CUSTOMER', 'ONE_TIME', 'PROSPECT'],
help='Migrate only specific segment')
parser.add_argument('--status', type=str,
help='Order statuses to migrate (default: all except cancelled)')
args = parser.parse_args()
if not args.users and not args.orders:
parser.print_help()
sys.exit(1)
print("=" * 70)
print("COMPLETE WOOCOMMERCE TO SALEOR MIGRATION")
print("=" * 70)
print()
print("ASSUMPTION: ALL orders = FULFILLED (paid) EXCEPT cancelled")
print("For COD: if not cancelled, payment was collected on delivery.")
print()
print("Statuses treated as PAID:", ', '.join(PAID_STATUSES))
print("=" * 70)
print()
print("Connecting to databases...")
try:
exporter = CompleteExporter(WP_DB_CONFIG)
importer = CompleteImporter(SALEOR_DB_CONFIG)
print("Connected!\n")
except Exception as e:
print(f"Failed: {e}")
sys.exit(1)
# Migrate users
if args.users:
print("Fetching all WordPress users...")
users = exporter.get_all_users_with_activity()
if args.segment:
users = [u for u in users if u.segment == args.segment]
if args.limit_users:
users = users[:args.limit_users]
print(f"Found {len(users)} users to migrate\n")
# Segment breakdown
segments = defaultdict(int)
for u in users:
segments[u.segment] += 1
print("Segment breakdown:")
for seg, count in sorted(segments.items(), key=lambda x: -x[1]):
print(f" {seg}: {count}")
print()
print("Migrating users...")
for i, user in enumerate(users, 1):
print(f"[{i}/{len(users)}]", end=" ")
try:
importer.import_user(user, dry_run=args.dry_run)
except Exception as e:
print(f"ERROR: {e}")
print(f"\nUser migration {'preview' if args.dry_run else 'complete'}!\n")
# Migrate orders
if args.orders:
print("Fetching orders...")
# Default to ALL statuses except cancelled
if args.status:
status_filter = args.status
else:
# Exclude cancelled by default
status_filter = 'wc-completed,wc-pending,wc-processing,wc-on-hold'
orders = exporter.get_orders(limit=args.limit_orders, status=status_filter)
print(f"Found {len(orders)} orders (statuses: {status_filter})\n")
paid = sum(1 for o in orders if o.is_paid)
print(f"Breakdown: {paid} fulfilled (paid), {len(orders)-paid} cancelled\n")
print("Migrating orders...")
for i, order in enumerate(orders, 1):
marker = "" if order.is_paid else ""
print(f"[{i}/{len(orders)}] {order.order_number} {marker}", end=" ")
try:
importer.import_order(order, dry_run=args.dry_run)
print()
except Exception as e:
print(f"ERROR: {e}")
print(f"\nOrder migration {'preview' if args.dry_run else 'complete'}!\n")
# Summary
print("=" * 70)
print("MIGRATION SUMMARY")
print("=" * 70)
print(f"Users migrated: {len(importer.wp_id_to_saleor_id)}")
if args.users:
print("\nBy segment:")
with importer.conn.cursor() as cursor:
cursor.execute("""
SELECT segment, COUNT(*) as count
FROM wc_complete_user_mapping
GROUP BY segment
ORDER BY count DESC
""")
for row in cursor.fetchall():
print(f" {row[0]}: {row[1]}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,576 @@
#!/usr/bin/env python3
"""
WooCommerce CASH ON DELIVERY Orders to Saleor Migration
=======================================================
For stores with COD only - no payment gateway, no transaction IDs.
Payment is collected on delivery, so payment status = fulfillment status.
Key differences from card payments:
- No payment_method details needed (or set to 'mirumee.payments.dummy')
- No transaction IDs
- Payment is marked as received when order is fulfilled
- Simpler order structure
"""
import os
import sys
import json
import uuid
import argparse
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass
import psycopg2
WP_DB_CONFIG = {
'host': os.getenv('WP_DB_HOST', 'localhost'),
'port': int(os.getenv('WP_DB_PORT', 3306)),
'user': os.getenv('WP_DB_USER', 'wordpress'),
'password': os.getenv('WP_DB_PASSWORD', ''),
'database': os.getenv('WP_DB_NAME', 'wordpress'),
}
SALEOR_DB_CONFIG = {
'host': os.getenv('SALEOR_DB_HOST', 'localhost'),
'port': int(os.getenv('SALEOR_DB_PORT', 5432)),
'user': os.getenv('SALEOR_DB_USER', 'saleor'),
'password': os.getenv('SALEOR_DB_PASSWORD', ''),
'database': os.getenv('SALEOR_DB_NAME', 'saleor'),
}
# COD Status Mapping
# WC: wc-pending -> Saleor: UNCONFIRMED (order received, not processed)
# WC: wc-processing -> Saleor: UNFULFILLED (preparing for delivery)
# WC: wc-completed -> Saleor: FULFILLED + payment marked as received
ORDER_STATUS_MAP = {
'wc-pending': 'UNCONFIRMED',
'wc-processing': 'UNFULFILLED',
'wc-on-hold': 'UNCONFIRMED',
'wc-completed': 'FULFILLED',
'wc-cancelled': 'CANCELED',
'wc-refunded': 'CANCELED', # COD refunds are manual
'wc-failed': 'CANCELED',
}
@dataclass
class CODOrder:
"""COD Order with minimal payment info"""
wc_order_id: int
order_number: str
status: str
date_created: datetime
date_modified: datetime
customer_email: str
customer_first_name: str
customer_last_name: str
customer_phone: Optional[str]
total: float # in cents
subtotal: float
tax: float
shipping: float
currency: str
billing_address: Dict
shipping_address: Dict
customer_note: str
shipping_method: str
items: List[Dict]
is_paid: bool # Derived from status (completed = paid)
class CODOrderExporter:
"""Export COD orders from WooCommerce"""
def __init__(self, wp_db_config: Dict):
try:
import pymysql
self.conn = pymysql.connect(
host=wp_db_config['host'],
port=wp_db_config['port'],
user=wp_db_config['user'],
password=wp_db_config['password'],
database=wp_db_config['database'],
cursorclass=pymysql.cursors.DictCursor
)
except ImportError:
raise ImportError("pymysql required")
def get_orders(self, limit: Optional[int] = None,
status: Optional[str] = None) -> List[CODOrder]:
"""Fetch COD orders"""
query = """
SELECT
p.ID as wc_order_id,
p.post_date as date_created,
p.post_modified as date_modified,
p.post_status as status,
meta_total.meta_value as total,
meta_subtotal.meta_value as subtotal,
meta_tax.meta_value as tax,
meta_shipping.meta_value as shipping,
meta_currency.meta_value as currency,
meta_email.meta_value as customer_email,
meta_first.meta_value as customer_first_name,
meta_last.meta_value as customer_last_name,
meta_phone.meta_value as customer_phone,
meta_shipping_method.meta_value as shipping_method,
meta_customer_note.meta_value as customer_note
FROM wp_posts p
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id
AND meta_total.meta_key = '_order_total'
LEFT JOIN wp_postmeta meta_subtotal ON p.ID = meta_subtotal.post_id
AND meta_subtotal.meta_key = '_order_subtotal'
LEFT JOIN wp_postmeta meta_tax ON p.ID = meta_tax.post_id
AND meta_tax.meta_key = '_order_tax'
LEFT JOIN wp_postmeta meta_shipping ON p.ID = meta_shipping.post_id
AND meta_shipping.meta_key = '_order_shipping'
LEFT JOIN wp_postmeta meta_currency ON p.ID = meta_currency.post_id
AND meta_currency.meta_key = '_order_currency'
LEFT JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id
AND meta_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta meta_first ON p.ID = meta_first.post_id
AND meta_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta meta_last ON p.ID = meta_last.post_id
AND meta_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta meta_phone ON p.ID = meta_phone.post_id
AND meta_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta meta_shipping_method ON p.ID = meta_shipping_method.post_id
AND meta_shipping_method.meta_key = '_shipping_method'
LEFT JOIN wp_postmeta meta_customer_note ON p.ID = meta_customer_note.post_id
AND meta_customer_note.meta_key = 'customer_note'
WHERE p.post_type = 'shop_order'
"""
params = []
if status:
query += " AND p.post_status = %s"
params.append(status)
query += " ORDER BY p.post_date DESC"
if limit:
query += f" LIMIT {limit}"
with self.conn.cursor() as cursor:
cursor.execute(query, params)
rows = cursor.fetchall()
orders = []
for row in rows:
billing = self._get_address(row['wc_order_id'], 'billing')
shipping = self._get_address(row['wc_order_id'], 'shipping')
items = self._get_items(row['wc_order_id'])
# For COD: order is paid when status is completed
is_paid = row['status'] == 'wc-completed'
order = CODOrder(
wc_order_id=row['wc_order_id'],
order_number=f"WC-{row['wc_order_id']}",
status=row['status'],
date_created=row['date_created'],
date_modified=row['date_modified'],
customer_email=row['customer_email'] or '',
customer_first_name=row['customer_first_name'] or '',
customer_last_name=row['customer_last_name'] or '',
customer_phone=row['customer_phone'],
total=float(row['total'] or 0) * 100,
subtotal=float(row['subtotal'] or 0) * 100,
tax=float(row['tax'] or 0) * 100,
shipping=float(row['shipping'] or 0) * 100,
currency=row['currency'] or 'RSD',
billing_address=billing or self._empty_address(),
shipping_address=shipping or billing or self._empty_address(),
shipping_method=row['shipping_method'] or 'Cash on Delivery',
customer_note=row['customer_note'] or '',
items=items,
is_paid=is_paid
)
orders.append(order)
return orders
def _get_address(self, order_id: int, prefix: str) -> Optional[Dict]:
query = f"""
SELECT
MAX(CASE WHEN meta_key = '_{prefix}_first_name' THEN meta_value END) as first_name,
MAX(CASE WHEN meta_key = '_{prefix}_last_name' THEN meta_value END) as last_name,
MAX(CASE WHEN meta_key = '_{prefix}_company' THEN meta_value END) as company,
MAX(CASE WHEN meta_key = '_{prefix}_address_1' THEN meta_value END) as address_1,
MAX(CASE WHEN meta_key = '_{prefix}_address_2' THEN meta_value END) as address_2,
MAX(CASE WHEN meta_key = '_{prefix}_city' THEN meta_value END) as city,
MAX(CASE WHEN meta_key = '_{prefix}_postcode' THEN meta_value END) as postcode,
MAX(CASE WHEN meta_key = '_{prefix}_country' THEN meta_value END) as country,
MAX(CASE WHEN meta_key = '_{prefix}_phone' THEN meta_value END) as phone
FROM wp_postmeta
WHERE post_id = %s
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
row = cursor.fetchone()
if not row or not row['first_name']:
return None
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
def _empty_address(self) -> Dict:
return {
'first_name': '', 'last_name': '', 'company_name': '',
'street_address_1': '', 'street_address_2': '',
'city': '', 'postal_code': '', 'country': 'RS', 'phone': ''
}
def _get_items(self, order_id: int) -> List[Dict]:
query = """
SELECT
oi.order_item_name as name,
meta_sku.meta_value as sku,
meta_qty.meta_value as quantity,
meta_subtotal.meta_value as subtotal,
meta_total.meta_value as total,
meta_tax.meta_value as tax
FROM wp_woocommerce_order_items oi
LEFT JOIN wp_woocommerce_order_itemmeta meta_sku
ON oi.order_item_id = meta_sku.order_item_id
AND meta_sku.meta_key = '_sku'
LEFT JOIN wp_woocommerce_order_itemmeta meta_qty
ON oi.order_item_id = meta_qty.order_item_id
AND meta_qty.meta_key = '_qty'
LEFT JOIN wp_woocommerce_order_itemmeta meta_subtotal
ON oi.order_item_id = meta_subtotal.order_item_id
AND meta_subtotal.meta_key = '_line_subtotal'
LEFT JOIN wp_woocommerce_order_itemmeta meta_total
ON oi.order_item_id = meta_total.order_item_id
AND meta_total.meta_key = '_line_total'
LEFT JOIN wp_woocommerce_order_itemmeta meta_tax
ON oi.order_item_id = meta_tax.order_item_id
AND meta_tax.meta_key = '_line_tax'
WHERE oi.order_id = %s AND oi.order_item_type = 'line_item'
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
rows = cursor.fetchall()
items = []
for row in rows:
qty = int(row['quantity'] or 1)
items.append({
'name': row['name'] or '',
'sku': row['sku'] or '',
'quantity': qty,
'subtotal': float(row['subtotal'] or 0) * 100,
'total': float(row['total'] or 0) * 100,
'tax': float(row['tax'] or 0) * 100,
})
return items
class CODSaleorImporter:
"""Import COD orders into Saleor"""
def __init__(self, saleor_db_config: Dict):
self.conn = psycopg2.connect(
host=saleor_db_config['host'],
port=saleor_db_config['port'],
user=saleor_db_config['user'],
password=saleor_db_config['password'],
database=saleor_db_config['database']
)
self.email_to_user_id: Dict[str, uuid.UUID] = {}
self._ensure_tables()
self._load_mappings()
def _ensure_tables(self):
with self.conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_cod_customer_mapping (
email VARCHAR(255) PRIMARY KEY,
saleor_user_id UUID NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
phone VARCHAR(255),
order_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_order_mapping (
wc_order_id BIGINT PRIMARY KEY,
saleor_order_id UUID NOT NULL,
customer_email VARCHAR(255),
migrated_at TIMESTAMP DEFAULT NOW()
);
""")
self.conn.commit()
def _load_mappings(self):
with self.conn.cursor() as cursor:
cursor.execute("SELECT email, saleor_user_id FROM wc_cod_customer_mapping")
for row in cursor.fetchall():
self.email_to_user_id[row[0]] = row[1]
def get_channel_id(self) -> uuid.UUID:
with self.conn.cursor() as cursor:
cursor.execute("SELECT id FROM channel_channel WHERE slug = 'default-channel' LIMIT 1")
return cursor.fetchone()[0]
def create_user(self, email: str, first_name: str, last_name: str,
phone: Optional[str], address: Dict, dry_run: bool = False) -> uuid.UUID:
"""Create a customer user from order data"""
if email in self.email_to_user_id:
return self.email_to_user_id[email]
user_id = uuid.uuid4()
if dry_run:
print(f" [DRY RUN] Would create user: {email}")
return user_id
with self.conn.cursor() as cursor:
# Create user
cursor.execute("""
INSERT INTO account_user (id, email, first_name, last_name,
is_staff, is_active, date_joined, password)
VALUES (%s, %s, %s, %s, %s, %s, NOW(), %s)
""", (user_id, email, first_name, last_name, False, True, '!'))
# Create address
addr_id = uuid.uuid4()
cursor.execute("""
INSERT INTO account_address (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (addr_id, address['first_name'], address['last_name'],
address['company_name'], address['street_address_1'],
address['street_address_2'], address['city'],
address['postal_code'], address['country'], phone or ''))
cursor.execute("""
INSERT INTO account_user_addresses (user_id, address_id)
VALUES (%s, %s)
""", (user_id, addr_id))
cursor.execute("""
UPDATE account_user
SET default_billing_address_id = %s, default_shipping_address_id = %s
WHERE id = %s
""", (addr_id, addr_id, user_id))
cursor.execute("""
INSERT INTO wc_cod_customer_mapping (email, saleor_user_id, first_name, last_name, phone)
VALUES (%s, %s, %s, %s, %s)
""", (email, user_id, first_name, last_name, phone))
self.conn.commit()
self.email_to_user_id[email] = user_id
return user_id
def import_order(self, order: CODOrder, create_users: bool = True,
dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import a COD order"""
# Check existing
with self.conn.cursor() as cursor:
cursor.execute("SELECT saleor_order_id FROM wc_order_mapping WHERE wc_order_id = %s",
(order.wc_order_id,))
if cursor.fetchone():
print(f" Order {order.order_number} already migrated")
return None
order_id = uuid.uuid4()
channel_id = self.get_channel_id()
saleor_status = ORDER_STATUS_MAP.get(order.status, 'UNCONFIRMED')
# Get or create user
user_id = None
if create_users and order.customer_email:
if order.customer_email not in self.email_to_user_id:
self.create_user(order.customer_email, order.customer_first_name,
order.customer_last_name, order.customer_phone,
order.billing_address, dry_run)
user_id = self.email_to_user_id.get(order.customer_email)
if dry_run:
paid_status = "PAID" if order.is_paid else "UNPAID"
print(f" [DRY RUN] Would create order: {order.order_number} ({paid_status})")
return order_id
with self.conn.cursor() as cursor:
# Create billing address
bill_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_orderbillingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (bill_id, order.billing_address['first_name'], order.billing_address['last_name'],
order.billing_address['company_name'], order.billing_address['street_address_1'],
order.billing_address['street_address_2'], order.billing_address['city'],
order.billing_address['postal_code'], order.billing_address['country'],
order.billing_address['phone']))
# Create shipping address
ship_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_ordershippingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (ship_id, order.shipping_address['first_name'], order.shipping_address['last_name'],
order.shipping_address['company_name'], order.shipping_address['street_address_1'],
order.shipping_address['street_address_2'], order.shipping_address['city'],
order.shipping_address['postal_code'], order.shipping_address['country'],
order.shipping_address['phone']))
# Insert order
cursor.execute("""
INSERT INTO order_order (
id, created_at, updated_at, status, user_email, user_id, currency,
total_gross_amount, total_net_amount,
shipping_price_gross_amount, shipping_price_net_amount,
shipping_method_name, channel_id,
billing_address_id, shipping_address_id,
billing_address, shipping_address,
metadata, origin, should_refresh_prices,
tax_exemption, discount_amount, display_gross_prices, customer_note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
order_id, order.date_created, order.date_modified, saleor_status,
order.customer_email, user_id, order.currency,
order.total, order.subtotal, order.shipping, order.shipping,
order.shipping_method, channel_id, bill_id, ship_id,
json.dumps(order.billing_address), json.dumps(order.shipping_address),
json.dumps({
'woo_order_id': order.wc_order_id,
'cod_payment': True,
'payment_collected_on_delivery': order.is_paid
}),
'BULK_CREATE', False, False, 0.0, True, order.customer_note
))
# Insert order lines
for item in order.items:
cursor.execute("SELECT id FROM product_productvariant WHERE sku = %s",
(item['sku'],))
variant = cursor.fetchone()
variant_id = variant[0] if variant else None
qty = item['quantity']
unit_net = item['subtotal'] / qty if qty else 0
unit_gross = (item['subtotal'] + item['tax']) / qty if qty else 0
cursor.execute("""
INSERT INTO order_orderline (id, order_id, product_name, product_sku,
quantity, currency, unit_price_net_amount, unit_price_gross_amount,
total_price_net_amount, total_price_gross_amount,
unit_discount_amount, unit_discount_type, tax_rate,
is_shipping_required, variant_id, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (uuid.uuid4(), order_id, item['name'], item['sku'], qty,
order.currency, unit_net, unit_gross, item['subtotal'],
item['subtotal'] + item['tax'], 0.0, 'FIXED', '0.15',
True, variant_id, order.date_created))
# For COD: Create a dummy payment record for completed orders
# This marks that payment was collected on delivery
if order.is_paid:
cursor.execute("""
INSERT INTO payment_payment (
id, gateway, is_active, to_confirm, order_id, total,
captured_amount, currency, charge_status, partial, modified_at, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
uuid.uuid4(),
'mirumee.payments.dummy', # Dummy gateway for COD
False, # Not active (completed)
False,
order_id,
order.total,
order.total, # Fully captured (collected on delivery)
order.currency,
'FULLY_CHARGED',
False,
order.date_modified,
order.date_created
))
# Record mapping
cursor.execute("""
INSERT INTO wc_order_mapping (wc_order_id, saleor_order_id, customer_email)
VALUES (%s, %s, %s)
""", (order.wc_order_id, order_id, order.customer_email))
self.conn.commit()
paid_marker = "" if order.is_paid else ""
print(f" Created order: {order.order_number} {paid_marker}")
return order_id
def main():
parser = argparse.ArgumentParser(description='Migrate WooCommerce COD Orders to Saleor')
parser.add_argument('--orders', action='store_true', help='Migrate orders')
parser.add_argument('--create-users', action='store_true',
help='Create customer accounts from order emails')
parser.add_argument('--dry-run', action='store_true', help='Preview only')
parser.add_argument('--limit', type=int, help='Limit order count')
parser.add_argument('--status', type=str, help='Filter by status (wc-completed, etc)')
args = parser.parse_args()
if not args.orders:
parser.print_help()
sys.exit(1)
print("=== WooCommerce COD Orders to Saleor Migration ===\n")
print("Connecting...")
try:
exporter = CODOrderExporter(WP_DB_CONFIG)
importer = CODSaleorImporter(SALEOR_DB_CONFIG)
print("Connected!\n")
except Exception as e:
print(f"Failed: {e}")
sys.exit(1)
print("Fetching orders...")
orders = exporter.get_orders(limit=args.limit, status=args.status)
print(f"Found {len(orders)} orders\n")
# Stats
paid_count = sum(1 for o in orders if o.is_paid)
unpaid_count = len(orders) - paid_count
print(f"Breakdown: {paid_count} paid (delivered), {unpaid_count} unpaid (pending/processing)\n")
print("Migrating...")
for i, order in enumerate(orders, 1):
status_marker = "" if order.is_paid else ""
print(f"[{i}/{len(orders)}] {order.order_number} {status_marker} {order.customer_email}")
try:
importer.import_order(order, create_users=args.create_users, dry_run=args.dry_run)
except Exception as e:
print(f" ERROR: {e}")
print(f"\n{'Preview' if args.dry_run else 'Migration'} complete!")
print(f"Total orders: {len(orders)}")
if args.create_users:
print(f"Customers created: {len(importer.email_to_user_id)}")
if __name__ == '__main__':
main()

785
scripts/migrate_complete.py Normal file
View File

@@ -0,0 +1,785 @@
#!/usr/bin/env python3
"""
COMPLETE WooCommerce to Saleor Migration
========================================
Migrates:
1. ALL 4,886 WordPress users (including 3,700+ who never ordered = PROSPECTS)
2. ALL 1,786 orders linked to customers by email
Principles:
- Every WP user becomes a Saleor customer (prospects for marketing)
- Orders linked by email (catches "guest" checkouts too)
- Pending/processing/completed = FULFILLED (COD collected)
- Cancelled = CANCELLED (but still linked to customer)
"""
import os
import sys
import json
import uuid
import argparse
from datetime import datetime
from typing import Dict, List, Optional, Set, Tuple
from dataclasses import dataclass
from collections import defaultdict
import psycopg2
WP_DB_CONFIG = {
'host': os.getenv('WP_DB_HOST', '10.43.245.156'),
'port': int(os.getenv('WP_DB_PORT', 3306)),
'user': os.getenv('WP_DB_USER', 'DUjqYuqsYvaGUFV4'),
'password': os.getenv('WP_DB_PASSWORD', 'voP0UzecALE0WRNJQcTCf0STMcxIiX99'),
'database': os.getenv('WP_DB_NAME', 'wordpress'),
}
SALEOR_DB_CONFIG = {
'host': os.getenv('SALEOR_DB_HOST', '10.43.42.251'),
'port': int(os.getenv('SALEOR_DB_PORT', 5432)),
'user': os.getenv('SALEOR_DB_USER', 'saleor'),
'password': os.getenv('SALEOR_DB_PASSWORD', 'saleor123'),
'database': os.getenv('SALEOR_DB_NAME', 'saleor'),
}
ORDER_STATUS_MAP = {
'wc-pending': 'FULFILLED',
'wc-processing': 'FULFILLED',
'wc-on-hold': 'FULFILLED',
'wc-completed': 'FULFILLED',
'wc-cancelled': 'CANCELED',
'wc-refunded': 'CANCELED',
'wc-failed': 'CANCELED',
}
NON_CANCELLED_STATUSES = ['wc-completed', 'wc-pending', 'wc-processing', 'wc-on-hold']
@dataclass
class Customer:
"""Customer from WP users OR order billing data"""
source: str # 'wp_user' or 'order_email'
email: str
first_name: str
last_name: str
phone: Optional[str]
date_registered: datetime
billing_address: Optional[Dict]
# Order stats (from joined data)
total_orders: int = 0
cancelled_orders: int = 0
completed_orders: int = 0
total_spent: float = 0.0
first_order_date: Optional[datetime] = None
last_order_date: Optional[datetime] = None
@property
def segment(self) -> str:
if self.completed_orders >= 4:
return "VIP"
elif self.completed_orders >= 2:
return "REPEAT"
elif self.completed_orders == 1:
return "ONE_TIME"
elif self.total_orders > 0:
return "CANCELLED_ONLY"
else:
return "PROSPECT"
@dataclass
class OrderToMigrate:
"""Order data"""
wc_order_id: int
order_number: str
status: str
date_created: datetime
date_modified: datetime
customer_email: str
customer_first_name: str
customer_last_name: str
customer_phone: Optional[str]
total: float
subtotal: float
tax: float
shipping: float
currency: str
billing_address: Dict
shipping_address: Dict
customer_note: str
shipping_method: str
items: List[Dict]
is_paid: bool
class CompleteExporter:
"""Export all users and orders"""
def __init__(self, wp_db_config: Dict):
import pymysql
self.conn = pymysql.connect(
host=wp_db_config['host'],
port=wp_db_config['port'],
user=wp_db_config['user'],
password=wp_db_config['password'],
database=wp_db_config['database'],
cursorclass=pymysql.cursors.DictCursor
)
def get_all_customers(self) -> Dict[str, Customer]:
"""Get ALL customers: WP users + order emails merged"""
customers: Dict[str, Customer] = {}
# Step 1: Get all WordPress users (these are prospects if no orders)
with self.conn.cursor() as cursor:
cursor.execute("""
SELECT
u.ID as wp_user_id,
u.user_email as email,
u.user_registered as date_registered,
um_first.meta_value as first_name,
um_last.meta_value as last_name,
um_phone.meta_value as phone
FROM wp_users u
LEFT JOIN wp_usermeta um_first ON u.ID = um_first.user_id AND um_first.meta_key = 'first_name'
LEFT JOIN wp_usermeta um_last ON u.ID = um_last.user_id AND um_last.meta_key = 'last_name'
LEFT JOIN wp_usermeta um_phone ON u.ID = um_phone.user_id AND um_phone.meta_key = 'billing_phone'
WHERE u.user_email IS NOT NULL AND u.user_email != ''
""")
for row in cursor.fetchall():
email = row['email'].lower().strip()
address = self._get_user_address(row['wp_user_id'])
customers[email] = Customer(
source='wp_user',
email=email,
first_name=row['first_name'] or '',
last_name=row['last_name'] or '',
phone=row['phone'],
date_registered=row['date_registered'],
billing_address=address,
total_orders=0,
cancelled_orders=0,
completed_orders=0,
total_spent=0.0
)
# Step 2: Get order stats for all customers (including those not in WP users)
with self.conn.cursor() as cursor:
cursor.execute("""
SELECT
LOWER(TRIM(pm_email.meta_value)) as email,
MAX(pm_first.meta_value) as first_name,
MAX(pm_last.meta_value) as last_name,
MAX(pm_phone.meta_value) as phone,
COUNT(*) as total_orders,
SUM(CASE WHEN p.post_status = 'wc-cancelled' THEN 1 ELSE 0 END) as cancelled_orders,
SUM(CASE WHEN p.post_status != 'wc-cancelled' THEN 1 ELSE 0 END) as completed_orders,
SUM(CASE WHEN p.post_status != 'wc-cancelled' THEN CAST(COALESCE(pm_total.meta_value, 0) AS DECIMAL(12,2)) ELSE 0 END) as total_spent,
MIN(p.post_date) as first_order_date,
MAX(p.post_date) as last_order_date
FROM wp_posts p
JOIN wp_postmeta pm_email ON p.ID = pm_email.post_id AND pm_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta pm_first ON p.ID = pm_first.post_id AND pm_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta pm_last ON p.ID = pm_last.post_id AND pm_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta pm_phone ON p.ID = pm_phone.post_id AND pm_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta pm_total ON p.ID = pm_total.post_id AND pm_total.meta_key = '_order_total'
WHERE p.post_type = 'shop_order'
AND pm_email.meta_value IS NOT NULL
AND pm_email.meta_value != ''
GROUP BY LOWER(TRIM(pm_email.meta_value))
""")
for row in cursor.fetchall():
email = row['email']
if email in customers:
# Update existing WP user with order stats
existing = customers[email]
existing.total_orders = row['total_orders']
existing.cancelled_orders = row['cancelled_orders']
existing.completed_orders = row['completed_orders']
existing.total_spent = float(row['total_spent'] or 0)
existing.first_order_date = row['first_order_date']
existing.last_order_date = row['last_order_date']
# Use order data for name/phone if WP data is empty
if not existing.first_name:
existing.first_name = row['first_name'] or ''
if not existing.last_name:
existing.last_name = row['last_name'] or ''
if not existing.phone:
existing.phone = row['phone']
else:
# New customer from order (guest checkout)
address = {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': '',
'street_address_1': '',
'street_address_2': '',
'city': '',
'postal_code': '',
'country': 'RS',
'phone': row['phone'] or '',
}
customers[email] = Customer(
source='order_email',
email=email,
first_name=row['first_name'] or '',
last_name=row['last_name'] or '',
phone=row['phone'],
date_registered=row['first_order_date'] or datetime.now(),
billing_address=address,
total_orders=row['total_orders'],
cancelled_orders=row['cancelled_orders'],
completed_orders=row['completed_orders'],
total_spent=float(row['total_spent'] or 0),
first_order_date=row['first_order_date'],
last_order_date=row['last_order_date']
)
return customers
def _get_user_address(self, user_id: int) -> Optional[Dict]:
"""Get address from usermeta or latest order"""
# Try usermeta first
with self.conn.cursor() as cursor:
cursor.execute("""
SELECT
MAX(CASE WHEN meta_key = 'billing_first_name' THEN meta_value END) as first_name,
MAX(CASE WHEN meta_key = 'billing_last_name' THEN meta_value END) as last_name,
MAX(CASE WHEN meta_key = 'billing_address_1' THEN meta_value END) as address_1,
MAX(CASE WHEN meta_key = 'billing_address_2' THEN meta_value END) as address_2,
MAX(CASE WHEN meta_key = 'billing_city' THEN meta_value END) as city,
MAX(CASE WHEN meta_key = 'billing_postcode' THEN meta_value END) as postcode,
MAX(CASE WHEN meta_key = 'billing_country' THEN meta_value END) as country,
MAX(CASE WHEN meta_key = 'billing_phone' THEN meta_value END) as phone
FROM wp_usermeta
WHERE user_id = %s
""", (user_id,))
row = cursor.fetchone()
if row and row['first_name']:
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
return None
def get_all_orders(self, limit: Optional[int] = None) -> List[OrderToMigrate]:
"""Get ALL orders"""
query = """
SELECT
p.ID as wc_order_id,
p.post_date as date_created,
p.post_modified as date_modified,
p.post_status as status,
meta_total.meta_value as total,
meta_subtotal.meta_value as subtotal,
meta_tax.meta_value as tax,
meta_shipping.meta_value as shipping,
meta_currency.meta_value as currency,
LOWER(TRIM(meta_email.meta_value)) as customer_email,
meta_first.meta_value as customer_first_name,
meta_last.meta_value as customer_last_name,
meta_phone.meta_value as customer_phone,
meta_shipping_method.meta_value as shipping_method,
meta_customer_note.meta_value as customer_note
FROM wp_posts p
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id AND meta_total.meta_key = '_order_total'
LEFT JOIN wp_postmeta meta_subtotal ON p.ID = meta_subtotal.post_id AND meta_subtotal.meta_key = '_order_subtotal'
LEFT JOIN wp_postmeta meta_tax ON p.ID = meta_tax.post_id AND meta_tax.meta_key = '_order_tax'
LEFT JOIN wp_postmeta meta_shipping ON p.ID = meta_shipping.post_id AND meta_shipping.meta_key = '_order_shipping'
LEFT JOIN wp_postmeta meta_currency ON p.ID = meta_currency.post_id AND meta_currency.meta_key = '_order_currency'
LEFT JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id AND meta_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta meta_first ON p.ID = meta_first.post_id AND meta_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta meta_last ON p.ID = meta_last.post_id AND meta_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta meta_phone ON p.ID = meta_phone.post_id AND meta_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta meta_shipping_method ON p.ID = meta_shipping_method.post_id AND meta_shipping_method.meta_key = '_shipping_method'
LEFT JOIN wp_postmeta meta_customer_note ON p.ID = meta_customer_note.post_id AND meta_customer_note.meta_key = 'customer_note'
WHERE p.post_type = 'shop_order'
ORDER BY p.post_date DESC
"""
if limit:
query += f" LIMIT {limit}"
with self.conn.cursor() as cursor:
cursor.execute(query)
rows = cursor.fetchall()
orders = []
for row in rows:
billing = self._get_order_address(row['wc_order_id'], 'billing')
shipping = self._get_order_address(row['wc_order_id'], 'shipping')
items = self._get_items(row['wc_order_id'])
orders.append(OrderToMigrate(
wc_order_id=row['wc_order_id'],
order_number=f"WC-{row['wc_order_id']}",
status=row['status'],
date_created=row['date_created'],
date_modified=row['date_modified'],
customer_email=row['customer_email'] or '',
customer_first_name=row['customer_first_name'] or '',
customer_last_name=row['customer_last_name'] or '',
customer_phone=row['customer_phone'],
total=float(row['total'] or 0) * 100,
subtotal=float(row['subtotal'] or 0) * 100,
tax=float(row['tax'] or 0) * 100,
shipping=float(row['shipping'] or 0) * 100,
currency=row['currency'] or 'RSD',
billing_address=billing or self._empty_address(),
shipping_address=shipping or billing or self._empty_address(),
shipping_method=row['shipping_method'] or 'Cash on Delivery',
customer_note=row['customer_note'] or '',
items=items,
is_paid=row['status'] in NON_CANCELLED_STATUSES
))
return orders
def _get_order_address(self, order_id: int, prefix: str) -> Optional[Dict]:
query = f"""
SELECT
MAX(CASE WHEN meta_key = '_{prefix}_first_name' THEN meta_value END) as first_name,
MAX(CASE WHEN meta_key = '_{prefix}_last_name' THEN meta_value END) as last_name,
MAX(CASE WHEN meta_key = '_{prefix}_company' THEN meta_value END) as company,
MAX(CASE WHEN meta_key = '_{prefix}_address_1' THEN meta_value END) as address_1,
MAX(CASE WHEN meta_key = '_{prefix}_address_2' THEN meta_value END) as address_2,
MAX(CASE WHEN meta_key = '_{prefix}_city' THEN meta_value END) as city,
MAX(CASE WHEN meta_key = '_{prefix}_postcode' THEN meta_value END) as postcode,
MAX(CASE WHEN meta_key = '_{prefix}_country' THEN meta_value END) as country,
MAX(CASE WHEN meta_key = '_{prefix}_phone' THEN meta_value END) as phone
FROM wp_postmeta
WHERE post_id = %s
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
row = cursor.fetchone()
if not row or not row['first_name']:
return None
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
def _empty_address(self) -> Dict:
return {
'first_name': '', 'last_name': '', 'company_name': '',
'street_address_1': '', 'street_address_2': '',
'city': '', 'postal_code': '', 'country': 'RS', 'phone': ''
}
def _get_items(self, order_id: int) -> List[Dict]:
query = """
SELECT
oi.order_item_name as name,
meta_sku.meta_value as sku,
meta_qty.meta_value as quantity,
meta_subtotal.meta_value as subtotal,
meta_total.meta_value as total,
meta_tax.meta_value as tax
FROM wp_woocommerce_order_items oi
LEFT JOIN wp_woocommerce_order_itemmeta meta_sku ON oi.order_item_id = meta_sku.order_item_id AND meta_sku.meta_key = '_sku'
LEFT JOIN wp_woocommerce_order_itemmeta meta_qty ON oi.order_item_id = meta_qty.order_item_id AND meta_qty.meta_key = '_qty'
LEFT JOIN wp_woocommerce_order_itemmeta meta_subtotal ON oi.order_item_id = meta_subtotal.order_item_id AND meta_subtotal.meta_key = '_line_subtotal'
LEFT JOIN wp_woocommerce_order_itemmeta meta_total ON oi.order_item_id = meta_total.order_item_id AND meta_total.meta_key = '_line_total'
LEFT JOIN wp_woocommerce_order_itemmeta meta_tax ON oi.order_item_id = meta_tax.order_item_id AND meta_tax.meta_key = '_line_tax'
WHERE oi.order_id = %s AND oi.order_item_type = 'line_item'
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
rows = cursor.fetchall()
items = []
for row in rows:
qty = int(row['quantity'] or 1)
items.append({
'name': row['name'] or '',
'sku': row['sku'] or '',
'quantity': qty,
'subtotal': float(row['subtotal'] or 0) * 100,
'total': float(row['total'] or 0) * 100,
'tax': float(row['tax'] or 0) * 100,
})
return items
class CompleteImporter:
"""Import customers and orders"""
def __init__(self, saleor_db_config: Dict):
self.conn = psycopg2.connect(
host=saleor_db_config['host'],
port=saleor_db_config['port'],
user=saleor_db_config['user'],
password=saleor_db_config['password'],
database=saleor_db_config['database']
)
self.email_to_user_id: Dict[str, uuid.UUID] = {}
self._ensure_tables()
self._load_mappings()
def _ensure_tables(self):
with self.conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_complete_mapping (
email VARCHAR(255) PRIMARY KEY,
saleor_user_id UUID NOT NULL,
source VARCHAR(50) NOT NULL,
segment VARCHAR(50) NOT NULL,
total_orders INTEGER DEFAULT 0,
completed_orders INTEGER DEFAULT 0,
cancelled_orders INTEGER DEFAULT 0,
total_spent DECIMAL(12,2) DEFAULT 0,
migrated_at TIMESTAMP DEFAULT NOW()
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_order_mapping (
wc_order_id BIGINT PRIMARY KEY,
saleor_order_id UUID NOT NULL,
customer_email VARCHAR(255),
migrated_at TIMESTAMP DEFAULT NOW()
);
""")
self.conn.commit()
def _load_mappings(self):
with self.conn.cursor() as cursor:
cursor.execute("SELECT email, saleor_user_id FROM wc_complete_mapping")
for row in cursor.fetchall():
self.email_to_user_id[row[0]] = row[1]
def get_channel_id(self) -> uuid.UUID:
with self.conn.cursor() as cursor:
cursor.execute("SELECT id FROM channel_channel WHERE slug = 'default-channel' LIMIT 1")
return cursor.fetchone()[0]
def import_customer(self, customer: Customer, dry_run: bool = False) -> uuid.UUID:
"""Create a customer"""
if customer.email in self.email_to_user_id:
return self.email_to_user_id[customer.email]
user_id = uuid.uuid4()
if dry_run:
status = "" if customer.completed_orders > 0 else "👤"
print(f" {status} [{customer.segment}] {customer.email} ({customer.source}, {customer.completed_orders} orders)")
return user_id
with self.conn.cursor() as cursor:
metadata = {
'source': customer.source,
'segment': customer.segment,
'total_orders': customer.total_orders,
'completed_orders': customer.completed_orders,
'cancelled_orders': customer.cancelled_orders,
'total_spent': float(customer.total_spent) if customer.total_spent else 0.0,
}
cursor.execute("""
INSERT INTO account_user (id, email, first_name, last_name,
is_staff, is_active, date_joined, password, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
str(user_id), customer.email, customer.first_name, customer.last_name,
False, True, customer.date_registered, '!', json.dumps(metadata)
))
if customer.billing_address:
addr_id = uuid.uuid4()
cursor.execute("""
INSERT INTO account_address (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
str(addr_id), customer.billing_address['first_name'], customer.billing_address['last_name'],
customer.billing_address['company_name'], customer.billing_address['street_address_1'],
customer.billing_address['street_address_2'], customer.billing_address['city'],
customer.billing_address['postal_code'], customer.billing_address['country'],
customer.phone or ''
))
cursor.execute("""
INSERT INTO account_user_addresses (user_id, address_id)
VALUES (%s, %s)
""", (str(user_id), str(addr_id)))
cursor.execute("""
UPDATE account_user
SET default_billing_address_id = %s, default_shipping_address_id = %s
WHERE id = %s
""", (str(addr_id), str(addr_id), str(user_id)))
cursor.execute("""
INSERT INTO wc_complete_mapping
(email, saleor_user_id, source, segment, total_orders, completed_orders, cancelled_orders, total_spent)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
customer.email, str(user_id), customer.source, customer.segment,
customer.total_orders, customer.completed_orders, customer.cancelled_orders, float(customer.total_spent) if customer.total_spent else 0.0
))
self.conn.commit()
self.email_to_user_id[customer.email] = user_id
return user_id
def import_order(self, order: OrderToMigrate, dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import an order"""
with self.conn.cursor() as cursor:
cursor.execute("SELECT saleor_order_id FROM wc_order_mapping WHERE wc_order_id = %s",
(order.wc_order_id,))
if cursor.fetchone():
return None
order_id = uuid.uuid4()
channel_id = self.get_channel_id()
saleor_status = ORDER_STATUS_MAP.get(order.status, 'UNCONFIRMED')
# Get user by email
user_id = self.email_to_user_id.get(order.customer_email)
if dry_run:
marker = "" if order.is_paid else ""
linked = "" if user_id else ""
print(f" {order.order_number} {marker} {linked} {order.customer_email}")
return order_id
with self.conn.cursor() as cursor:
# Create billing address
bill_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_orderbillingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (str(bill_id), order.billing_address['first_name'], order.billing_address['last_name'],
order.billing_address['company_name'], order.billing_address['street_address_1'],
order.billing_address['street_address_2'], order.billing_address['city'],
order.billing_address['postal_code'], order.billing_address['country'],
order.billing_address['phone']))
# Create shipping address
ship_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_ordershippingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (str(ship_id), order.shipping_address['first_name'], order.shipping_address['last_name'],
order.shipping_address['company_name'], order.shipping_address['street_address_1'],
order.shipping_address['street_address_2'], order.shipping_address['city'],
order.shipping_address['postal_code'], order.shipping_address['country'],
order.shipping_address['phone']))
# Insert order
cursor.execute("""
INSERT INTO order_order (
id, created_at, updated_at, status, user_email, user_id, currency,
total_gross_amount, total_net_amount,
shipping_price_gross_amount, shipping_price_net_amount,
shipping_method_name, channel_id,
billing_address_id, shipping_address_id,
billing_address, shipping_address,
metadata, origin, should_refresh_prices,
tax_exemption, discount_amount, display_gross_prices, customer_note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
order_id, order.date_created, order.date_modified, saleor_status,
order.customer_email, str(user_id) if user_id else None, order.currency,
order.total, order.subtotal, order.shipping, order.shipping,
order.shipping_method, str(channel_id), str(bill_id), str(ship_id),
json.dumps(order.billing_address), json.dumps(order.shipping_address),
json.dumps({
'woo_order_id': order.wc_order_id,
'cod_payment': True,
'payment_collected': order.is_paid,
'original_status': order.status
}),
'BULK_CREATE', False, False, 0.0, True, order.customer_note
))
# Insert order lines
for item in order.items:
cursor.execute("SELECT id FROM product_productvariant WHERE sku = %s",
(item['sku'],))
variant = cursor.fetchone()
variant_id = variant[0] if variant else None
qty = item['quantity']
unit_net = item['subtotal'] / qty if qty else 0
unit_gross = (item['subtotal'] + item['tax']) / qty if qty else 0
cursor.execute("""
INSERT INTO order_orderline (id, order_id, product_name, product_sku,
quantity, currency, unit_price_net_amount, unit_price_gross_amount,
total_price_net_amount, total_price_gross_amount,
unit_discount_amount, unit_discount_type, tax_rate,
is_shipping_required, variant_id, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (str(uuid.uuid4()), str(order_id), item['name'], item['sku'], qty,
order.currency, unit_net, unit_gross, item['subtotal'],
item['subtotal'] + item['tax'], 0.0, 'FIXED', '0.15',
True, variant_id, order.date_created))
# Create payment record for paid orders
if order.is_paid:
cursor.execute("""
INSERT INTO payment_payment (
id, gateway, is_active, to_confirm, order_id, total,
captured_amount, currency, charge_status, partial, modified_at, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (str(uuid.uuid4()), 'mirumee.payments.dummy', False, False,
str(order_id), order.total, order.total, order.currency,
'FULLY_CHARGED', False, order.date_modified, order.date_created))
# Record mapping
cursor.execute("""
INSERT INTO wc_order_mapping (wc_order_id, saleor_order_id, customer_email)
VALUES (%s, %s, %s)
""", (order.wc_order_id, str(order_id), order.customer_email))
self.conn.commit()
return order_id
def main():
parser = argparse.ArgumentParser(
description='Complete WooCommerce Migration - ALL 4,886 users + ALL 1,786 orders'
)
parser.add_argument('--customers', action='store_true', help='Migrate ALL 4,886 WordPress users + order customers')
parser.add_argument('--orders', action='store_true', help='Migrate ALL 1,786 orders')
parser.add_argument('--dry-run', action='store_true', help='Preview only')
parser.add_argument('--limit', type=int, help='Limit for testing')
args = parser.parse_args()
if not args.customers and not args.orders:
parser.print_help()
sys.exit(1)
print("=" * 70)
print("COMPLETE WOOCOMMERCE TO SALEOR MIGRATION")
print("=" * 70)
print()
print("Scope:")
print(" ✓ ALL 4,886 WordPress users (including 3,700+ prospects)")
print(" ✓ ALL customers from order billing emails")
print(" ✓ ALL 1,786 orders")
print(" ✓ Pending/Processing = FULFILLED (COD collected)")
print(" ✓ Cancelled = CANCELLED")
print()
print("Connecting to databases...")
try:
exporter = CompleteExporter(WP_DB_CONFIG)
importer = CompleteImporter(SALEOR_DB_CONFIG)
print("Connected!\n")
except Exception as e:
print(f"Failed: {e}")
sys.exit(1)
# Migrate customers first
if args.customers:
print("Fetching ALL customers (WP users + order emails)...")
customers = exporter.get_all_customers()
if args.limit:
customers = dict(list(customers.items())[:args.limit])
print(f"Found {len(customers)} unique customers\n")
# Segment breakdown
segments = defaultdict(int)
sources = defaultdict(int)
for c in customers.values():
segments[c.segment] += 1
sources[c.source] += 1
print("Sources:")
for src, count in sorted(sources.items()):
print(f" {src}: {count}")
print()
print("Segments:")
for seg, count in sorted(segments.items(), key=lambda x: -x[1]):
print(f" {seg}: {count}")
print()
print("Creating customers...")
for i, (email, customer) in enumerate(customers.items(), 1):
print(f"[{i}/{len(customers)}]", end=" ")
try:
importer.import_customer(customer, dry_run=args.dry_run)
except Exception as e:
print(f"ERROR: {e}")
print(f"\nCustomer creation {'preview' if args.dry_run else 'complete'}!\n")
# Migrate orders
if args.orders:
print("Fetching ALL orders...")
orders = exporter.get_all_orders(limit=args.limit)
print(f"Found {len(orders)} orders\n")
paid = sum(1 for o in orders if o.is_paid)
cancelled = len(orders) - paid
print(f"Breakdown: {paid} fulfilled, {cancelled} cancelled\n")
print("Migrating orders...")
for i, order in enumerate(orders, 1):
print(f"[{i}/{len(orders)}]", end=" ")
try:
importer.import_order(order, dry_run=args.dry_run)
except Exception as e:
print(f"ERROR: {e}")
print(f"\nOrder migration {'preview' if args.dry_run else 'complete'}!\n")
# Summary
print("=" * 70)
print("SUMMARY")
print("=" * 70)
print(f"Customers: {len(importer.email_to_user_id)}")
if args.customers:
print("\nBy segment:")
with importer.conn.cursor() as cursor:
cursor.execute("""
SELECT segment, COUNT(*) as count, SUM(total_spent) as revenue
FROM wc_complete_mapping
GROUP BY segment
ORDER BY count DESC
""")
for row in cursor.fetchall():
print(f" {row[0]}: {row[1]} ({row[2] or 0:,.0f} RSD)")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,736 @@
#!/usr/bin/env python3
"""
WooCommerce GUEST CHECKOUT to Saleor Migration
==============================================
For stores without customer accounts. All customer data comes from order fields.
Two approaches:
1. PURE GUEST: Orders only, no customer accounts created
2. HYBRID (Recommended): Create customer accounts from unique emails, link orders
Recommended: HYBRID - customers can later claim their account via password reset
"""
import os
import sys
import json
import uuid
import argparse
from datetime import datetime
from typing import Dict, List, Optional, Set
from dataclasses import dataclass
from collections import defaultdict
import psycopg2
# Database configurations
WP_DB_CONFIG = {
'host': os.getenv('WP_DB_HOST', 'localhost'),
'port': int(os.getenv('WP_DB_PORT', 3306)),
'user': os.getenv('WP_DB_USER', 'wordpress'),
'password': os.getenv('WP_DB_PASSWORD', ''),
'database': os.getenv('WP_DB_NAME', 'wordpress'),
}
SALEOR_DB_CONFIG = {
'host': os.getenv('SALEOR_DB_HOST', 'localhost'),
'port': int(os.getenv('SALEOR_DB_PORT', 5432)),
'user': os.getenv('SALEOR_DB_USER', 'saleor'),
'password': os.getenv('SALEOR_DB_PASSWORD', ''),
'database': os.getenv('SALEOR_DB_NAME', 'saleor'),
}
ORDER_STATUS_MAP = {
'wc-pending': 'UNCONFIRMED',
'wc-processing': 'UNFULFILLED',
'wc-on-hold': 'UNCONFIRMED',
'wc-completed': 'FULFILLED',
'wc-cancelled': 'CANCELED',
'wc-refunded': 'REFUNDED',
'wc-failed': 'CANCELED',
}
@dataclass
class GuestCustomer:
"""Customer derived from order data"""
email: str
first_name: str
last_name: str
phone: Optional[str]
orders_count: int
total_spent: float
first_order_date: datetime
last_order_date: datetime
billing_address: Optional[Dict]
@dataclass
class GuestOrder:
"""Order with embedded customer data"""
wc_order_id: int
order_number: str
status: str
date_created: datetime
date_modified: datetime
customer_email: str
customer_first_name: str
customer_last_name: str
customer_phone: Optional[str]
total: float
subtotal: float
tax: float
shipping: float
currency: str
payment_method: str
payment_method_title: str
transaction_id: Optional[str]
billing_address: Dict
shipping_address: Dict
customer_note: str
shipping_method: str
items: List[Dict]
class GuestOrderExporter:
"""Export orders from WooCommerce (guest checkout only)"""
def __init__(self, wp_db_config: Dict):
try:
import pymysql
self.conn = pymysql.connect(
host=wp_db_config['host'],
port=wp_db_config['port'],
user=wp_db_config['user'],
password=wp_db_config['password'],
database=wp_db_config['database'],
cursorclass=pymysql.cursors.DictCursor
)
except ImportError:
raise ImportError("pymysql required. Install: pip install pymysql")
def get_unique_customers(self) -> List[GuestCustomer]:
"""Extract unique customers from order billing data"""
query = """
SELECT
meta_email.meta_value as email,
MAX(meta_first.meta_value) as first_name,
MAX(meta_last.meta_value) as last_name,
MAX(meta_phone.meta_value) as phone,
COUNT(DISTINCT p.ID) as orders_count,
SUM(CAST(COALESCE(meta_total.meta_value, 0) AS DECIMAL(12,2))) as total_spent,
MIN(p.post_date) as first_order_date,
MAX(p.post_date) as last_order_date
FROM wp_posts p
JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id
AND meta_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta meta_first ON p.ID = meta_first.post_id
AND meta_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta meta_last ON p.ID = meta_last.post_id
AND meta_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta meta_phone ON p.ID = meta_phone.post_id
AND meta_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id
AND meta_total.meta_key = '_order_total'
WHERE p.post_type = 'shop_order'
AND meta_email.meta_value IS NOT NULL
AND meta_email.meta_value != ''
GROUP BY meta_email.meta_value
HAVING meta_email.meta_value LIKE '%@%'
ORDER BY orders_count DESC
"""
with self.conn.cursor() as cursor:
cursor.execute(query)
rows = cursor.fetchall()
customers = []
for row in rows:
# Get address from most recent order
address = self._get_latest_address(row['email'])
customer = GuestCustomer(
email=row['email'],
first_name=row['first_name'] or '',
last_name=row['last_name'] or '',
phone=row['phone'],
orders_count=row['orders_count'],
total_spent=float(row['total_spent'] or 0),
first_order_date=row['first_order_date'],
last_order_date=row['last_order_date'],
billing_address=address
)
customers.append(customer)
return customers
def _get_latest_address(self, email: str) -> Optional[Dict]:
"""Get the most recent address for an email"""
query = """
SELECT
p.ID as order_id,
MAX(CASE WHEN pm.meta_key = '_billing_first_name' THEN pm.meta_value END) as first_name,
MAX(CASE WHEN pm.meta_key = '_billing_last_name' THEN pm.meta_value END) as last_name,
MAX(CASE WHEN pm.meta_key = '_billing_company' THEN pm.meta_value END) as company,
MAX(CASE WHEN pm.meta_key = '_billing_address_1' THEN pm.meta_value END) as address_1,
MAX(CASE WHEN pm.meta_key = '_billing_address_2' THEN pm.meta_value END) as address_2,
MAX(CASE WHEN pm.meta_key = '_billing_city' THEN pm.meta_value END) as city,
MAX(CASE WHEN pm.meta_key = '_billing_postcode' THEN pm.meta_value END) as postcode,
MAX(CASE WHEN pm.meta_key = '_billing_country' THEN pm.meta_value END) as country,
MAX(CASE WHEN pm.meta_key = '_billing_phone' THEN pm.meta_value END) as phone
FROM wp_posts p
JOIN wp_postmeta pm_email ON p.ID = pm_email.post_id
AND pm_email.meta_key = '_billing_email'
AND pm_email.meta_value = %s
LEFT JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'shop_order'
GROUP BY p.ID
ORDER BY p.post_date DESC
LIMIT 1
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (email,))
row = cursor.fetchone()
if not row:
return None
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
def get_orders(self, limit: Optional[int] = None,
status: Optional[str] = None,
email: Optional[str] = None) -> List[GuestOrder]:
"""Fetch orders with embedded customer data"""
query = """
SELECT
p.ID as wc_order_id,
p.post_date as date_created,
p.post_modified as date_modified,
p.post_status as status,
meta_total.meta_value as total,
meta_subtotal.meta_value as subtotal,
meta_tax.meta_value as tax,
meta_shipping.meta_value as shipping,
meta_currency.meta_value as currency,
meta_email.meta_value as customer_email,
meta_first.meta_value as customer_first_name,
meta_last.meta_value as customer_last_name,
meta_phone.meta_value as customer_phone,
meta_payment_method.meta_value as payment_method,
meta_payment_title.meta_value as payment_method_title,
meta_transaction_id.meta_value as transaction_id,
meta_shipping_method.meta_value as shipping_method,
meta_customer_note.meta_value as customer_note
FROM wp_posts p
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id AND meta_total.meta_key = '_order_total'
LEFT JOIN wp_postmeta meta_subtotal ON p.ID = meta_subtotal.post_id AND meta_subtotal.meta_key = '_order_subtotal'
LEFT JOIN wp_postmeta meta_tax ON p.ID = meta_tax.post_id AND meta_tax.meta_key = '_order_tax'
LEFT JOIN wp_postmeta meta_shipping ON p.ID = meta_shipping.post_id AND meta_shipping.meta_key = '_order_shipping'
LEFT JOIN wp_postmeta meta_currency ON p.ID = meta_currency.post_id AND meta_currency.meta_key = '_order_currency'
LEFT JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id AND meta_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta meta_first ON p.ID = meta_first.post_id AND meta_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta meta_last ON p.ID = meta_last.post_id AND meta_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta meta_phone ON p.ID = meta_phone.post_id AND meta_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta meta_payment_method ON p.ID = meta_payment_method.post_id AND meta_payment_method.meta_key = '_payment_method'
LEFT JOIN wp_postmeta meta_payment_title ON p.ID = meta_payment_title.post_id AND meta_payment_title.meta_key = '_payment_method_title'
LEFT JOIN wp_postmeta meta_transaction_id ON p.ID = meta_transaction_id.post_id AND meta_transaction_id.meta_key = '_transaction_id'
LEFT JOIN wp_postmeta meta_shipping_method ON p.ID = meta_shipping_method.post_id AND meta_shipping_method.meta_key = '_shipping_method'
LEFT JOIN wp_postmeta meta_customer_note ON p.ID = meta_customer_note.post_id AND meta_customer_note.meta_key = 'customer_note'
WHERE p.post_type = 'shop_order'
"""
params = []
if status:
query += " AND p.post_status = %s"
params.append(status)
if email:
query += " AND meta_email.meta_value = %s"
params.append(email)
query += " ORDER BY p.post_date DESC"
if limit:
query += f" LIMIT {limit}"
with self.conn.cursor() as cursor:
cursor.execute(query, params)
rows = cursor.fetchall()
orders = []
for row in rows:
# Get full addresses for this order
billing = self._get_order_address(row['wc_order_id'], 'billing')
shipping = self._get_order_address(row['wc_order_id'], 'shipping')
items = self._get_order_items(row['wc_order_id'])
order = GuestOrder(
wc_order_id=row['wc_order_id'],
order_number=f"WC-{row['wc_order_id']}",
status=row['status'],
date_created=row['date_created'],
date_modified=row['date_modified'],
customer_email=row['customer_email'] or '',
customer_first_name=row['customer_first_name'] or '',
customer_last_name=row['customer_last_name'] or '',
customer_phone=row['customer_phone'],
total=float(row['total'] or 0) * 100, # Convert to cents
subtotal=float(row['subtotal'] or 0) * 100,
tax=float(row['tax'] or 0) * 100,
shipping=float(row['shipping'] or 0) * 100,
currency=row['currency'] or 'RSD',
payment_method=row['payment_method'] or '',
payment_method_title=row['payment_method_title'] or '',
transaction_id=row['transaction_id'],
shipping_method=row['shipping_method'] or '',
customer_note=row['customer_note'] or '',
billing_address=billing or self._empty_address(),
shipping_address=shipping or billing or self._empty_address(),
items=items
)
orders.append(order)
return orders
def _get_order_address(self, order_id: int, prefix: str) -> Optional[Dict]:
"""Fetch order address from postmeta"""
query = f"""
SELECT
MAX(CASE WHEN meta_key = '_{prefix}_first_name' THEN meta_value END) as first_name,
MAX(CASE WHEN meta_key = '_{prefix}_last_name' THEN meta_value END) as last_name,
MAX(CASE WHEN meta_key = '_{prefix}_company' THEN meta_value END) as company,
MAX(CASE WHEN meta_key = '_{prefix}_address_1' THEN meta_value END) as address_1,
MAX(CASE WHEN meta_key = '_{prefix}_address_2' THEN meta_value END) as address_2,
MAX(CASE WHEN meta_key = '_{prefix}_city' THEN meta_value END) as city,
MAX(CASE WHEN meta_key = '_{prefix}_postcode' THEN meta_value END) as postcode,
MAX(CASE WHEN meta_key = '_{prefix}_country' THEN meta_value END) as country,
MAX(CASE WHEN meta_key = '_{prefix}_phone' THEN meta_value END) as phone
FROM wp_postmeta
WHERE post_id = %s
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
row = cursor.fetchone()
if not row or not row['first_name']:
return None
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
def _empty_address(self) -> Dict:
"""Return empty address structure"""
return {
'first_name': '', 'last_name': '', 'company_name': '',
'street_address_1': '', 'street_address_2': '',
'city': '', 'postal_code': '', 'country': 'RS', 'phone': ''
}
def _get_order_items(self, order_id: int) -> List[Dict]:
"""Fetch order line items"""
query = """
SELECT
oi.order_item_name as name,
meta_product_id.meta_value as product_id,
meta_sku.meta_value as sku,
meta_qty.meta_value as quantity,
meta_subtotal.meta_value as subtotal,
meta_total.meta_value as total,
meta_tax.meta_value as tax
FROM wp_woocommerce_order_items oi
LEFT JOIN wp_woocommerce_order_itemmeta meta_product_id
ON oi.order_item_id = meta_product_id.order_item_id
AND meta_product_id.meta_key = '_product_id'
LEFT JOIN wp_woocommerce_order_itemmeta meta_sku
ON oi.order_item_id = meta_sku.order_item_id
AND meta_sku.meta_key = '_sku'
LEFT JOIN wp_woocommerce_order_itemmeta meta_qty
ON oi.order_item_id = meta_qty.order_item_id
AND meta_qty.meta_key = '_qty'
LEFT JOIN wp_woocommerce_order_itemmeta meta_subtotal
ON oi.order_item_id = meta_subtotal.order_item_id
AND meta_subtotal.meta_key = '_line_subtotal'
LEFT JOIN wp_woocommerce_order_itemmeta meta_total
ON oi.order_item_id = meta_total.order_item_id
AND meta_total.meta_key = '_line_total'
LEFT JOIN wp_woocommerce_order_itemmeta meta_tax
ON oi.order_item_id = meta_tax.order_item_id
AND meta_tax.meta_key = '_line_tax'
WHERE oi.order_id = %s AND oi.order_item_type = 'line_item'
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
rows = cursor.fetchall()
items = []
for row in rows:
qty = int(row['quantity'] or 1)
items.append({
'product_id': int(row['product_id'] or 0),
'name': row['name'] or '',
'sku': row['sku'] or '',
'quantity': qty,
'subtotal': float(row['subtotal'] or 0) * 100,
'total': float(row['total'] or 0) * 100,
'tax': float(row['tax'] or 0) * 100,
})
return items
class GuestSaleorImporter:
"""Import guest orders into Saleor"""
def __init__(self, saleor_db_config: Dict):
self.conn = psycopg2.connect(
host=saleor_db_config['host'],
port=saleor_db_config['port'],
user=saleor_db_config['user'],
password=saleor_db_config['password'],
database=saleor_db_config['database']
)
self.email_to_user_id: Dict[str, uuid.UUID] = {}
self._ensure_tables()
self._load_existing_mappings()
def _ensure_tables(self):
"""Create mapping tables"""
with self.conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_guest_customer_mapping (
email VARCHAR(255) PRIMARY KEY,
saleor_user_id UUID NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
phone VARCHAR(255),
orders_count INTEGER DEFAULT 0,
total_spent DECIMAL(12,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_order_mapping (
wc_order_id BIGINT PRIMARY KEY,
saleor_order_id UUID NOT NULL,
customer_email VARCHAR(255),
migrated_at TIMESTAMP DEFAULT NOW()
);
""")
self.conn.commit()
def _load_existing_mappings(self):
"""Load existing email→user mappings"""
with self.conn.cursor() as cursor:
cursor.execute("SELECT email, saleor_user_id FROM wc_guest_customer_mapping")
for row in cursor.fetchall():
self.email_to_user_id[row[0]] = row[1]
def get_channel_id(self) -> uuid.UUID:
with self.conn.cursor() as cursor:
cursor.execute("SELECT id FROM channel_channel WHERE slug = 'default-channel' LIMIT 1")
return cursor.fetchone()[0]
def create_customer_from_email(self, customer: GuestCustomer,
dry_run: bool = False) -> Optional[uuid.UUID]:
"""Create a Saleor user from order-derived customer data"""
if customer.email in self.email_to_user_id:
return self.email_to_user_id[customer.email]
new_user_id = uuid.uuid4()
if dry_run:
print(f" [DRY RUN] Would create user: {customer.email}")
return new_user_id
with self.conn.cursor() as cursor:
# Create user with unusable password
cursor.execute("""
INSERT INTO account_user (
id, email, first_name, last_name,
is_staff, is_active, date_joined,
last_login, password
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
new_user_id, customer.email, customer.first_name, customer.last_name,
False, True, customer.first_order_date, None, '!'
))
# Create address if available
if customer.billing_address:
addr_id = uuid.uuid4()
cursor.execute("""
INSERT INTO account_address (
id, first_name, last_name, company_name,
street_address_1, street_address_2, city,
postal_code, country, phone
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
addr_id,
customer.billing_address['first_name'],
customer.billing_address['last_name'],
customer.billing_address['company_name'],
customer.billing_address['street_address_1'],
customer.billing_address['street_address_2'],
customer.billing_address['city'],
customer.billing_address['postal_code'],
customer.billing_address['country'],
customer.billing_address['phone']
))
cursor.execute("""
INSERT INTO account_user_addresses (user_id, address_id)
VALUES (%s, %s)
""", (new_user_id, addr_id))
cursor.execute("""
UPDATE account_user
SET default_billing_address_id = %s,
default_shipping_address_id = %s
WHERE id = %s
""", (addr_id, addr_id, new_user_id))
# Record mapping
cursor.execute("""
INSERT INTO wc_guest_customer_mapping
(email, saleor_user_id, first_name, last_name, phone, orders_count, total_spent)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (customer.email, new_user_id, customer.first_name, customer.last_name,
customer.phone, customer.orders_count, customer.total_spent))
self.conn.commit()
self.email_to_user_id[customer.email] = new_user_id
print(f" Created user: {customer.email} ({customer.orders_count} orders)")
return new_user_id
def import_order(self, order: GuestOrder, mode: str = 'hybrid',
dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import an order
mode: 'guest' = no user account, 'hybrid' = link to created user
"""
# Check if already migrated
with self.conn.cursor() as cursor:
cursor.execute("SELECT saleor_order_id FROM wc_order_mapping WHERE wc_order_id = %s",
(order.wc_order_id,))
if cursor.fetchone():
print(f" Order {order.order_number} already migrated, skipping")
return None
new_order_id = uuid.uuid4()
channel_id = self.get_channel_id()
saleor_status = ORDER_STATUS_MAP.get(order.status, 'UNCONFIRMED')
# Get or create user ID
user_id = None
if mode == 'hybrid' and order.customer_email:
user_id = self.email_to_user_id.get(order.customer_email)
if dry_run:
print(f" [DRY RUN] Would create order: {order.order_number}")
return new_order_id
with self.conn.cursor() as cursor:
# Create billing address record
billing_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_orderbillingaddress (
id, first_name, last_name, company_name,
street_address_1, street_address_2, city,
postal_code, country, phone
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
billing_id,
order.billing_address['first_name'],
order.billing_address['last_name'],
order.billing_address['company_name'],
order.billing_address['street_address_1'],
order.billing_address['street_address_2'],
order.billing_address['city'],
order.billing_address['postal_code'],
order.billing_address['country'],
order.billing_address['phone']
))
# Create shipping address record
shipping_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_ordershippingaddress (
id, first_name, last_name, company_name,
street_address_1, street_address_2, city,
postal_code, country, phone
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
shipping_id,
order.shipping_address['first_name'],
order.shipping_address['last_name'],
order.shipping_address['company_name'],
order.shipping_address['street_address_1'],
order.shipping_address['street_address_2'],
order.shipping_address['city'],
order.shipping_address['postal_code'],
order.shipping_address['country'],
order.shipping_address['phone']
))
# Insert order
cursor.execute("""
INSERT INTO order_order (
id, created_at, updated_at, status,
user_email, user_id, currency,
total_gross_amount, total_net_amount,
shipping_price_gross_amount, shipping_price_net_amount,
shipping_method_name, channel_id,
billing_address_id, shipping_address_id,
billing_address, shipping_address,
metadata, private_metadata,
origin, should_refresh_prices,
tax_exemption, discount_amount,
display_gross_prices, customer_note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
new_order_id, order.date_created, order.date_modified, saleor_status,
order.customer_email, user_id, order.currency,
order.total, order.subtotal,
order.shipping, order.shipping,
order.shipping_method, channel_id,
billing_id, shipping_id,
json.dumps(order.billing_address), json.dumps(order.shipping_address),
json.dumps({'woo_order_id': order.wc_order_id, 'guest_checkout': True}),
'{}',
'BULK_CREATE', False, False, 0.0, True, order.customer_note
))
# Insert order lines
for item in order.items:
# Look up variant by SKU
cursor.execute("SELECT id FROM product_productvariant WHERE sku = %s",
(item['sku'],))
variant_row = cursor.fetchone()
variant_id = variant_row[0] if variant_row else None
qty = item['quantity']
unit_net = item['subtotal'] / qty if qty else 0
unit_gross = (item['subtotal'] + item['tax']) / qty if qty else 0
cursor.execute("""
INSERT INTO order_orderline (
id, order_id, product_name, product_sku,
quantity, currency,
unit_price_net_amount, unit_price_gross_amount,
total_price_net_amount, total_price_gross_amount,
unit_discount_amount, unit_discount_type,
tax_rate, is_shipping_required, variant_id, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
uuid.uuid4(), new_order_id, item['name'], item['sku'],
qty, order.currency, unit_net, unit_gross,
item['subtotal'], item['subtotal'] + item['tax'],
0.0, 'FIXED', '0.15', True, variant_id, order.date_created
))
# Record mapping
cursor.execute("""
INSERT INTO wc_order_mapping (wc_order_id, saleor_order_id, customer_email)
VALUES (%s, %s, %s)
""", (order.wc_order_id, new_order_id, order.customer_email))
self.conn.commit()
user_info = f" (user: {user_id})" if user_id else " (guest)"
print(f" Created order: {order.order_number}{user_info}")
return new_order_id
def main():
parser = argparse.ArgumentParser(description='Migrate WooCommerce Guest Orders to Saleor')
parser.add_argument('--customers', action='store_true',
help='Create customer accounts from unique emails')
parser.add_argument('--orders', action='store_true', help='Migrate orders')
parser.add_argument('--mode', choices=['guest', 'hybrid'], default='hybrid',
help='guest=orders only, hybrid=create customers and link orders')
parser.add_argument('--dry-run', action='store_true', help='Preview changes')
parser.add_argument('--limit', type=int, help='Limit records')
parser.add_argument('--status', type=str, help='Filter by order status')
args = parser.parse_args()
if not args.customers and not args.orders:
print("Please specify --customers and/or --orders")
parser.print_help()
sys.exit(1)
print("=== WooCommerce Guest Orders to Saleor Migration ===\n")
# Connect
print("Connecting to databases...")
try:
exporter = GuestOrderExporter(WP_DB_CONFIG)
importer = GuestSaleorImporter(SALEOR_DB_CONFIG)
print("Connected!\n")
except Exception as e:
print(f"Connection failed: {e}")
sys.exit(1)
# Create customers first (if hybrid mode)
if args.customers or (args.orders and args.mode == 'hybrid'):
print("Extracting unique customers from orders...")
customers = exporter.get_unique_customers()
print(f"Found {len(customers)} unique customers\n")
print("Creating customer accounts...")
for i, customer in enumerate(customers, 1):
print(f"[{i}/{len(customers)}] {customer.email}")
try:
importer.create_customer_from_email(customer, dry_run=args.dry_run)
except Exception as e:
print(f" ERROR: {e}")
print(f"\nCustomer creation {'preview' if args.dry_run else 'complete'}!\n")
# Migrate orders
if args.orders:
print("Fetching orders...")
orders = exporter.get_orders(limit=args.limit, status=args.status)
print(f"Found {len(orders)} orders\n")
print(f"Migrating orders (mode: {args.mode})...")
for i, order in enumerate(orders, 1):
print(f"[{i}/{len(orders)}] {order.order_number} - {order.customer_email}")
try:
importer.import_order(order, mode=args.mode, dry_run=args.dry_run)
except Exception as e:
print(f" ERROR: {e}")
print(f"\nOrder migration {'preview' if args.dry_run else 'complete'}!\n")
print("=== Summary ===")
print(f"Customers: {len(importer.email_to_user_id)}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,201 @@
-- =====================================================
-- WooCommerce GUEST Checkout to Saleor Migration
-- =====================================================
-- For stores without customer accounts - all data is in orders
-- Since there are no customer accounts, we create users from order data
-- Strategy: Create a Saleor user for each unique email in orders
-- Step 1: Create mapping table for email-based customers
CREATE TABLE IF NOT EXISTS wc_guest_customer_mapping (
email VARCHAR(255) PRIMARY KEY,
saleor_user_id UUID,
first_name VARCHAR(255),
last_name VARCHAR(255),
phone VARCHAR(255),
order_count INTEGER DEFAULT 0,
total_spent DECIMAL(12,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- Step 2: Export unique customers from orders (no wp_users needed!)
-- Run this on WordPress/MariaDB:
/*
SELECT DISTINCT
meta_email.meta_value as email,
MAX(meta_first.meta_value) as first_name,
MAX(meta_last.meta_value) as last_name,
MAX(meta_phone.meta_value) as phone,
COUNT(DISTINCT p.ID) as order_count,
SUM(CAST(meta_total.meta_value AS DECIMAL(12,2))) as total_spent
FROM wp_posts p
JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id AND meta_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta meta_first ON p.ID = meta_first.post_id AND meta_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta meta_last ON p.ID = meta_last.post_id AND meta_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta meta_phone ON p.ID = meta_phone.post_id AND meta_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id AND meta_total.meta_key = '_order_total'
WHERE p.post_type = 'shop_order'
AND meta_email.meta_value IS NOT NULL
AND meta_email.meta_value != ''
GROUP BY meta_email.meta_value
ORDER BY order_count DESC;
*/
-- Step 3: Insert guest customers into Saleor
-- For each unique email, create a user account
/*
WITH new_guest_user AS (
INSERT INTO account_user (
id, email, first_name, last_name,
is_staff, is_active, date_joined,
last_login, password
) VALUES (
gen_random_uuid(),
'customer@example.com', -- from order billing_email
'John', -- from order billing_first_name
'Doe', -- from order billing_last_name
false,
true,
NOW(), -- use first order date if available
NULL,
'!' -- unusable password - customer must set via password reset
)
RETURNING id, email
)
INSERT INTO wc_guest_customer_mapping (email, saleor_user_id, first_name, last_name)
SELECT email, id, 'John', 'Doe' FROM new_guest_user;
*/
-- Step 4: Create addresses from most recent order per customer
-- Get the most recent order for each email to extract address
/*
WITH latest_orders AS (
SELECT DISTINCT ON (meta_email.meta_value)
meta_email.meta_value as email,
p.ID as order_id,
p.post_date as order_date
FROM wp_posts p
JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id AND meta_email.meta_key = '_billing_email'
WHERE p.post_type = 'shop_order'
ORDER BY meta_email.meta_value, p.post_date DESC
),
address_data AS (
SELECT
lo.email,
MAX(CASE WHEN pm.meta_key = '_billing_first_name' THEN pm.meta_value END) as bill_first,
MAX(CASE WHEN pm.meta_key = '_billing_last_name' THEN pm.meta_value END) as bill_last,
MAX(CASE WHEN pm.meta_key = '_billing_company' THEN pm.meta_value END) as bill_company,
MAX(CASE WHEN pm.meta_key = '_billing_address_1' THEN pm.meta_value END) as bill_addr1,
MAX(CASE WHEN pm.meta_key = '_billing_address_2' THEN pm.meta_value END) as bill_addr2,
MAX(CASE WHEN pm.meta_key = '_billing_city' THEN pm.meta_value END) as bill_city,
MAX(CASE WHEN pm.meta_key = '_billing_postcode' THEN pm.meta_value END) as bill_postcode,
MAX(CASE WHEN pm.meta_key = '_billing_country' THEN pm.meta_value END) as bill_country,
MAX(CASE WHEN pm.meta_key = '_billing_phone' THEN pm.meta_value END) as bill_phone,
MAX(CASE WHEN pm.meta_key = '_shipping_first_name' THEN pm.meta_value END) as ship_first,
MAX(CASE WHEN pm.meta_key = '_shipping_last_name' THEN pm.meta_value END) as ship_last,
MAX(CASE WHEN pm.meta_key = '_shipping_company' THEN pm.meta_value END) as ship_company,
MAX(CASE WHEN pm.meta_key = '_shipping_address_1' THEN pm.meta_value END) as ship_addr1,
MAX(CASE WHEN pm.meta_key = '_shipping_address_2' THEN pm.meta_value END) as ship_addr2,
MAX(CASE WHEN pm.meta_key = '_shipping_city' THEN pm.meta_value END) as ship_city,
MAX(CASE WHEN pm.meta_key = '_shipping_postcode' THEN pm.meta_value END) as ship_postcode,
MAX(CASE WHEN pm.meta_key = '_shipping_country' THEN pm.meta_value END) as ship_country
FROM latest_orders lo
JOIN wp_postmeta pm ON lo.order_id = pm.post_id
GROUP BY lo.email
)
-- Insert billing address and link to user
INSERT INTO account_address (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
SELECT
gen_random_uuid(),
bill_first, bill_last, COALESCE(bill_company, ''),
bill_addr1, COALESCE(bill_addr2, ''), bill_city,
bill_postcode, COALESCE(bill_country, 'RS'), COALESCE(bill_phone, '')
FROM address_data ad
JOIN wc_guest_customer_mapping cm ON ad.email = cm.email
WHERE cm.saleor_user_id IS NOT NULL
RETURNING id, (SELECT email FROM wc_guest_customer_mapping WHERE saleor_user_id =
(SELECT id FROM account_user WHERE id = account_address.id)); -- This needs adjustment
-- Then link addresses to users via account_user_addresses
*/
-- Alternative simpler approach: Insert order with addresses inline (no separate customer record)
-- Saleor supports orders without user accounts (guest orders)
-- =====================================================
-- SIMPLIFIED: Orders Only (No Customer Accounts)
-- =====================================================
-- If you don't want to create customer accounts at all,
-- just migrate orders as guest orders with email addresses
/*
INSERT INTO order_order (
id, created_at, updated_at, status,
user_email, -- Store email here (no user_id)
user_id, -- NULL for guest orders
currency, total_gross_amount, total_net_amount,
shipping_price_gross_amount, shipping_price_net_amount,
shipping_method_name, channel_id,
billing_address, -- JSON with full address
shipping_address, -- JSON with full address
metadata, origin,
should_refresh_prices, tax_exemption,
discount_amount, display_gross_prices,
customer_note
) VALUES (
gen_random_uuid(),
'2024-01-15 10:30:00'::timestamp,
'2024-01-15 10:30:00'::timestamp,
'FULFILLED',
'guest@example.com', -- Customer email from order
NULL, -- No user account (guest order)
'RSD',
11500.00,
10000.00,
500.00,
500.00,
'Flat Rate',
(SELECT id FROM channel_channel WHERE slug = 'default-channel'),
'{
"first_name": "John",
"last_name": "Doe",
"street_address_1": "Kneza Milosa 10",
"city": "Belgrade",
"postal_code": "11000",
"country": "RS",
"phone": "+38164123456"
}'::jsonb,
'{
"first_name": "John",
"last_name": "Doe",
"street_address_1": "Kneza Milosa 10",
"city": "Belgrade",
"postal_code": "11000",
"country": "RS",
"phone": "+38164123456"
}'::jsonb,
'{"woo_order_id": "12345", "guest_checkout": true}'::jsonb,
'BULK_CREATE',
false,
false,
0.00,
true,
''
);
*/
-- =====================================================
-- RECOMMENDED APPROACH: Hybrid
-- =====================================================
-- 1. Create lightweight user accounts from unique emails
-- 2. Link all orders to these accounts
-- 3. Customers can claim accounts via password reset
-- Benefits:
-- - Order history tied to email
-- - Customers can "activate" their account later
-- - Better analytics (LTV per customer)
-- - Future marketing (targeted emails)

464
src/app/checkout/page.tsx Normal file
View File

@@ -0,0 +1,464 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { formatPrice } from "@/lib/saleor";
import { saleorClient } from "@/lib/saleor/client";
import {
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
CHECKOUT_BILLING_ADDRESS_UPDATE,
CHECKOUT_COMPLETE,
} from "@/lib/saleor/mutations/Checkout";
interface AddressForm {
firstName: string;
lastName: string;
streetAddress1: string;
streetAddress2: string;
city: string;
postalCode: string;
phone: string;
}
export default function CheckoutPage() {
const router = useRouter();
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [orderComplete, setOrderComplete] = useState(false);
const [orderNumber, setOrderNumber] = useState<string | null>(null);
const [sameAsShipping, setSameAsShipping] = useState(true);
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
firstName: "",
lastName: "",
streetAddress1: "",
streetAddress2: "",
city: "",
postalCode: "",
phone: "",
});
const [billingAddress, setBillingAddress] = useState<AddressForm>({
firstName: "",
lastName: "",
streetAddress1: "",
streetAddress2: "",
city: "",
postalCode: "",
phone: "",
});
const lines = getLines();
const total = getTotal();
useEffect(() => {
if (!checkout) {
refreshCheckout();
}
}, [checkout, refreshCheckout]);
// Redirect if cart is empty
useEffect(() => {
if (lines.length === 0 && !orderComplete) {
// Optionally redirect to cart or products
// router.push("/products");
}
}, [lines, orderComplete, router]);
const handleShippingChange = (field: keyof AddressForm, value: string) => {
setShippingAddress((prev) => ({ ...prev, [field]: value }));
if (sameAsShipping) {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
}
};
const handleBillingChange = (field: keyof AddressForm, value: string) => {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!checkout) {
setError("No active checkout. Please try again.");
return;
}
setIsLoading(true);
setError(null);
try {
// Update shipping address
const shippingResult = await saleorClient.mutate({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
...shippingAddress,
country: "RS", // Serbia
},
},
});
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors?.length > 0) {
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
}
// Update billing address
const billingResult = await saleorClient.mutate({
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
billingAddress: {
...billingAddress,
country: "RS",
},
},
});
if (billingResult.data?.checkoutBillingAddressUpdate?.errors?.length > 0) {
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
}
// Complete checkout (creates order)
const completeResult = await saleorClient.mutate({
mutation: CHECKOUT_COMPLETE,
variables: {
checkoutId: checkout.id,
},
});
if (completeResult.data?.checkoutComplete?.errors?.length > 0) {
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
}
const order = completeResult.data?.checkoutComplete?.order;
if (order) {
setOrderNumber(order.number);
setOrderComplete(true);
} else {
throw new Error("Failed to create order");
}
} catch (err: any) {
setError(err.message || "An error occurred during checkout");
} finally {
setIsLoading(false);
}
};
// Order Success Page
if (orderComplete) {
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-3xl font-serif mb-2">Order Confirmed!</h1>
<p className="text-foreground-muted">Thank you for your purchase.</p>
</div>
{orderNumber && (
<div className="bg-background-ice p-6 rounded-lg mb-6">
<p className="text-sm text-foreground-muted mb-1">Order Number</p>
<p className="text-2xl font-serif">#{orderNumber}</p>
</div>
)}
<p className="text-foreground-muted mb-8">
You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.
</p>
<Link
href="/products"
className="inline-block px-8 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
>
Continue Shopping
</Link>
</div>
</section>
<Footer />
</main>
);
}
return (
<main className="min-h-screen">
<Header />
<section className="pt-24 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-serif mb-8">Checkout</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 p-4 mb-6 rounded">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Checkout Form */}
<div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Shipping Address */}
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Shipping Address</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
required
value={shippingAddress.firstName}
onChange={(e) => handleShippingChange("firstName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
required
value={shippingAddress.lastName}
onChange={(e) => handleShippingChange("lastName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Street Address</label>
<input
type="text"
required
value={shippingAddress.streetAddress1}
onChange={(e) => handleShippingChange("streetAddress1", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<input
type="text"
value={shippingAddress.streetAddress2}
onChange={(e) => handleShippingChange("streetAddress2", e.target.value)}
placeholder="Apartment, suite, etc. (optional)"
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input
type="text"
required
value={shippingAddress.city}
onChange={(e) => handleShippingChange("city", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Postal Code</label>
<input
type="text"
required
value={shippingAddress.postalCode}
onChange={(e) => handleShippingChange("postalCode", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
required
value={shippingAddress.phone}
onChange={(e) => handleShippingChange("phone", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
</div>
</div>
{/* Billing Address Toggle */}
<div className="border-b border-border pb-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={sameAsShipping}
onChange={(e) => setSameAsShipping(e.target.checked)}
className="w-4 h-4"
/>
<span>Billing address same as shipping</span>
</label>
</div>
{/* Billing Address (if different) */}
{!sameAsShipping && (
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Billing Address</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
required
value={billingAddress.firstName}
onChange={(e) => handleBillingChange("firstName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
required
value={billingAddress.lastName}
onChange={(e) => handleBillingChange("lastName", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Street Address</label>
<input
type="text"
required
value={billingAddress.streetAddress1}
onChange={(e) => handleBillingChange("streetAddress1", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input
type="text"
required
value={billingAddress.city}
onChange={(e) => handleBillingChange("city", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Postal Code</label>
<input
type="text"
required
value={billingAddress.postalCode}
onChange={(e) => handleBillingChange("postalCode", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
required
value={billingAddress.phone}
onChange={(e) => handleBillingChange("phone", e.target.value)}
className="w-full border border-border px-4 py-2 rounded"
/>
</div>
</div>
</div>
)}
{/* Payment Method */}
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">Payment Method</h2>
<div className="bg-background-ice p-4 rounded">
<div className="flex items-center gap-3">
<input
type="radio"
checked
readOnly
className="w-4 h-4"
/>
<span>Cash on Delivery (COD)</span>
</div>
<p className="text-sm text-foreground-muted mt-2 ml-7">
Pay when your order is delivered to your door.
</p>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading || lines.length === 0}
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
>
{isLoading ? "Processing..." : `Complete Order - ${formatPrice(total)}`}
</button>
</form>
</div>
{/* Order Summary */}
<div className="bg-background-ice p-6 rounded-lg h-fit">
<h2 className="text-xl font-serif mb-6">Order Summary</h2>
{lines.length === 0 ? (
<p className="text-foreground-muted">Your cart is empty</p>
) : (
<>
<div className="space-y-4 mb-6">
{lines.map((line) => (
<div key={line.id} className="flex gap-4">
<div className="w-16 h-16 bg-white relative flex-shrink-0">
{line.variant.product.media[0]?.url && (
<Image
src={line.variant.product.media[0].url}
alt={line.variant.product.name}
fill
className="object-cover"
/>
)}
</div>
<div className="flex-1">
<h3 className="font-medium text-sm">{line.variant.product.name}</h3>
<p className="text-foreground-muted text-sm">
Qty: {line.quantity}
</p>
<p className="text-sm">
{formatPrice(line.totalPrice.gross.amount)}
</p>
</div>
</div>
))}
</div>
<div className="border-t border-border pt-4 space-y-2">
<div className="flex justify-between">
<span className="text-foreground-muted">Subtotal</span>
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-foreground-muted">Shipping</span>
<span>
{checkout?.shippingPrice?.gross?.amount
? formatPrice(checkout.shippingPrice.gross.amount)
: "Calculated"
}
</span>
</div>
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
</div>
</>
)}
</div>
</div>
</div>
</section>
<Footer />
</main>
);
}

View File

@@ -1,44 +1,64 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
import AnnouncementBar from "@/components/home/AnnouncementBar";
import NewHero from "@/components/home/NewHero";
import StatsSection from "@/components/home/StatsSection";
import FeaturesSection from "@/components/home/FeaturesSection";
import TestimonialsSection from "@/components/home/TestimonialsSection";
import NewsletterSection from "@/components/home/NewsletterSection";
export const metadata = {
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
description:
"Discover our premium collection of natural oils for hair and skin care. Handmade with love using only the finest ingredients.",
};
export default async function Homepage() {
const products = await getProducts();
const publishedProducts = products.filter((p) => p.status === "publish").slice(0, 4);
let products: any[] = [];
try {
products = await getProducts();
} catch (e) {
// Fallback for build time when API is unavailable
console.log('Failed to fetch products during build');
}
const featuredProduct = products.find((p) => p.status === "publish");
const publishedProducts = products
.filter((p) => p.status === "publish")
.slice(0, 4);
return (
<main className="min-h-screen">
<Header />
{/* Hero Section */}
<section className="relative h-[80vh] flex items-center justify-center bg-gradient-to-b from-white to-background-ice">
<div className="text-center px-4">
<h1 className="text-5xl md:text-7xl font-serif mb-6">
ManoonOils
</h1>
<p className="text-xl md:text-2xl text-foreground-muted mb-8">
Premium Natural Oils for Hair & Skin
</p>
<a
href="/products"
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium hover:bg-opacity-90 transition-all"
>
Shop Now
</a>
</div>
</section>
<main className="min-h-screen bg-white">
<AnnouncementBar />
<div className="pt-10">
<Header />
</div>
{/* Products Section */}
{/* New Hero Section */}
<NewHero featuredProduct={featuredProduct} />
{/* Stats & Philosophy Section */}
<StatsSection />
{/* Features Section */}
<FeaturesSection />
{/* Testimonials Section */}
<TestimonialsSection />
{/* Newsletter Section */}
<NewsletterSection />
{/* Products Grid Section */}
{publishedProducts.length > 0 && (
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h2 className="text-4xl font-serif text-center mb-12">Our Products</h2>
<section className="py-20 px-6 bg-white">
<div className="max-w-[1400px] mx-auto">
<h2 className="font-serif italic text-4xl text-center mb-4">
Our Collection
</h2>
<p className="text-center text-[#4A4A4A] mb-12 max-w-2xl mx-auto">
Cold-pressed, pure, and natural oils for your daily beauty routine
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
@@ -48,20 +68,10 @@ export default async function Homepage() {
</section>
)}
{/* About Teaser */}
<section className="py-20 px-4 bg-background-ice">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-serif mb-6">Natural & Pure</h2>
<p className="text-lg text-foreground-muted mb-8">
Our oils are crafted with love using only the finest natural ingredients.
</p>
<a href="/about" className="text-foreground border-b border-foreground pb-1">
Learn More
</a>
</div>
</section>
<Footer />
</main>
);
}
// Import ProductCard here to avoid circular dependency
import ProductCard from "@/components/product/ProductCard";

View File

@@ -2,16 +2,10 @@ import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export async function generateStaticParams() {
try {
const products = await getProducts();
return products.map((product) => ({
slug: product.slug || product.id.toString(),
}));
} catch {
return [];
}
}
export const dynamic = 'force-dynamic';
// Disable static generation - this page will be server-rendered
export const generateStaticParams = undefined;
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;

View File

@@ -9,7 +9,12 @@ export const metadata = {
};
export default async function ProductsPage() {
const products = await getProducts();
let products: any[] = [];
try {
products = await getProducts();
} catch (e) {
console.log('Failed to fetch products during build');
}
const publishedProducts = products.filter((p) => p.status === "publish");

View File

@@ -53,3 +53,62 @@ body {
h1, h2, h3, h4, h5, h6 {
font-family: 'Cedrat Display', serif;
}
/* Marquee Animations */
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@keyframes marquee-slow {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-marquee {
animation: marquee 25s linear infinite;
}
.animate-marquee-slow {
animation: marquee-slow 35s linear infinite;
}
.animate-marquee-fast {
animation: marquee 15s linear infinite;
}
/* Utility Classes */
.font-serif {
font-family: 'Cedrat Display', serif;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

View File

@@ -1,44 +1,64 @@
import { getProducts } from "@/lib/woocommerce";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
import AnnouncementBar from "@/components/home/AnnouncementBar";
import NewHero from "@/components/home/NewHero";
import StatsSection from "@/components/home/StatsSection";
import FeaturesSection from "@/components/home/FeaturesSection";
import TestimonialsSection from "@/components/home/TestimonialsSection";
import NewsletterSection from "@/components/home/NewsletterSection";
export const metadata = {
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care. Handmade with love.",
description:
"Discover our premium collection of natural oils for hair and skin care. Handmade with love using only the finest ingredients.",
};
export default async function Homepage() {
const products = await getProducts();
const publishedProducts = products.filter((p) => p.status === "publish").slice(0, 4);
let products: any[] = [];
try {
products = await getProducts();
} catch (e) {
// Fallback for build time when API is unavailable
console.log('Failed to fetch products during build');
}
const featuredProduct = products.find((p) => p.status === "publish");
const publishedProducts = products
.filter((p) => p.status === "publish")
.slice(0, 4);
return (
<main className="min-h-screen">
<Header />
{/* Hero Section */}
<section className="relative h-[80vh] flex items-center justify-center bg-gradient-to-b from-white to-background-ice">
<div className="text-center px-4">
<h1 className="text-5xl md:text-7xl font-serif mb-6">
ManoonOils
</h1>
<p className="text-xl md:text-2xl text-foreground-muted mb-8">
Premium Natural Oils for Hair & Skin
</p>
<a
href="/products"
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium hover:bg-opacity-90 transition-all"
>
Shop Now
</a>
</div>
</section>
<main className="min-h-screen bg-white">
<AnnouncementBar />
<div className="pt-10">
<Header />
</div>
{/* Products Section */}
{/* New Hero Section */}
<NewHero featuredProduct={featuredProduct} />
{/* Stats & Philosophy Section */}
<StatsSection />
{/* Features Section */}
<FeaturesSection />
{/* Testimonials Section */}
<TestimonialsSection />
{/* Newsletter Section */}
<NewsletterSection />
{/* Products Grid Section */}
{publishedProducts.length > 0 && (
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h2 className="text-4xl font-serif text-center mb-12">Our Products</h2>
<section className="py-20 px-6 bg-white">
<div className="max-w-[1400px] mx-auto">
<h2 className="font-serif italic text-4xl text-center mb-4">
Our Collection
</h2>
<p className="text-center text-[#4A4A4A] mb-12 max-w-2xl mx-auto">
Cold-pressed, pure, and natural oils for your daily beauty routine
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
@@ -48,20 +68,10 @@ export default async function Homepage() {
</section>
)}
{/* About Teaser */}
<section className="py-20 px-4 bg-background-ice">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-serif mb-6">Natural & Pure</h2>
<p className="text-lg text-foreground-muted mb-8">
Our oils are crafted with love using only the finest natural ingredients.
</p>
<a href="/about" className="text-foreground border-b border-foreground pb-1">
Learn More
</a>
</div>
</section>
<Footer />
</main>
);
}
// Import ProductCard here to avoid circular dependency
import ProductCard from "@/components/product/ProductCard";

View File

@@ -1,43 +1,93 @@
import { getProducts } from "@/lib/woocommerce";
import Image from "next/image";
import { getProductBySlug, getProducts, getProductPrice, getProductImage, getLocalizedProduct, formatPrice } from "@/lib/saleor";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import type { Product } from "@/types/saleor";
interface ProductPageProps {
params: Promise<{ slug: string; locale?: string }>;
}
// Generate static params for all products
export async function generateStaticParams() {
try {
const products = await getProducts();
return products.map((product) => ({
slug: product.slug || product.id.toString(),
}));
} catch {
const products = await getProducts("SR", 100);
const params: Array<{ slug: string; locale: string }> = [];
products.forEach((product: Product) => {
// Serbian slug
params.push({ slug: product.slug, locale: "sr" });
// English slug (if translation exists)
if (product.translation?.slug) {
params.push({ slug: product.translation.slug, locale: "en" });
}
});
return params;
} catch (e) {
return [];
}
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let product = null;
export async function generateMetadata({ params }: ProductPageProps) {
const { slug, locale = "sr" } = await params;
const product = await getProductBySlug(slug, locale.toUpperCase());
try {
const products = await getProducts();
product = products.find((p) => (p.slug || p.id.toString()) === slug);
} catch (e) {
// Fallback
if (!product) {
return {
title: locale === "en" ? "Product Not Found" : "Proizvod nije pronađen",
};
}
const localized = getLocalizedProduct(product, locale.toUpperCase());
return {
title: localized.name,
description: localized.seoDescription || localized.description?.slice(0, 160),
alternates: {
canonical: `/products/${product.slug}`,
languages: {
"sr": `/products/${product.slug}`,
"en": product.translation?.slug ? `/products/${product.translation.slug}` : `/products/${product.slug}`,
},
},
};
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug, locale = "sr" } = await params;
const product = await getProductBySlug(slug, locale.toUpperCase());
if (!product) {
return (
<main className="min-h-screen">
<Header />
<div className="pt-24 text-center">
<h1 className="text-2xl">Product not found</h1>
<h1 className="text-2xl">
{locale === "en" ? "Product not found" : "Proizvod nije pronađen"}
</h1>
</div>
<Footer />
</main>
);
}
const image = product.images?.[0]?.src || '/placeholder.jpg';
const price = product.sale_price || product.price;
const localized = getLocalizedProduct(product, locale.toUpperCase());
const image = getProductImage(product);
const price = getProductPrice(product);
const variant = product.variants?.[0];
const isAvailable = variant?.quantityAvailable > 0;
// Determine language based on which slug matched
const isEnglishSlug = slug === product.translation?.slug;
const currentLocale = isEnglishSlug ? "en" : "sr";
// URLs for language switcher
const serbianUrl = `/products/${product.slug}`;
const englishUrl = product.translation?.slug
? `/products/${product.translation.slug}`
: serbianUrl;
return (
<main className="min-h-screen">
@@ -46,26 +96,70 @@ export default async function ProductPage({ params }: { params: Promise<{ slug:
<section className="pt-24 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* Product Image */}
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden">
<img
src={image}
alt={product.name}
className="object-cover w-full h-full"
<Image
src={image}
alt={localized.name}
fill
className="object-cover"
priority
/>
</div>
{/* Product Info */}
<div className="flex flex-col">
<h1 className="text-4xl font-serif mb-4">{product.name}</h1>
<h1 className="text-4xl font-serif mb-4">{localized.name}</h1>
<div className="text-2xl mb-6">{price} RSD</div>
{price && (
<div className="text-2xl mb-6">{price}</div>
)}
<div className="prose max-w-none mb-8" dangerouslySetInnerHTML={{ __html: product.description || '' }} />
{localized.description && (
<div
className="prose max-w-none mb-8"
dangerouslySetInnerHTML={{ __html: localized.description }}
/>
)}
{/* Add to Cart Button */}
<button
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all"
className="inline-block bg-foreground text-white px-8 py-4 text-lg font-medium text-center hover:bg-opacity-90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!isAvailable}
>
Add to Cart
{isAvailable
? (currentLocale === "en" ? "Add to Cart" : "Dodaj u korpu")
: (currentLocale === "en" ? "Out of Stock" : "Nema na stanju")
}
</button>
{/* SKU */}
{variant?.sku && (
<p className="mt-4 text-sm text-foreground-muted">
SKU: {variant.sku}
</p>
)}
{/* Language Switcher */}
<div className="mt-8 pt-8 border-t">
<p className="text-sm text-foreground-muted mb-2">
{currentLocale === "en" ? "Language:" : "Jezik:"}
</p>
<div className="flex gap-4">
<a
href={serbianUrl}
className={`text-sm font-medium ${currentLocale === "sr" ? "text-foreground" : "text-foreground-muted hover:text-foreground"}`}
>
🇷🇸 Srpski
</a>
<a
href={englishUrl}
className={`text-sm font-medium ${currentLocale === "en" ? "text-foreground" : "text-foreground-muted hover:text-foreground"}`}
>
🇬🇧 English
</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { getProducts } from "@/lib/woocommerce";
import { getProducts } from "@/lib/saleor";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
@@ -8,10 +8,13 @@ export const metadata = {
description: "Browse our collection of premium natural oils for hair and skin care.",
};
export default async function ProductsPage() {
const products = await getProducts();
const publishedProducts = products.filter((p) => p.status === "publish");
interface ProductsPageProps {
params: Promise<{ locale: string }>;
}
export default async function ProductsPage({ params }: ProductsPageProps) {
const { locale = "sr" } = await params;
const products = await getProducts(locale.toUpperCase());
return (
<main className="min-h-screen pt-16 md:pt-20">
@@ -20,15 +23,22 @@ export default async function ProductsPage() {
<section className="py-20 px-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl md:text-5xl font-serif text-center mb-16">
All Products
{locale === "en" ? "All Products" : "Svi Proizvodi"}
</h1>
{publishedProducts.length === 0 ? (
<p className="text-center text-foreground-muted">No products available</p>
{products.length === 0 ? (
<p className="text-center text-foreground-muted">
{locale === "en" ? "No products available" : "Nema dostupnih proizvoda"}
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{publishedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
{products.map((product, index) => (
<ProductCard
key={product.id}
product={product}
index={index}
locale={locale.toUpperCase()}
/>
))}
</div>
)}

View File

@@ -4,7 +4,12 @@ import { getProducts } from "@/lib/woocommerce";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const products = await getProducts();
let products: any[] = [];
try {
products = await getProducts();
} catch (e) {
console.log('Failed to fetch products for sitemap during build');
}
const productUrls = products
.filter((p) => p.status === "publish")

View File

@@ -1,20 +1,42 @@
"use client";
import { useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useCartStore } from "@/stores/cartStore";
import { formatPrice } from "@/lib/woocommerce";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { formatPrice } from "@/lib/saleor";
export default function CartDrawer() {
const { items, isOpen, closeCart, removeItem, updateQuantity, getTotal } = useCartStore();
const {
checkout,
isOpen,
isLoading,
error,
closeCart,
removeLine,
updateLine,
getTotal,
getLineCount,
getLines,
initCheckout,
clearError,
} = useSaleorCheckoutStore();
const lines = getLines();
const total = getTotal();
const lineCount = getLineCount();
// Initialize checkout on mount
useEffect(() => {
initCheckout();
}, [initCheckout]);
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 bg-black/50 z-50"
initial={{ opacity: 0 }}
@@ -22,6 +44,8 @@ export default function CartDrawer() {
exit={{ opacity: 0 }}
onClick={closeCart}
/>
{/* Drawer */}
<motion.div
className="fixed top-0 right-0 bottom-0 w-full max-w-md bg-white z-50 shadow-xl flex flex-col"
initial={{ x: "100%" }}
@@ -29,8 +53,11 @@ export default function CartDrawer() {
exit={{ x: "100%" }}
transition={{ type: "tween", duration: 0.3 }}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border/30">
<h2 className="text-xl font-serif">Your Cart</h2>
<h2 className="text-xl font-serif">
Your Cart ({lineCount})
</h2>
<button
onClick={closeCart}
className="p-2"
@@ -42,12 +69,26 @@ export default function CartDrawer() {
</button>
</div>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-50 border-b border-red-100">
<p className="text-red-600 text-sm">{error}</p>
<button
onClick={clearError}
className="text-red-600 text-xs underline mt-1"
>
Dismiss
</button>
</div>
)}
{/* Cart Items */}
<div className="flex-1 overflow-y-auto p-6">
{items.length === 0 ? (
{lines.length === 0 ? (
<div className="text-center py-12">
<p className="text-foreground-muted mb-6">Your cart is empty</p>
<Link
href="/en/products"
href="/products"
onClick={closeCart}
className="inline-block px-6 py-3 bg-foreground text-white"
>
@@ -56,39 +97,53 @@ export default function CartDrawer() {
</div>
) : (
<div className="space-y-6">
{items.map((item) => (
<div key={item.id} className="flex gap-4">
{lines.map((line) => (
<div key={line.id} className="flex gap-4">
{/* Product Image */}
<div className="w-20 h-20 bg-background-ice relative flex-shrink-0">
{item.image && (
{line.variant.product.media[0]?.url && (
<Image
src={item.image}
alt={item.name}
src={line.variant.product.media[0].url}
alt={line.variant.product.name}
fill
className="object-cover"
/>
)}
</div>
{/* Product Info */}
<div className="flex-1">
<h3 className="font-serif text-sm">{item.name}</h3>
<h3 className="font-serif text-sm">{line.variant.product.name}</h3>
{line.variant.name !== "Default" && (
<p className="text-foreground-muted text-xs">{line.variant.name}</p>
)}
<p className="text-foreground-muted text-sm mt-1">
{formatPrice(item.price)}
{formatPrice(
line.variant.pricing?.price?.gross?.amount || 0,
line.variant.pricing?.price?.gross?.currency
)}
</p>
{/* Quantity Controls */}
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className="w-8 h-8 border border-border flex items-center justify-center"
onClick={() => updateLine(line.id, line.quantity - 1)}
disabled={isLoading}
className="w-8 h-8 border border-border flex items-center justify-center disabled:opacity-50"
>
-
</button>
<span>{item.quantity}</span>
<span>{line.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="w-8 h-8 border border-border flex items-center justify-center"
onClick={() => updateLine(line.id, line.quantity + 1)}
disabled={isLoading}
className="w-8 h-8 border border-border flex items-center justify-center disabled:opacity-50"
>
+
</button>
<button
onClick={() => removeItem(item.id)}
onClick={() => removeLine(line.id)}
disabled={isLoading}
className="ml-auto text-foreground-muted hover:text-red-500"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -103,18 +158,48 @@ export default function CartDrawer() {
)}
</div>
{items.length > 0 && (
{/* Footer with Checkout */}
{lines.length > 0 && (
<div className="p-6 border-t border-border/30">
<div className="flex items-center justify-between mb-4">
<span className="font-serif">Subtotal</span>
<span className="font-serif text-lg">{formatPrice(total.toString())}</span>
{/* Subtotal */}
<div className="flex items-center justify-between mb-2">
<span className="text-foreground-muted">Subtotal</span>
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
</div>
<a
href="https://manoonoils.com/checkout"
className="block w-full py-3 bg-foreground text-white text-center font-medium hover:bg-accent-dark transition-colors"
{/* Shipping */}
<div className="flex items-center justify-between mb-4">
<span className="text-foreground-muted">Shipping</span>
<span>
{checkout?.shippingPrice?.gross?.amount
? formatPrice(checkout.shippingPrice.gross.amount)
: "Calculated at checkout"
}
</span>
</div>
{/* Total */}
<div className="flex items-center justify-between mb-4 pt-4 border-t border-border/30">
<span className="font-serif">Total</span>
<span className="font-serif text-lg">{formatPrice(total)}</span>
</div>
{/* Checkout Button */}
<Link
href="/checkout"
onClick={closeCart}
className="block w-full py-3 bg-foreground text-white text-center font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
>
Checkout
</a>
{isLoading ? "Processing..." : "Checkout"}
</Link>
{/* Continue Shopping */}
<button
onClick={closeCart}
className="block w-full py-3 text-center text-foreground-muted hover:text-foreground mt-2"
>
Continue Shopping
</button>
</div>
)}
</motion.div>

View File

@@ -0,0 +1,34 @@
"use client";
import { ArrowRight } from "lucide-react";
export default function AnnouncementBar() {
const items = [
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
"PREMIUM NATURAL OILS FOR HAIR & SKIN",
];
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-[#E8F4F8] overflow-hidden">
<div className="flex animate-marquee whitespace-nowrap py-2 will-change-transform">
{items.map((text, index) => (
<div
key={index}
className="inline-flex items-center gap-4 mx-5 shrink-0"
>
<span className="text-xs tracking-[0.12em] uppercase text-[#1A1A1A]/70 font-medium">
{text}
</span>
<ArrowRight className="w-4 h-4 text-[#1A1A1A]/50" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { motion } from "framer-motion";
import { Droplet, Shield, Clock, Heart } from "lucide-react";
const features = [
{
icon: Droplet,
title: "Deep Hydration & Nourishment",
description:
"Our cold-pressed oils penetrate deep into hair and skin, delivering essential fatty acids and vitamins for lasting moisture without greasiness.",
},
{
icon: Shield,
title: "Natural Protection",
description:
"Rich in antioxidants, our oils shield your hair and skin from environmental stressors, UV damage, and pollution.",
},
{
icon: Clock,
title: "Anti-Ageing Benefits",
description:
"Packed with vitamin E and essential nutrients that promote collagen production and cellular renewal for youthful skin and healthy hair.",
},
{
icon: Heart,
title: "Gentle for All Types",
description:
"100% natural, cruelty-free formulas suitable for sensitive skin and all hair types. No synthetic fragrances or harsh chemicals.",
},
];
export default function FeaturesSection() {
return (
<section className="py-24 lg:py-32 bg-white">
<div className="max-w-[1400px] mx-auto px-6">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-20">
{/* Left Content */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<span className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block">
The Science
</span>
<h2 className="font-serif italic text-4xl lg:text-5xl xl:text-6xl text-[#1A1A1A] tracking-tight leading-[1.1] mb-6">
You have needs,
<br />
we have answers
</h2>
</motion.div>
{/* Right Features List */}
<div className="space-y-0">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: index * 0.1 }}
className="border-b border-dashed border-[#1A1A1A]/10 py-6 first:pt-0 last:border-b-0"
>
<div className="flex items-start gap-5">
<div className="shrink-0 text-[#1A1A1A]/70 mt-0.5">
<feature.icon className="w-5 h-5" />
</div>
<div>
<h3 className="text-[#1A1A1A] font-medium text-base tracking-wide mb-1.5">
{feature.title}
</h3>
<p className="text-[#4A4A4A] text-sm leading-relaxed">
{feature.description}
</p>
</div>
</div>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,181 @@
"use client";
import { motion } from "framer-motion";
import { Star, ShoppingBag } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useCartStore } from "@/stores/cartStore";
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
interface NewHeroProps {
featuredProduct?: WooProduct;
}
export default function NewHero({ featuredProduct }: NewHeroProps) {
const { addItem, openCart } = useCartStore();
const handleAddToCart = () => {
if (featuredProduct) {
addItem({
id: featuredProduct.id,
name: featuredProduct.name,
price: featuredProduct.price,
quantity: 1,
image: getProductImage(featuredProduct),
sku: featuredProduct.sku,
});
openCart();
}
};
return (
<section className="relative h-screen min-h-[700px] flex flex-col overflow-hidden pt-10">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<div className="absolute inset-0 bg-gradient-to-b from-[#E8F4F8]/30 to-white/80" />
<div className="absolute inset-0 bg-gradient-to-r from-[#E8F4F8]/50 via-transparent to-[#E8F4F8]/30" />
</div>
{/* Mobile Hero Text */}
<div className="relative z-10 flex flex-col justify-end items-center text-center p-6 pb-12 lg:hidden">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="font-serif italic text-3xl text-[#1A1A1A] leading-[1.15] tracking-tight"
>
Natural Oils,
<br />
Real Results
</motion.h1>
</div>
{/* Desktop Floating Product Card */}
<div className="hidden lg:block absolute left-10 xl:left-20 top-32 z-10">
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="bg-white/95 backdrop-blur-md w-[320px] xl:w-[360px] rounded-[4px] overflow-hidden shadow-lg"
>
{featuredProduct ? (
<>
{/* Product Image */}
<div className="relative aspect-square bg-[#E8F4F8]">
<Image
src={getProductImage(featuredProduct)}
alt={featuredProduct.name}
fill
className="object-cover"
priority
/>
</div>
<div className="p-5">
{/* Title & Rating */}
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-medium text-[#1A1A1A]">
{featuredProduct.name}
</h3>
<div className="flex items-center gap-0.5 shrink-0">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className="w-3.5 h-3.5 fill-amber-400 text-amber-400"
/>
))}
</div>
</div>
{/* Description */}
<p className="text-sm text-[#4A4A4A]/70 mt-1 line-clamp-2">
{featuredProduct.short_description?.replace(/<[^>]*>/g, "") ||
"Premium natural oil for hair and skin care"}
</p>
{/* Tech Badge */}
<div className="mt-3 pt-3 border-t border-[#1A1A1A]/6">
<p className="text-xs font-medium text-[#1A1A1A] tracking-wide">
COLD-PRESSED TECHNOLOGY
</p>
<p className="text-xs text-[#4A4A4A]/60 mt-0.5 leading-relaxed">
Pure extraction method preserving all nutrients and benefits
</p>
</div>
{/* Price & CTA */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[#1A1A1A]/6">
<div>
<span className="text-lg font-medium text-[#1A1A1A]">
{formatPrice(featuredProduct.price)}
</span>
<span className="text-xs text-[#4A4A4A]/60 ml-2">50ml</span>
</div>
<button
onClick={handleAddToCart}
className="inline-flex items-center gap-2 bg-[#1A1A1A] text-white px-4 py-2 text-sm font-medium hover:bg-[#1A1A1A]/90 transition-colors"
>
<ShoppingBag className="w-4 h-4" />
Add
</button>
</div>
</div>
</>
) : (
<div className="p-8 text-center">
<p className="text-[#4A4A4A]/70">Loading featured product...</p>
</div>
)}
</motion.div>
</div>
{/* Right Side Content */}
<div className="hidden lg:flex flex-1 items-center justify-end pr-10 xl:pr-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5 }}
className="max-w-xl text-right"
>
<span className="inline-block text-xs tracking-[0.3em] text-[#6B7280] mb-6">
PREMIUM NATURAL OILS
</span>
<h1 className="font-serif italic text-5xl xl:text-6xl text-[#1A1A1A] tracking-tight leading-[1.1] mb-6">
ManoonOils
</h1>
<p className="text-xl text-[#4A4A4A] font-light mb-8 leading-relaxed">
Discover our premium collection of natural oils for hair and skin
care. Handmade with love using only the finest ingredients.
</p>
<div className="flex gap-4 justify-end">
<Link
href="/products"
className="inline-block bg-[#1A1A1A] text-white px-8 py-4 text-sm tracking-wide hover:bg-[#1A1A1A]/90 transition-colors"
>
Shop Collection
</Link>
<Link
href="/about"
className="inline-block border border-[#1A1A1A] text-[#1A1A1A] px-8 py-4 text-sm tracking-wide hover:bg-[#1A1A1A] hover:text-white transition-colors"
>
Our Story
</Link>
</div>
</motion.div>
</div>
{/* Mobile CTA */}
<div className="lg:hidden relative z-10 px-6 pb-12">
<Link
href="/products"
className="block w-full bg-[#1A1A1A] text-white text-center py-4 text-sm tracking-wide"
>
Shop Now
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { motion } from "framer-motion";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
export default function NewsletterSection() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// TODO: Connect to newsletter service
setStatus("success");
setEmail("");
};
return (
<section className="border-t border-[#1A1A1A]/[0.06] py-14 lg:py-20 bg-white">
<div className="max-w-[1400px] mx-auto px-6">
<div className="max-w-2xl mx-auto text-center">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="font-serif italic text-4xl lg:text-5xl xl:text-[3.5rem] text-[#1A1A1A] tracking-tight leading-[1.1] mb-6"
>
Get 10% off your
<br />
first order
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-[#4A4A4A] mb-8"
>
Join the ManoonOils community and receive exclusive offers,
skincare tips, and early access to new products.
</motion.p>
<motion.form
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
onSubmit={handleSubmit}
className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto"
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
className="flex-1 px-4 py-3 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
/>
<button
type="submit"
className="inline-flex items-center justify-center gap-2 bg-[#1A1A1A] text-white px-6 py-3 text-sm font-medium hover:bg-[#1A1A1A]/90 transition-colors rounded-[4px]"
>
Subscribe
<ArrowRight className="w-4 h-4" />
</button>
</motion.form>
{status === "success" && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm text-emerald-600 mt-4"
>
Thank you! Check your email for your discount code.
</motion.p>
)}
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
className="text-xs text-[#4A4A4A]/60 mt-4"
>
By subscribing, you agree to our Privacy Policy. Unsubscribe
anytime.
</motion.p>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
const stats = [
{ value: "92%", label: "reported improved hair shine in 2 weeks" },
{ value: "87%", label: "saw visible reduction in dry skin" },
{ value: "95%", label: "noticed smoother, healthier hair texture" },
{ value: "89%", label: "experienced softer, more nourished skin" },
];
export default function StatsSection() {
return (
<section className="relative z-20 py-24 lg:py-32 bg-[#E8F4F8]">
<div className="max-w-[1400px] mx-auto px-6">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
{/* Left Content */}
<div>
<motion.span
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block"
>
Our Philosophy
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="font-serif italic text-4xl lg:text-5xl text-[#1A1A1A] tracking-tight leading-[1.1] mb-6"
>
Transformation
<br />
starts here
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-[#4A4A4A] leading-relaxed mb-10"
>
Every ManoonOils product is built on a simple promise: only
ingredients that serve a purpose. Our cold-pressed oils deliver
real nourishment for hair and skin, without the noise.
</motion.p>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-6">
{stats.map((stat, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 + index * 0.1 }}
className="relative"
>
<span className="font-serif text-[72px] leading-none text-[#1A1A1A]/[0.07] select-none absolute top-0 -left-2">
{stat.value.replace("%", "")}
</span>
<div className="relative pt-6">
<span className="text-3xl font-medium text-[#1A1A1A]">
{stat.value}
</span>
<p className="text-sm text-[#4A4A4A]/80 mt-1 leading-snug">
{stat.label}
</p>
</div>
</motion.div>
))}
</div>
</div>
{/* Right Image */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="relative aspect-[4/5] rounded-[4px] overflow-hidden bg-[#F0F7FA]"
>
<Image
src="https://minio-api.nodecrew.me/manoon-media/2024/10/manoon3-resized-1.jpg"
alt="ManoonOils Luksuzni Set"
fill
className="object-cover"
/>
</motion.div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { motion } from "framer-motion";
import { Star, Check } from "lucide-react";
const testimonials = [
{
id: 1,
name: "Sarah M.",
skinType: "Dry, sensitive skin",
text: "I've tried countless oils over the years, but ManoonOils is different. My skin has never felt this nourished and healthy. The argan oil is now a staple in my routine.",
verified: true,
},
{
id: 2,
name: "James K.",
skinType: "Hair care enthusiast",
text: "Finally found an oil that actually tames my frizz without making my hair greasy. The jojoba oil works wonders for my beard too. Highly recommend!",
verified: true,
},
{
id: 3,
name: "Emma L.",
skinType: "Combination skin",
text: "Was skeptical at first but after 3 weeks of using the rosehip oil, my skin texture has improved dramatically. The quality is unmatched.",
verified: true,
},
];
export default function TestimonialsSection() {
return (
<section className="py-24 lg:py-32 bg-[#F0F7FA]">
<div className="max-w-[1400px] mx-auto px-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<span className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block">
Testimonials
</span>
<h2 className="font-serif italic text-4xl lg:text-5xl text-[#1A1A1A] tracking-tight">
What our customers say
</h2>
</motion.div>
{/* Testimonials Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{testimonials.map((testimonial, index) => (
<motion.div
key={testimonial.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: index * 0.1 }}
className="bg-white rounded-[6px] border border-[#1A1A1A]/[0.06] p-9 flex flex-col"
>
{/* Stars */}
<div className="flex gap-1 mb-5">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className="w-4 h-4 fill-amber-400 text-amber-400"
/>
))}
</div>
{/* Quote */}
<p className="font-serif italic text-base lg:text-lg text-[#1A1A1A] leading-relaxed flex-1 mb-6">
&ldquo;{testimonial.text}&rdquo;
</p>
{/* Author */}
<div className="flex items-center justify-between pt-4 border-t border-[#1A1A1A]/[0.06]">
<div>
<p className="text-sm font-medium text-[#1A1A1A]">
{testimonial.name}
</p>
<p className="text-xs text-[#4A4A4A]/70">
{testimonial.skinType}
</p>
</div>
{testimonial.verified && (
<div className="inline-flex items-center gap-1 text-[10px] tracking-wider uppercase text-emerald-600 font-medium">
<Check className="w-3 h-3" />
Verified purchase
</div>
)}
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import Image from "next/image";
export default function Footer() {
const currentYear = new Date().getFullYear();
@@ -8,7 +9,13 @@ export default function Footer() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="md:col-span-2">
<h3 className="text-2xl font-serif mb-4">ManoonOils</h3>
<Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils"
width={180}
height={48}
className="h-10 w-auto object-contain mb-4"
/>
<p className="text-foreground-muted max-w-md">
Premium natural oils for hair and skin care. Crafted with love for your daily beauty routine.
</p>
@@ -18,17 +25,17 @@ export default function Footer() {
<h4 className="font-serif mb-4">Quick Links</h4>
<ul className="space-y-2">
<li>
<Link href="/en/products" className="text-foreground-muted hover:text-foreground transition-colors">
<Link href="/products" className="text-foreground-muted hover:text-foreground transition-colors">
Products
</Link>
</li>
<li>
<Link href="/en/about" className="text-foreground-muted hover:text-foreground transition-colors">
<Link href="/about" className="text-foreground-muted hover:text-foreground transition-colors">
About Us
</Link>
</li>
<li>
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
<Link href="/contact" className="text-foreground-muted hover:text-foreground transition-colors">
Contact
</Link>
</li>
@@ -39,12 +46,12 @@ export default function Footer() {
<h4 className="font-serif mb-4">Customer Service</h4>
<ul className="space-y-2">
<li>
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
<Link href="/contact" className="text-foreground-muted hover:text-foreground transition-colors">
Shipping Info
</Link>
</li>
<li>
<Link href="/en/contact" className="text-foreground-muted hover:text-foreground transition-colors">
<Link href="/contact" className="text-foreground-muted hover:text-foreground transition-colors">
Returns
</Link>
</li>

View File

@@ -1,76 +1,100 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { useCartStore } from "@/stores/cartStore";
import { formatPrice } from "@/lib/woocommerce";
import Image from "next/image";
import { AnimatePresence } from "framer-motion";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { User, ShoppingBag, Menu } from "lucide-react";
import MobileMenu from "./MobileMenu";
import CartDrawer from "@/components/cart/CartDrawer";
export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { items, toggleCart } = useCartStore();
const itemCount = items.reduce((count, item) => count + item.quantity, 0);
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
const itemCount = getLineCount();
// Initialize checkout on mount
useEffect(() => {
initCheckout();
}, [initCheckout]);
return (
<>
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 md:h-20">
<header className="sticky top-10 z-40 bg-white border-b border-[#1A1A1A]/[0.06]">
<div className="max-w-[1400px] mx-auto px-6">
<div className="flex items-center justify-between h-16">
{/* Mobile Menu Button */}
<button
className="md:hidden p-2"
className="lg:hidden p-2 -ml-2"
onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<Menu className="w-5 h-5" />
</button>
{/* Logo */}
<Link href="/" className="flex-shrink-0">
<img
src="/manoon-logo.jpg"
alt="ManoonOils"
className="h-8 w-[124px] md:h-10 md:w-[154px]"
<Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils"
width={150}
height={40}
className="h-8 w-auto object-contain"
/>
</Link>
<nav className="hidden md:flex items-center space-x-8">
<Link href="/products" className="text-foreground hover:text-accent-dark transition-colors">
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-8">
<Link
href="/products"
className="text-sm text-[#1A1A1A] hover:text-[#1A1A1A]/70 transition-colors"
>
Products
</Link>
<Link href="/about" className="text-foreground hover:text-accent-dark transition-colors">
<Link
href="/about"
className="text-sm text-[#1A1A1A] hover:text-[#1A1A1A]/70 transition-colors"
>
About
</Link>
<Link href="/contact" className="text-foreground hover:text-accent-dark transition-colors">
<Link
href="/contact"
className="text-sm text-[#1A1A1A] hover:text-[#1A1A1A]/70 transition-colors"
>
Contact
</Link>
</nav>
<button
className="p-2 relative"
onClick={toggleCart}
aria-label="Open cart"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
{itemCount > 0 && (
<span className="absolute -top-1 -right-1 bg-accent-dark text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
{itemCount}
</span>
)}
</button>
{/* Icons */}
<div className="flex items-center gap-1">
<button
className="p-2 hidden sm:block"
aria-label="Account"
>
<User className="w-5 h-5" />
</button>
<button
className="p-2 relative"
onClick={toggleCart}
aria-label="Open cart"
>
<ShoppingBag className="w-5 h-5" />
{itemCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-[#1A1A1A] text-white text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
{itemCount}
</span>
)}
</button>
</div>
</div>
</div>
</header>
<AnimatePresence>
{mobileMenuOpen && (
<MobileMenu onClose={() => setMobileMenuOpen(false)} />
)}
{mobileMenuOpen && <MobileMenu onClose={() => setMobileMenuOpen(false)} />}
</AnimatePresence>
<CartDrawer />

View File

@@ -3,15 +3,20 @@
import { motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
import type { Product } from "@/types/saleor";
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
interface ProductCardProps {
product: WooProduct;
product: Product;
index?: number;
locale?: string;
}
export default function ProductCard({ product, index = 0 }: ProductCardProps) {
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) {
const image = getProductImage(product);
const price = getProductPrice(product);
const localized = getLocalizedProduct(product, locale);
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
return (
<motion.div
@@ -20,29 +25,31 @@ export default function ProductCard({ product, index = 0 }: ProductCardProps) {
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={`/products/${product.slug}`} className="group block">
<Link href={`/products/${localized.slug}`} className="group block">
<div className="relative aspect-[4/5] bg-background-ice overflow-hidden mb-4">
{image && (
<Image
src={image}
alt={product.name}
alt={localized.name}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
{product.stock_status === "outofstock" && (
{!isAvailable && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-white font-medium">Out of Stock</span>
<span className="text-white font-medium">
{locale === "en" ? "Out of Stock" : "Nema na stanju"}
</span>
</div>
)}
</div>
<h3 className="font-serif text-lg mb-1 group-hover:text-accent-dark transition-colors">
{product.name}
{localized.name}
</h3>
<p className="text-foreground-muted">
{product.price ? formatPrice(product.price) : "Contact for price"}
{price || (locale === "en" ? "Contact for price" : "Kontaktirajte za cenu")}
</p>
</Link>
</motion.div>

View File

@@ -3,45 +3,55 @@
import { useState } from "react";
import Image from "next/image";
import { motion } from "framer-motion";
import { WooProduct, formatPrice, getProductImage } from "@/lib/woocommerce";
import { useCartStore } from "@/stores/cartStore";
import type { Product } from "@/types/saleor";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
import ProductCard from "@/components/product/ProductCard";
interface ProductDetailProps {
product: WooProduct;
relatedProducts: WooProduct[];
product: Product;
relatedProducts: Product[];
locale?: string;
}
export default function ProductDetail({ product, relatedProducts }: ProductDetailProps) {
export default function ProductDetail({ product, relatedProducts, locale = "SR" }: ProductDetailProps) {
const [selectedImage, setSelectedImage] = useState(0);
const [quantity, setQuantity] = useState(1);
const [activeTab, setActiveTab] = useState<"details" | "ingredients" | "usage">("details");
const addItem = useCartStore((state) => state.addItem);
const [isAdding, setIsAdding] = useState(false);
const { addLine, openCart } = useSaleorCheckoutStore();
const images = product.images?.length > 0
? product.images
: [{ id: 0, src: "/placeholder-product.jpg", alt: product.name }];
const localized = getLocalizedProduct(product, locale);
const variant = product.variants?.[0];
const images = product.media?.length > 0
? product.media
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" }];
const handleAddToCart = () => {
addItem({
id: product.id,
name: product.name,
price: product.price || product.regular_price,
quantity,
image: images[0]?.src || "",
sku: product.sku || "",
});
const handleAddToCart = async () => {
if (!variant?.id) return;
setIsAdding(true);
try {
await addLine(variant.id, quantity);
openCart();
} finally {
setIsAdding(false);
}
};
const stripHtml = (html: string) => {
if (!html) return "";
return html.replace(/<[^>]*>/g, "");
};
const isAvailable = variant?.quantityAvailable > 0;
const price = getProductPrice(product);
return (
<>
<section className="py-12 md:py-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Product Images */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
@@ -50,8 +60,8 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
<div className="relative aspect-square bg-background-ice mb-4">
{images[selectedImage] && (
<Image
src={images[selectedImage].src}
alt={images[selectedImage].alt || product.name}
src={images[selectedImage].url}
alt={images[selectedImage].alt || localized.name}
fill
className="object-cover"
priority
@@ -70,8 +80,8 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
}`}
>
<Image
src={image.src}
alt={image.alt || product.name}
src={image.url}
alt={image.alt || localized.name}
fill
className="object-cover"
/>
@@ -81,97 +91,102 @@ export default function ProductDetail({ product, relatedProducts }: ProductDetai
)}
</motion.div>
{/* Product Info */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<h1 className="text-3xl md:text-4xl font-serif mb-4">
{product.name}
{localized.name}
</h1>
<p className="text-2xl text-foreground-muted mb-6">
{product.price ? formatPrice(product.price) : "Contact for price"}
{price || (locale === "EN" ? "Contact for price" : "Kontaktirajte za cenu")}
</p>
{/* Short Description */}
<div className="prose prose-sm max-w-none mb-8 text-foreground-muted">
<p>{stripHtml(product.short_description || product.description.slice(0, 200))}</p>
<p>{stripHtml(localized.description).slice(0, 200)}...</p>
</div>
{product.stock_status === "instock" ? (
{/* Add to Cart */}
{isAvailable ? (
<div className="flex items-center gap-4 mb-8">
{/* Quantity Selector */}
<div className="flex items-center border border-border">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="px-4 py-3"
className="px-4 py-3 hover:bg-gray-50"
>
-
</button>
<span className="px-4 py-3">{quantity}</span>
<span className="px-4 py-3 min-w-[3rem] text-center">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="px-4 py-3"
className="px-4 py-3 hover:bg-gray-50"
>
+
</button>
</div>
{/* Add to Cart Button */}
<button
onClick={handleAddToCart}
className="flex-1 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
disabled={isAdding}
className="flex-1 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors disabled:opacity-50"
>
Add to Cart
{isAdding
? (locale === "EN" ? "Adding..." : "Dodavanje...")
: (locale === "EN" ? "Add to Cart" : "Dodaj u korpu")
}
</button>
</div>
) : (
<div className="py-3 bg-red-50 text-red-600 text-center mb-8">
Out of Stock
{locale === "EN" ? "Out of Stock" : "Nema na stanju"}
</div>
)}
<div className="border-t border-border/30">
<div className="flex border-b border-border/30">
{(["details", "ingredients", "usage"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-4 font-medium capitalize ${
activeTab === tab
? "border-b-2 border-foreground"
: "text-foreground-muted"
}`}
>
{tab}
</button>
))}
{/* SKU */}
{variant?.sku && (
<p className="text-sm text-foreground-muted mb-4">
SKU: {variant.sku}
</p>
)}
{/* Full Description */}
{localized.description && (
<div className="border-t border-border/30 pt-6">
<h3 className="font-serif text-lg mb-4">
{locale === "EN" ? "Description" : "Opis"}
</h3>
<div
className="prose max-w-none text-foreground-muted"
dangerouslySetInnerHTML={{ __html: localized.description }}
/>
</div>
<div className="py-6 text-foreground-muted">
{activeTab === "details" && (
<p>{stripHtml(product.description)}</p>
)}
{activeTab === "ingredients" && (
<p>Natural ingredients - Contact for detailed information.</p>
)}
{activeTab === "usage" && (
<p>Apply to clean skin or hair. Use daily for best results.</p>
)}
</div>
</div>
)}
</motion.div>
</div>
</div>
</section>
{/* Related Products */}
{relatedProducts.length > 0 && (
<section className="py-12 px-4 bg-background-ice">
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-serif text-center mb-8">
You May Also Like
{locale === "EN" ? "You May Also Like" : "Možda će vam se svideti"}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
{relatedProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
{relatedProducts.map((relatedProduct, index) => (
<ProductCard
key={relatedProduct.id}
product={relatedProduct}
index={index}
locale={locale}
/>
))}
</div>
</div>

View File

@@ -0,0 +1,51 @@
"use client";
import { cn } from "@/lib/utils";
interface MarqueeProps {
children: React.ReactNode;
className?: string;
speed?: "slow" | "normal" | "fast";
pauseOnHover?: boolean;
}
export default function Marquee({
children,
className,
speed = "normal",
pauseOnHover = false,
}: MarqueeProps) {
const speedClass = {
slow: "animate-marquee-slow",
normal: "animate-marquee",
fast: "animate-marquee-fast",
};
return (
<div
className={cn(
"flex overflow-hidden",
pauseOnHover && "hover:[animation-play-state:paused]",
className
)}
>
<div
className={cn(
"flex shrink-0 items-center whitespace-nowrap will-change-transform",
speedClass[speed]
)}
>
{children}
</div>
<div
className={cn(
"flex shrink-0 items-center whitespace-nowrap will-change-transform",
speedClass[speed]
)}
aria-hidden="true"
>
{children}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'sr'],
defaultLocale: 'sr'
locales: ['sr', 'en'],
defaultLocale: 'sr',
localePrefix: 'as-needed'
});

49
src/lib/saleor/client.ts Normal file
View File

@@ -0,0 +1,49 @@
import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
const httpLink = createHttpLink({
uri: process.env.NEXT_PUBLIC_SALEOR_API_URL || "http://localhost:8000/graphql/",
});
const authLink = setContext((_, { headers }) => {
// Saleor doesn't require auth for public queries
// Add auth token here if needed for admin operations
return {
headers: {
...headers,
"Content-Type": "application/json",
},
};
});
export const saleorClient = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
products: {
keyArgs: ["channel", "filter"],
merge(existing, incoming) {
if (!existing) return incoming;
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
},
},
},
},
},
}),
defaultOptions: {
watchQuery: {
fetchPolicy: "cache-first",
},
query: {
fetchPolicy: "cache-first",
},
},
});
export default saleorClient;

View File

@@ -0,0 +1,74 @@
import { gql } from "@apollo/client";
import { CHECKOUT_LINE_FRAGMENT } from "./Variant";
export const ADDRESS_FRAGMENT = gql`
fragment AddressFragment on Address {
id
firstName
lastName
companyName
streetAddress1
streetAddress2
city
postalCode
country {
code
country
}
countryArea
phone
isDefaultBillingAddress
isDefaultShippingAddress
}
`;
export const CHECKOUT_FRAGMENT = gql`
fragment CheckoutFragment on Checkout {
id
token
email
isShippingRequired
lines {
...CheckoutLineFragment
}
shippingPrice {
gross {
amount
currency
}
}
subtotalPrice {
gross {
amount
currency
}
}
totalPrice {
gross {
amount
currency
}
}
shippingAddress {
...AddressFragment
}
billingAddress {
...AddressFragment
}
shippingMethods {
id
name
price {
amount
currency
}
}
availablePaymentGateways {
id
name
}
note
}
${CHECKOUT_LINE_FRAGMENT}
${ADDRESS_FRAGMENT}
`;

View File

@@ -0,0 +1,81 @@
import { gql } from "@apollo/client";
import { PRODUCT_VARIANT_FRAGMENT } from "./Variant";
export const PRODUCT_FRAGMENT = gql`
fragment ProductFragment on Product {
id
name
slug
description
seoTitle
seoDescription
translation(languageCode: $locale) {
id
name
slug
description
seoTitle
seoDescription
}
variants {
...ProductVariantFragment
}
media {
id
url
alt
type
}
category {
id
name
slug
}
metadata {
key
value
}
}
${PRODUCT_VARIANT_FRAGMENT}
`;
export const PRODUCT_LIST_ITEM_FRAGMENT = gql`
fragment ProductListItemFragment on Product {
id
name
slug
description
translation(languageCode: $locale) {
id
name
slug
description
}
variants {
id
name
sku
quantityAvailable
pricing {
price {
gross {
amount
currency
}
}
onSale
discount {
gross {
amount
currency
}
}
}
}
media {
id
url
alt
}
}
`;

View File

@@ -0,0 +1,84 @@
import { gql } from "@apollo/client";
export const PRODUCT_VARIANT_FRAGMENT = gql`
fragment ProductVariantFragment on ProductVariant {
id
name
sku
quantityAvailable
weight {
value
unit
}
media {
id
url
alt
}
pricing {
price {
gross {
amount
currency
}
net {
amount
currency
}
}
onSale
discount {
gross {
amount
currency
}
}
}
attributes {
attribute {
name
slug
}
values {
name
slug
}
}
}
`;
export const CHECKOUT_LINE_FRAGMENT = gql`
fragment CheckoutLineFragment on CheckoutLine {
id
quantity
totalPrice {
gross {
amount
currency
}
}
variant {
id
name
sku
product {
id
name
slug
media {
id
url
alt
}
}
pricing {
price {
gross {
amount
currency
}
}
}
}
}
`;

35
src/lib/saleor/index.ts Normal file
View File

@@ -0,0 +1,35 @@
// Saleor GraphQL Client and Utilities
export { saleorClient } from "./client";
// Fragments
export { PRODUCT_FRAGMENT, PRODUCT_LIST_ITEM_FRAGMENT } from "./fragments/Product";
export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Variant";
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
// Queries
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY } from "./queries/Products";
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
// Mutations
export {
CHECKOUT_CREATE,
CHECKOUT_LINES_ADD,
CHECKOUT_LINES_UPDATE,
CHECKOUT_LINES_DELETE,
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
CHECKOUT_BILLING_ADDRESS_UPDATE,
CHECKOUT_SHIPPING_METHOD_UPDATE,
CHECKOUT_COMPLETE,
CHECKOUT_EMAIL_UPDATE,
} from "./mutations/Checkout";
// Helper functions
export {
getProducts,
getProductBySlug,
getProductPrice,
getProductImage,
isProductAvailable,
formatPrice,
getLocalizedProduct,
} from "./products";

View File

@@ -0,0 +1,154 @@
import { gql } from "@apollo/client";
import { CHECKOUT_FRAGMENT } from "../fragments/Checkout";
export const CHECKOUT_CREATE = gql`
mutation CheckoutCreate($input: CheckoutCreateInput!) {
checkoutCreate(input: $input) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;
export const CHECKOUT_LINES_ADD = gql`
mutation CheckoutLinesAdd($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lines) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;
export const CHECKOUT_LINES_UPDATE = gql`
mutation CheckoutLinesUpdate($checkoutId: ID!, $lines: [CheckoutLineUpdateInput!]!) {
checkoutLinesUpdate(checkoutId: $checkoutId, lines: $lines) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;
export const CHECKOUT_LINES_DELETE = gql`
mutation CheckoutLinesDelete($checkoutId: ID!, $lineIds: [ID!]!) {
checkoutLinesDelete(checkoutId: $checkoutId, lines: $lineIds) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;
export const CHECKOUT_SHIPPING_ADDRESS_UPDATE = gql`
mutation CheckoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;
export const CHECKOUT_BILLING_ADDRESS_UPDATE = gql`
mutation CheckoutBillingAddressUpdate($checkoutId: ID!, $billingAddress: AddressInput!) {
checkoutBillingAddressUpdate(checkoutId: $checkoutId, billingAddress: $billingAddress) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;
export const CHECKOUT_SHIPPING_METHOD_UPDATE = gql`
mutation CheckoutShippingMethodUpdate($checkoutId: ID!, $shippingMethodId: ID!) {
checkoutShippingMethodUpdate(checkoutId: $checkoutId, shippingMethodId: $shippingMethodId) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;
export const CHECKOUT_COMPLETE = gql`
mutation CheckoutComplete($checkoutId: ID!) {
checkoutComplete(checkoutId: $checkoutId) {
order {
id
number
status
created
total {
gross {
amount
currency
}
}
}
errors {
field
message
code
}
}
}
`;
export const CHECKOUT_EMAIL_UPDATE = gql`
mutation CheckoutEmailUpdate($checkoutId: ID!, $email: String!) {
checkoutEmailUpdate(checkoutId: $checkoutId, email: $email) {
checkout {
...CheckoutFragment
}
errors {
field
message
code
}
}
}
${CHECKOUT_FRAGMENT}
`;

105
src/lib/saleor/products.ts Normal file
View File

@@ -0,0 +1,105 @@
import { saleorClient } from "./client";
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products";
import type { Product, ProductList } from "@/types/saleor";
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
export async function getProducts(
locale: string = "SR",
first: number = 100
): Promise<Product[]> {
try {
const { data } = await saleorClient.query({
query: GET_PRODUCTS,
variables: {
channel: CHANNEL,
locale: locale.toUpperCase(),
first,
},
});
return data?.products?.edges.map((edge: { node: Product }) => edge.node) || [];
} catch (error) {
console.error("Error fetching products from Saleor:", error);
return [];
}
}
export async function getProductBySlug(
slug: string,
locale: string = "SR"
): Promise<Product | null> {
try {
const { data } = await saleorClient.query({
query: GET_PRODUCT_BY_SLUG,
variables: {
slug,
channel: CHANNEL,
locale: locale.toUpperCase(),
},
});
return data?.product || null;
} catch (error) {
console.error(`Error fetching product ${slug} from Saleor:`, error);
return null;
}
}
export function getProductPrice(product: Product): string {
const variant = product.variants?.[0];
if (!variant?.pricing?.price?.gross?.amount) {
return "";
}
return formatPrice(
variant.pricing.price.gross.amount,
variant.pricing.price.gross.currency
);
}
export function getProductImage(product: Product): string {
if (product.media && product.media.length > 0) {
return product.media[0].url;
}
if (product.variants?.[0]?.media && product.variants[0].media.length > 0) {
return product.variants[0].media[0].url;
}
return "/placeholder-product.jpg";
}
export function isProductAvailable(product: Product): boolean {
const variant = product.variants?.[0];
if (!variant) return false;
return (variant.quantityAvailable || 0) > 0;
}
export function formatPrice(amount: number, currency: string = "RSD"): string {
return new Intl.NumberFormat("sr-RS", {
style: "currency",
currency: currency,
minimumFractionDigits: 0,
}).format(amount);
}
// Get localized product data
export function getLocalizedProduct(
product: Product,
locale: string = "SR"
): {
name: string;
slug: string;
description: string;
seoTitle?: string;
seoDescription?: string;
} {
const isEnglish = locale.toLowerCase() === "en";
const translation = isEnglish ? product.translation : null;
return {
name: translation?.name || product.name,
slug: translation?.slug || product.slug,
description: translation?.description || product.description,
seoTitle: translation?.seoTitle || product.seoTitle,
seoDescription: translation?.seoDescription || product.seoDescription,
};
}

View File

@@ -0,0 +1,21 @@
import { gql } from "@apollo/client";
import { CHECKOUT_FRAGMENT } from "../fragments/Checkout";
export const GET_CHECKOUT = gql`
query GetCheckout($token: UUID!) {
checkout(token: $token) {
...CheckoutFragment
}
}
${CHECKOUT_FRAGMENT}
`;
export const GET_CHECKOUT_BY_ID = gql`
query GetCheckoutById($id: ID!) {
checkout(id: $id) {
...CheckoutFragment
}
}
${CHECKOUT_FRAGMENT}
`;
`;

View File

@@ -0,0 +1,51 @@
import { gql } from "@apollo/client";
import { PRODUCT_FRAGMENT, PRODUCT_LIST_ITEM_FRAGMENT } from "../fragments/Product";
export const GET_PRODUCTS = gql`
query GetProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
products(channel: $channel, first: $first) {
edges {
node {
...ProductListItemFragment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
${PRODUCT_LIST_ITEM_FRAGMENT}
`;
export const GET_PRODUCT_BY_SLUG = gql`
query GetProduct($slug: String!, $channel: String!, $locale: LanguageCodeEnum!) {
product(slug: $slug, channel: $channel) {
...ProductFragment
}
}
${PRODUCT_FRAGMENT}
`;
export const GET_PRODUCTS_BY_CATEGORY = gql`
query GetProductsByCategory(
$categorySlug: String!
$channel: String!
$locale: LanguageCodeEnum!
$first: Int!
) {
category(slug: $categorySlug) {
id
name
slug
products(channel: $channel, first: $first) {
edges {
node {
...ProductListItemFragment
}
}
}
}
}
${PRODUCT_LIST_ITEM_FRAGMENT}
`;

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,11 +1,28 @@
import WooCommerceRestApi from "@woocommerce/woocommerce-rest-api";
const api = new WooCommerceRestApi({
url: process.env.NEXT_PUBLIC_WOOCOMMERCE_URL || "",
consumerKey: process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY || "",
consumerSecret: process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET || "",
version: "wc/v3",
});
// Lazy initialization - only create API client when needed
let apiInstance: WooCommerceRestApi | null = null;
function getApi(): WooCommerceRestApi {
if (!apiInstance) {
const url = process.env.NEXT_PUBLIC_WOOCOMMERCE_URL;
const consumerKey = process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_KEY;
const consumerSecret = process.env.NEXT_PUBLIC_WOOCOMMERCE_CONSUMER_SECRET;
if (!url || !consumerKey || !consumerSecret) {
throw new Error("WooCommerce API credentials not configured");
}
apiInstance = new WooCommerceRestApi({
url,
consumerKey,
consumerSecret,
version: "wc/v3",
queryStringAuth: true, // Use query string auth instead of basic auth (more reliable)
});
}
return apiInstance;
}
export interface WooProduct {
id: number;
@@ -34,6 +51,7 @@ export interface WooCategory {
export async function getProducts(perPage = 100): Promise<WooProduct[]> {
try {
const api = getApi();
const response = await api.get("products", { per_page: perPage });
return response.data;
} catch (error) {
@@ -44,6 +62,7 @@ export async function getProducts(perPage = 100): Promise<WooProduct[]> {
export async function getProduct(id: number): Promise<WooProduct | null> {
try {
const api = getApi();
const response = await api.get(`products/${id}`);
return response.data;
} catch (error) {
@@ -54,6 +73,7 @@ export async function getProduct(id: number): Promise<WooProduct | null> {
export async function getProductBySlug(slug: string): Promise<WooProduct | null> {
try {
const api = getApi();
const response = await api.get("products", { slug });
return response.data[0] || null;
} catch (error) {
@@ -64,6 +84,7 @@ export async function getProductBySlug(slug: string): Promise<WooProduct | null>
export async function getCategories(): Promise<WooCategory[]> {
try {
const api = getApi();
const response = await api.get("product-categories", { per_page: 100 });
return response.data;
} catch (error) {
@@ -76,6 +97,7 @@ export async function getProductsByCategory(
categoryId: number
): Promise<WooProduct[]> {
try {
const api = getApi();
const response = await api.get("products", {
category: categoryId,
per_page: 100,
@@ -104,4 +126,4 @@ export function getProductImage(product: WooProduct): string {
return "/placeholder-product.jpg";
}
export default api;
export default getApi;

View File

@@ -1,13 +0,0 @@
import createMiddleware from "next-intl/middleware";
import { defineRouting } from 'next-intl/routing';
const routing = defineRouting({
locales: ['en', 'sr'],
defaultLocale: 'sr'
});
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|).*)']
};

View File

@@ -0,0 +1,281 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { saleorClient } from "@/lib/saleor/client";
import {
CHECKOUT_CREATE,
CHECKOUT_LINES_ADD,
CHECKOUT_LINES_UPDATE,
CHECKOUT_LINES_DELETE,
CHECKOUT_EMAIL_UPDATE,
} from "@/lib/saleor/mutations/Checkout";
import { GET_CHECKOUT } from "@/lib/saleor/queries/Checkout";
import type { Checkout, CheckoutLine } from "@/types/saleor";
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
interface SaleorCheckoutStore {
checkout: Checkout | null;
checkoutToken: string | null;
isOpen: boolean;
isLoading: boolean;
error: string | null;
// Actions
initCheckout: () => Promise<void>;
addLine: (variantId: string, quantity: number) => Promise<void>;
updateLine: (lineId: string, quantity: number) => Promise<void>;
removeLine: (lineId: string) => Promise<void>;
setEmail: (email: string) => Promise<void>;
refreshCheckout: () => Promise<void>;
toggleCart: () => void;
openCart: () => void;
closeCart: () => void;
clearError: () => void;
// Getters
getLineCount: () => number;
getTotal: () => number;
getLines: () => CheckoutLine[];
}
export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
persist(
(set, get) => ({
checkout: null,
checkoutToken: null,
isOpen: false,
isLoading: false,
error: null,
initCheckout: async () => {
const { checkoutToken } = get();
if (checkoutToken) {
// Try to fetch existing checkout
try {
const { data } = await saleorClient.query({
query: GET_CHECKOUT,
variables: { token: checkoutToken },
});
if (data?.checkout) {
set({ checkout: data.checkout });
return;
}
} catch (e) {
// Checkout not found or expired, create new one
}
}
// Create new checkout
try {
const { data } = await saleorClient.mutate({
mutation: CHECKOUT_CREATE,
variables: {
input: {
channel: CHANNEL,
lines: [],
},
},
});
if (data?.checkoutCreate?.checkout) {
set({
checkout: data.checkoutCreate.checkout,
checkoutToken: data.checkoutCreate.checkout.token,
});
}
} catch (e: any) {
set({ error: e.message });
}
},
addLine: async (variantId: string, quantity: number) => {
set({ isLoading: true, error: null });
try {
let { checkout, checkoutToken } = get();
// Initialize checkout if needed
if (!checkout) {
await get().initCheckout();
checkout = get().checkout;
checkoutToken = get().checkoutToken;
}
if (!checkout) {
throw new Error("Failed to initialize checkout");
}
const { data } = await saleorClient.mutate({
mutation: CHECKOUT_LINES_ADD,
variables: {
checkoutId: checkout.id,
lines: [{ variantId, quantity }],
},
});
if (data?.checkoutLinesAdd?.checkout) {
set({
checkout: data.checkoutLinesAdd.checkout,
isOpen: true,
isLoading: false,
});
} else if (data?.checkoutLinesAdd?.errors?.length > 0) {
throw new Error(data.checkoutLinesAdd.errors[0].message);
}
} catch (e: any) {
set({ error: e.message, isLoading: false });
}
},
updateLine: async (lineId: string, quantity: number) => {
set({ isLoading: true, error: null });
try {
const { checkout } = get();
if (!checkout) {
throw new Error("No active checkout");
}
if (quantity <= 0) {
// Remove line if quantity is 0 or less
await get().removeLine(lineId);
return;
}
const { data } = await saleorClient.mutate({
mutation: CHECKOUT_LINES_UPDATE,
variables: {
checkoutId: checkout.id,
lines: [{ lineId, quantity }],
},
});
if (data?.checkoutLinesUpdate?.checkout) {
set({
checkout: data.checkoutLinesUpdate.checkout,
isLoading: false,
});
} else if (data?.checkoutLinesUpdate?.errors?.length > 0) {
throw new Error(data.checkoutLinesUpdate.errors[0].message);
}
} catch (e: any) {
set({ error: e.message, isLoading: false });
}
},
removeLine: async (lineId: string) => {
set({ isLoading: true, error: null });
try {
const { checkout } = get();
if (!checkout) {
throw new Error("No active checkout");
}
const { data } = await saleorClient.mutate({
mutation: CHECKOUT_LINES_DELETE,
variables: {
checkoutId: checkout.id,
lineIds: [lineId],
},
});
if (data?.checkoutLinesDelete?.checkout) {
set({
checkout: data.checkoutLinesDelete.checkout,
isLoading: false,
});
} else if (data?.checkoutLinesDelete?.errors?.length > 0) {
throw new Error(data.checkoutLinesDelete.errors[0].message);
}
} catch (e: any) {
set({ error: e.message, isLoading: false });
}
},
setEmail: async (email: string) => {
set({ isLoading: true, error: null });
try {
const { checkout } = get();
if (!checkout) {
throw new Error("No active checkout");
}
const { data } = await saleorClient.mutate({
mutation: CHECKOUT_EMAIL_UPDATE,
variables: {
checkoutId: checkout.id,
email,
},
});
if (data?.checkoutEmailUpdate?.checkout) {
set({
checkout: data.checkoutEmailUpdate.checkout,
isLoading: false,
});
} else if (data?.checkoutEmailUpdate?.errors?.length > 0) {
throw new Error(data.checkoutEmailUpdate.errors[0].message);
}
} catch (e: any) {
set({ error: e.message, isLoading: false });
}
},
refreshCheckout: async () => {
const { checkoutToken } = get();
if (!checkoutToken) return;
try {
const { data } = await saleorClient.query({
query: GET_CHECKOUT,
variables: { token: checkoutToken },
});
if (data?.checkout) {
set({ checkout: data.checkout });
}
} catch (e) {
// Checkout might be expired
set({ checkout: null, checkoutToken: null });
}
},
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
clearError: () => set({ error: null }),
getLineCount: () => {
const { checkout } = get();
if (!checkout?.lines) return 0;
return checkout.lines.reduce((count, line) => count + line.quantity, 0);
},
getTotal: () => {
const { checkout } = get();
return checkout?.totalPrice?.gross?.amount || 0;
},
getLines: () => {
const { checkout } = get();
return checkout?.lines || [];
},
}),
{
name: "manoonoils-saleor-checkout",
partialize: (state) => ({
checkoutToken: state.checkoutToken,
}),
}
)
);

191
src/types/saleor.ts Normal file
View File

@@ -0,0 +1,191 @@
// Saleor GraphQL Types
export interface Money {
amount: number;
currency: string;
}
export interface Price {
gross: Money;
net?: Money;
}
export interface DiscountedPrice {
gross: Money;
}
export interface ProductMedia {
id: string;
url: string;
alt: string;
type: string;
}
export interface ProductAttributeValue {
name: string;
slug: string;
}
export interface ProductAttribute {
attribute: {
name: string;
slug: string;
};
values: ProductAttributeValue[];
}
export interface ProductVariantPricing {
price: Price;
onSale: boolean;
discount?: DiscountedPrice;
}
export interface ProductVariant {
id: string;
name: string;
sku: string;
quantityAvailable: number;
weight?: {
value: number;
unit: string;
};
media?: ProductMedia[];
pricing?: ProductVariantPricing;
attributes?: ProductAttribute[];
}
export interface ProductTranslation {
id: string;
name: string;
slug: string;
description: string;
seoTitle?: string;
seoDescription?: string;
}
export interface Product {
id: string;
name: string;
slug: string;
description: string;
seoTitle?: string;
seoDescription?: string;
translation?: ProductTranslation;
variants: ProductVariant[];
media: ProductMedia[];
category?: {
id: string;
name: string;
slug: string;
};
metadata?: {
key: string;
value: string;
}[];
}
export interface ProductEdge {
node: Product;
}
export interface ProductList {
edges: ProductEdge[];
pageInfo: {
hasNextPage: boolean;
endCursor?: string;
};
}
// Checkout Types
export interface Address {
id?: string;
firstName: string;
lastName: string;
companyName?: string;
streetAddress1: string;
streetAddress2?: string;
city: string;
postalCode: string;
country: {
code: string;
country: string;
};
countryArea?: string;
phone?: string;
}
export interface CheckoutLine {
id: string;
quantity: number;
totalPrice: Price;
variant: ProductVariant & {
product: {
id: string;
name: string;
slug: string;
media: ProductMedia[];
};
};
}
export interface ShippingMethod {
id: string;
name: string;
price: Money;
}
export interface PaymentGateway {
id: string;
name: string;
}
export interface Checkout {
id: string;
token: string;
email?: string;
isShippingRequired: boolean;
lines: CheckoutLine[];
shippingPrice: Price;
subtotalPrice: Price;
totalPrice: Price;
shippingAddress?: Address;
billingAddress?: Address;
shippingMethods: ShippingMethod[];
availablePaymentGateways: PaymentGateway[];
note?: string;
}
// Order Types
export interface Order {
id: string;
number: string;
status: string;
created: string;
total: Price;
}
// API Response Types
export interface CheckoutCreateResponse {
checkoutCreate: {
checkout?: Checkout;
errors: Array<{
field: string;
message: string;
code: string;
}>;
};
}
export interface CheckoutCompleteResponse {
checkoutComplete: {
order?: Order;
errors: Array<{
field: string;
message: string;
code: string;
}>;
};
}