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
This commit is contained in:
Unchained
2026-03-21 12:36:21 +02:00
parent db1914d69b
commit 7b94537670
27 changed files with 7879 additions and 3 deletions

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** | - |

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

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

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

153
package-lock.json generated
View File

@@ -8,9 +8,11 @@
"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",
@@ -43,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",
@@ -512,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",
@@ -2106,7 +2159,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2701,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",
@@ -3301,7 +3402,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -4523,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",
@@ -6113,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",
@@ -6549,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",
@@ -7340,7 +7487,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@@ -9,9 +9,11 @@
"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",

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)

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

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